diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-19 09:08:42 +0000 |
commit | b76ae638462ab0f673e5915986070518dd3f9ad3 (patch) | |
tree | bdab0533383b52873be0ec0eb4d3c66598ff8b91 /app/assets/javascripts/vue_shared | |
parent | 434373eabe7b4be9593d18a585fb763f1e5f1a6f (diff) | |
download | gitlab-ce-b76ae638462ab0f673e5915986070518dd3f9ad3.tar.gz |
Add latest changes from gitlab-org/gitlab@14-2-stable-eev14.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared')
51 files changed, 1001 insertions, 822 deletions
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js new file mode 100644 index 00000000000..eeed5e9dc3a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.stories.js @@ -0,0 +1,27 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import { __ } from '~/locale'; +import DropdownWidget from './dropdown_widget.vue'; + +export default { + component: DropdownWidget, + title: 'vue_shared/components/dropdown/dropdown_widget/dropdown_widget', +}; + +const Template = (args, { argTypes }) => ({ + components: { DropdownWidget }, + props: Object.keys(argTypes), + template: '<dropdown-widget v-bind="$props" v-on="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + options: [ + { id: 'gid://gitlab/Milestone/-1', title: __('Any Milestone') }, + { id: 'gid://gitlab/Milestone/0', title: __('No Milestone') }, + { id: 'gid://gitlab/Milestone/-2', title: __('Upcoming') }, + { id: 'gid://gitlab/Milestone/-3', title: __('Started') }, + ], + selectText: 'Select', + searchText: 'Search', +}; diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue new file mode 100644 index 00000000000..7859ef85dd8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -0,0 +1,165 @@ +<script> +import { + GlLoadingIcon, + GlDropdown, + GlDropdownForm, + GlDropdownDivider, + GlDropdownItem, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlLoadingIcon, + GlDropdown, + GlDropdownForm, + GlDropdownDivider, + GlDropdownItem, + GlSearchBoxByType, + }, + props: { + selectText: { + type: String, + required: false, + default: __('Select'), + }, + searchText: { + type: String, + required: false, + default: __('Search'), + }, + presetOptions: { + type: Array, + required: false, + default: () => [], + }, + options: { + type: Array, + required: false, + default: () => [], + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + selected: { + type: Object, + required: false, + default: () => {}, + }, + searchTerm: { + type: String, + required: false, + default: '', + }, + }, + computed: { + isSearchEmpty() { + return this.searchTerm === '' && !this.isLoading; + }, + noOptionsFound() { + return !this.isSearchEmpty && this.options.length === 0; + }, + }, + methods: { + selectOption(option) { + this.$emit('set-option', option || null); + }, + isSelected(option) { + return ( + this.selected && + ((option.name && this.selected.name === option.name) || + (option.title && this.selected.title === option.title)) + ); + }, + showDropdown() { + this.$refs.dropdown.show(); + }, + setFocus() { + this.$refs.search.focusInput(); + }, + setSearchTerm(search) { + this.$emit('set-search', search); + }, + avatarUrl(option) { + return option.avatar_url || option.avatarUrl || null; + }, + secondaryText(option) { + // TODO: this has some knowledge of the context where the component is used. We could later rework it. + return option.username || null; + }, + }, + i18n: { + noMatchingResults: __('No matching results'), + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + :text="selectText" + lazy + menu-class="gl-w-full!" + class="gl-w-full" + v-on="$listeners" + @shown="setFocus" + > + <template #header> + <gl-search-box-by-type + ref="search" + :value="searchTerm" + :placeholder="searchText" + class="js-dropdown-input-field" + @input="setSearchTerm" + /> + </template> + <gl-dropdown-form class="gl-relative gl-min-h-7"> + <gl-loading-icon + v-if="isLoading" + size="md" + class="gl-absolute gl-left-0 gl-top-0 gl-right-0" + /> + <template v-else> + <template v-if="isSearchEmpty && presetOptions.length > 0"> + <gl-dropdown-item + v-for="option in presetOptions" + :key="option.id" + :is-checked="isSelected(option)" + :is-check-centered="true" + :is-check-item="true" + @click="selectOption(option)" + > + <slot name="preset-item" :item="option"> + {{ option.title }} + </slot> + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> + <gl-dropdown-item + v-for="option in options" + :key="option.id" + :is-checked="isSelected(option)" + :is-check-centered="true" + :is-check-item="true" + :avatar-url="avatarUrl(option)" + :secondary-text="secondaryText(option)" + data-testid="unselected-option" + @click="selectOption(option)" + > + <slot name="item" :item="option"> + {{ option.title }} + </slot> + </gl-dropdown-item> + <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> + {{ $options.i18n.noMatchingResults }} + </gl-dropdown-item> + </template> + </gl-dropdown-form> + <template #footer> + <slot name="footer"></slot> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 994ce6a762a..2e9634819a0 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -2,10 +2,14 @@ import { __ } from '~/locale'; export const DEBOUNCE_DELAY = 200; export const MAX_RECENT_TOKENS_SIZE = 3; +export const WEIGHT_TOKEN_SUGGESTIONS_SIZE = 21; export const FILTER_NONE = 'None'; export const FILTER_ANY = 'Any'; export const FILTER_CURRENT = 'Current'; +export const FILTER_UPCOMING = 'Upcoming'; +export const FILTER_STARTED = 'Started'; +export const FILTER_NONE_ANY = [FILTER_NONE, FILTER_ANY]; export const OPERATOR_IS = '='; export const OPERATOR_IS_TEXT = __('is'); @@ -24,11 +28,9 @@ export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([ { value: FILTER_CURRENT, text: __(FILTER_CURRENT) }, ]); -export const DEFAULT_LABELS = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; - export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ - { value: 'Upcoming', text: __('Upcoming') }, // eslint-disable-line @gitlab/require-i18n-strings - { value: 'Started', text: __('Started') }, // eslint-disable-line @gitlab/require-i18n-strings + { value: FILTER_UPCOMING, text: __(FILTER_UPCOMING) }, + { value: FILTER_STARTED, text: __(FILTER_STARTED) }, ]); export const SortDirection = { @@ -36,12 +38,14 @@ export const SortDirection = { ascending: 'ascending', }; +export const FILTERED_SEARCH_LABELS = 'labels'; export const FILTERED_SEARCH_TERM = 'filtered-search-term'; export const TOKEN_TITLE_AUTHOR = __('Author'); export const TOKEN_TITLE_ASSIGNEE = __('Assignee'); export const TOKEN_TITLE_MILESTONE = __('Milestone'); export const TOKEN_TITLE_LABEL = __('Label'); +export const TOKEN_TITLE_TYPE = __('Type'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); export const TOKEN_TITLE_ITERATION = __('Iteration'); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 5ab287150f2..9dc5c5db276 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -16,7 +16,7 @@ import createFlash from '~/flash'; import { __ } from '~/locale'; import { SortDirection } from './constants'; -import { stripQuotes, uniqueTokens } from './filtered_search_utils'; +import { filterEmptySearchTerm, stripQuotes, uniqueTokens } from './filtered_search_utils'; export default { components: { @@ -223,9 +223,14 @@ export default { // Put any searches that may have come in before // we fetched the saved searches ahead of the already saved ones - const resultantSearches = this.recentSearchesStore.setRecentSearches( + let resultantSearches = this.recentSearchesStore.setRecentSearches( this.recentSearchesStore.state.recentSearches.concat(searches), ); + // If visited URL has search params, add them to recent search store + if (filterEmptySearchTerm(this.filterValue).length) { + resultantSearches = this.recentSearchesStore.addRecentSearch(this.filterValue); + } + this.recentSearchesService.save(resultantSearches); this.recentSearches = resultantSearches; }); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js index 571d24b50cf..6573f366b52 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -247,3 +247,12 @@ export function setTokenValueToRecentlyUsed(recentSuggestionsStorageKey, tokenVa ); } } + +/** + * Removes `FILTERED_SEARCH_TERM` tokens with empty data + * + * @param filterTokens array of filtered search tokens + * @return {Array} array of filtered search tokens + */ +export const filterEmptySearchTerm = (filterTokens = []) => + filterTokens.filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index a25a19a006c..ae5d3965de1 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -31,19 +31,25 @@ export default { data() { return { authors: this.config.initialAuthors || [], - defaultAuthors: this.config.defaultAuthors || [DEFAULT_LABEL_ANY], - preloadedAuthors: this.config.preloadedAuthors || [], loading: false, }; }, + computed: { + defaultAuthors() { + return this.config.defaultAuthors || [DEFAULT_LABEL_ANY]; + }, + preloadedAuthors() { + return this.config.preloadedAuthors || []; + }, + }, methods: { - getActiveAuthor(authors, currentValue) { - return authors.find((author) => author.username.toLowerCase() === currentValue); + getActiveAuthor(authors, data) { + return authors.find((author) => author.username.toLowerCase() === data.toLowerCase()); }, getAvatarUrl(author) { return author.avatarUrl || author.avatar_url; }, - fetchAuthorBySearchTerm(searchTerm) { + fetchAuthors(searchTerm) { this.loading = true; const fetchPromise = this.config.fetchPath ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) @@ -76,11 +82,11 @@ export default { :active="active" :suggestions-loading="loading" :suggestions="authors" - :fn-active-token-value="getActiveAuthor" + :get-active-token-value="getActiveAuthor" :default-suggestions="defaultAuthors" :preloaded-suggestions="preloadedAuthors" :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" - @fetch-suggestions="fetchAuthorBySearchTerm" + @fetch-suggestions="fetchAuthors" v-on="$listeners" > <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> @@ -91,7 +97,7 @@ export default { shape="circle" class="gl-mr-2" /> - <span>{{ activeTokenValue ? activeTokenValue.name : inputValue }}</span> + {{ activeTokenValue ? activeTokenValue.name : inputValue }} </template> <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index a4804525a53..d1326e96794 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -8,7 +8,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { DEBOUNCE_DELAY } from '../constants'; +import { DEBOUNCE_DELAY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; export default { @@ -42,12 +42,10 @@ export default { required: false, default: () => [], }, - fnActiveTokenValue: { + getActiveTokenValue: { type: Function, required: false, - default: (suggestions, currentTokenValue) => { - return suggestions.find(({ value }) => value === currentTokenValue); - }, + default: (suggestions, data) => suggestions.find(({ value }) => value === data), }, defaultSuggestions: { type: Array, @@ -69,11 +67,6 @@ export default { required: false, default: 'id', }, - fnCurrentTokenValue: { - type: Function, - required: false, - default: null, - }, }, data() { return { @@ -81,7 +74,6 @@ export default { recentSuggestions: this.recentSuggestionsStorageKey ? getRecentlyUsedSuggestions(this.recentSuggestionsStorageKey) : [], - loading: false, }; }, computed: { @@ -94,14 +86,16 @@ export default { preloadedTokenIds() { return this.preloadedSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); }, - currentTokenValue() { - if (this.fnCurrentTokenValue) { - return this.fnCurrentTokenValue(this.value.data); - } - return this.value.data.toLowerCase(); - }, activeTokenValue() { - return this.fnActiveTokenValue(this.suggestions, this.currentTokenValue); + return this.getActiveTokenValue(this.suggestions, this.value.data); + }, + availableDefaultSuggestions() { + if (this.value.operator === OPERATOR_IS_NOT) { + return this.defaultSuggestions.filter( + (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value), + ); + } + return this.defaultSuggestions; }, /** * Return all the suggestions when searchKey is present @@ -117,6 +111,29 @@ export default { !this.preloadedTokenIds.includes(tokenValue[this.valueIdentifier]), ); }, + showDefaultSuggestions() { + return this.availableDefaultSuggestions.length; + }, + showRecentSuggestions() { + return this.isRecentSuggestionsEnabled && this.recentSuggestions.length && !this.searchKey; + }, + showPreloadedSuggestions() { + return this.preloadedSuggestions.length && !this.searchKey; + }, + showAvailableSuggestions() { + return this.availableSuggestions.length; + }, + showSuggestions() { + // These conditions must match the template under `#suggestions` slot + // See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65817#note_632619411 + return ( + this.showDefaultSuggestions || + this.showRecentSuggestions || + this.showPreloadedSuggestions || + this.suggestionsLoading || + this.showAvailableSuggestions + ); + }, }, watch: { active: { @@ -168,10 +185,10 @@ export default { <template #view="viewTokenProps"> <slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot> </template> - <template #suggestions> - <template v-if="defaultSuggestions.length"> + <template v-if="showSuggestions" #suggestions> + <template v-if="showDefaultSuggestions"> <gl-filtered-search-suggestion - v-for="token in defaultSuggestions" + v-for="token in availableDefaultSuggestions" :key="token.value" :value="token.value" > @@ -179,13 +196,13 @@ export default { </gl-filtered-search-suggestion> <gl-dropdown-divider /> </template> - <template v-if="isRecentSuggestionsEnabled && recentSuggestions.length && !searchKey"> + <template v-if="showRecentSuggestions"> <gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header> <slot name="suggestions-list" :suggestions="recentSuggestions"></slot> <gl-dropdown-divider /> </template> <slot - v-if="preloadedSuggestions.length && !searchKey" + v-if="showPreloadedSuggestions" name="suggestions-list" :suggestions="preloadedSuggestions" ></slot> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue index 5859fd10688..4ecfc1cf40c 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/branch_token.vue @@ -1,27 +1,19 @@ <script> -import { - GlToken, - GlFilteredSearchToken, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; - +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; - -import { DEBOUNCE_DELAY } from '../constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; export default { components: { - GlToken, - GlFilteredSearchToken, + BaseToken, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -34,82 +26,62 @@ export default { data() { return { branches: this.config.initialBranches || [], - defaultBranches: this.config.defaultBranches || [], - loading: true, + loading: false, }; }, computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeBranch() { - return this.branches.find((branch) => branch.name.toLowerCase() === this.currentValue); - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.branches.length) { - this.fetchBranchBySearchTerm(this.value.data); - } - }, + defaultBranches() { + return this.config.defaultBranches || []; }, }, methods: { - fetchBranchBySearchTerm(searchTerm) { + getActiveBranch(branches, data) { + return branches.find((branch) => branch.name.toLowerCase() === data.toLowerCase()); + }, + fetchBranches(searchTerm) { this.loading = true; this.config .fetchBranches(searchTerm) .then(({ data }) => { this.branches = data; }) - .catch(() => createFlash({ message: __('There was a problem fetching branches.') })) + .catch(() => { + createFlash({ message: __('There was a problem fetching branches.') }); + }) .finally(() => { this.loading = false; }); }, - searchBranches: debounce(function debouncedSearch({ data }) { - this.fetchBranchBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultBranches" + :suggestions="branches" + :suggestions-loading="loading" + :get-active-token-value="getActiveBranch" + @fetch-suggestions="fetchBranches" v-on="$listeners" - @input="searchBranches" > - <template #view-token="{ inputValue }"> - <gl-token variant="search-value">{{ - activeBranch ? activeBranch.name : inputValue - }}</gl-token> + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? activeTokenValue.name : inputValue }} </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="branch in defaultBranches" - :key="branch.value" - :value="branch.value" + v-for="branch in suggestions" + :key="branch.id" + :value="branch.name" > - {{ branch.text }} + <div class="gl-display-flex"> + <span class="gl-display-inline-block gl-mr-3 gl-p-3"></span> + {{ branch.name }} + </div> </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultBranches.length" /> - <gl-loading-icon v-if="loading" size="sm" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="branch in branches" - :key="branch.id" - :value="branch.name" - > - <div class="gl-display-flex"> - <span class="gl-display-inline-block gl-mr-3 gl-p-3"></span> - <div>{{ branch.name }}</div> - </div> - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue index d186f46866c..5a69751a2cc 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -1,26 +1,21 @@ <script> -import { - GlFilteredSearchToken, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; - +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; - -import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { DEFAULT_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; export default { components: { - GlFilteredSearchToken, + BaseToken, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -33,87 +28,63 @@ export default { data() { return { emojis: this.config.initialEmojis || [], - defaultEmojis: this.config.defaultEmojis || DEFAULT_NONE_ANY, - loading: true, + loading: false, }; }, computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeEmoji() { - return this.emojis.find( - (emoji) => emoji.name.toLowerCase() === stripQuotes(this.currentValue), - ); - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.emojis.length) { - this.fetchEmojiBySearchTerm(this.value.data); - } - }, + defaultEmojis() { + return this.config.defaultEmojis || DEFAULT_NONE_ANY; }, }, methods: { - fetchEmojiBySearchTerm(searchTerm) { + getActiveEmoji(emojis, data) { + return emojis.find((emoji) => emoji.name.toLowerCase() === stripQuotes(data).toLowerCase()); + }, + fetchEmojis(searchTerm) { this.loading = true; this.config .fetchEmojis(searchTerm) - .then((res) => { - this.emojis = Array.isArray(res) ? res : res.data; + .then((response) => { + this.emojis = Array.isArray(response) ? response : response.data; + }) + .catch(() => { + createFlash({ message: __('There was a problem fetching emojis.') }); }) - .catch(() => - createFlash({ - message: __('There was a problem fetching emojis.'), - }), - ) .finally(() => { this.loading = false; }); }, - searchEmojis: debounce(function debouncedSearch({ data }) { - this.fetchEmojiBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultEmojis" + :suggestions="emojis" + :suggestions-loading="loading" + :get-active-token-value="getActiveEmoji" + @fetch-suggestions="fetchEmojis" v-on="$listeners" - @input="searchEmojis" > - <template #view="{ inputValue }"> - <gl-emoji v-if="activeEmoji" :data-name="activeEmoji.name" /> - <span v-else>{{ inputValue }}</span> + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + <gl-emoji v-if="activeTokenValue" :data-name="activeTokenValue.name" /> + <template v-else>{{ inputValue }}</template> </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="emoji in defaultEmojis" - :key="emoji.value" - :value="emoji.value" + v-for="emoji in suggestions" + :key="emoji.name" + :value="emoji.name" > - {{ emoji.value }} + <div class="gl-display-flex"> + <gl-emoji class="gl-mr-3" :data-name="emoji.name" /> + {{ emoji.name }} + </div> </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultEmojis.length" /> - <gl-loading-icon v-if="loading" size="sm" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="emoji in emojis" - :key="emoji.name" - :value="emoji.name" - > - <div class="gl-display-flex"> - <gl-emoji :data-name="emoji.name" /> - <span class="gl-ml-3">{{ emoji.name }}</span> - </div> - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue index aa234cf86d9..9f68308808e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue @@ -8,7 +8,7 @@ import { import { debounce } from 'lodash'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants'; +import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; export default { separator: '::&', @@ -48,6 +48,14 @@ export default { defaultEpics() { return this.config.defaultEpics || DEFAULT_NONE_ANY; }, + availableDefaultEpics() { + if (this.value.operator === OPERATOR_IS_NOT) { + return this.defaultEpics.filter( + (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value), + ); + } + return this.defaultEpics; + }, activeEpic() { if (this.currentValue && this.epics.length) { // Check if current value is an epic ID. @@ -99,7 +107,7 @@ export default { // We don't have any information about selected token except for its // group path and iid joined by separator, so we need to manually // compose epic path from it. - if (data.includes(this.$options.separator)) { + if (data.includes?.(this.$options.separator)) { const [groupPath, epicIid] = data.split(this.$options.separator); epicPath = `/groups/${groupPath}/-/epics/${epicIid}`; } @@ -127,13 +135,13 @@ export default { </template> <template #suggestions> <gl-filtered-search-suggestion - v-for="epic in defaultEpics" + v-for="epic in availableDefaultEpics" :key="epic.value" :value="epic.value" > {{ epic.text }} </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultEpics.length" /> + <gl-dropdown-divider v-if="availableDefaultEpics.length" /> <gl-loading-icon v-if="loading" size="sm" /> <template v-else> <gl-filtered-search-suggestion v-for="epic in epics" :key="epic.id" :value="getValue(epic)"> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue index ba8b2421726..c1d1bc7da91 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue @@ -1,24 +1,21 @@ <script> -import { - GlDropdownDivider, - GlFilteredSearchSuggestion, - GlFilteredSearchToken, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; -import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { DEFAULT_ITERATIONS } from '../constants'; export default { components: { - GlDropdownDivider, + BaseToken, GlFilteredSearchSuggestion, - GlFilteredSearchToken, - GlLoadingIcon, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -35,84 +32,58 @@ export default { }; }, computed: { - currentValue() { - return this.value.data; - }, - activeIteration() { - return this.iterations.find( - (iteration) => getIdFromGraphQLId(iteration.id) === Number(this.currentValue), - ); - }, defaultIterations() { return this.config.defaultIterations || DEFAULT_ITERATIONS; }, }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.iterations.length) { - this.fetchIterationBySearchTerm(this.currentValue); - } - }, - }, - }, methods: { - getValue(iteration) { - return String(getIdFromGraphQLId(iteration.id)); + getActiveIteration(iterations, data) { + return iterations.find((iteration) => this.getValue(iteration) === data); }, - fetchIterationBySearchTerm(searchTerm) { - const fetchPromise = this.config.fetchPath - ? this.config.fetchIterations(this.config.fetchPath, searchTerm) - : this.config.fetchIterations(searchTerm); - + fetchIterations(searchTerm) { this.loading = true; - - fetchPromise + this.config + .fetchIterations(searchTerm) .then((response) => { this.iterations = Array.isArray(response) ? response : response.data; }) - .catch(() => createFlash({ message: __('There was a problem fetching iterations.') })) + .catch(() => { + createFlash({ message: __('There was a problem fetching iterations.') }); + }) .finally(() => { this.loading = false; }); }, - searchIterations: debounce(function debouncedSearch({ data }) { - this.fetchIterationBySearchTerm(data); - }, DEBOUNCE_DELAY), + getValue(iteration) { + return String(getIdFromGraphQLId(iteration.id)); + }, }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultIterations" + :suggestions="iterations" + :suggestions-loading="loading" + :get-active-token-value="getActiveIteration" + @fetch-suggestions="fetchIterations" v-on="$listeners" - @input="searchIterations" > - <template #view="{ inputValue }"> - {{ activeIteration ? activeIteration.title : inputValue }} + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? activeTokenValue.title : inputValue }} </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="iteration in defaultIterations" - :key="iteration.value" - :value="iteration.value" + v-for="iteration in suggestions" + :key="iteration.id" + :value="getValue(iteration)" > - {{ iteration.text }} + {{ iteration.title }} </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultIterations.length" /> - <gl-loading-icon v-if="loading" size="sm" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="iteration in iterations" - :key="iteration.id" - :value="getValue(iteration)" - > - {{ iteration.title }} - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index 4d08f81fee9..c31f3a25fb1 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -5,7 +5,7 @@ import createFlash from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import { DEFAULT_LABELS } from '../constants'; +import { DEFAULT_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; import BaseToken from './base_token.vue'; @@ -33,14 +33,18 @@ export default { data() { return { labels: this.config.initialLabels || [], - defaultLabels: this.config.defaultLabels || DEFAULT_LABELS, loading: false, }; }, + computed: { + defaultLabels() { + return this.config.defaultLabels || DEFAULT_NONE_ANY; + }, + }, methods: { - getActiveLabel(labels, currentValue) { + getActiveLabel(labels, data) { return labels.find( - (label) => this.getLabelName(label).toLowerCase() === stripQuotes(currentValue), + (label) => this.getLabelName(label).toLowerCase() === stripQuotes(data).toLowerCase(), ); }, /** @@ -68,7 +72,7 @@ export default { } return {}; }, - fetchLabelBySearchTerm(searchTerm) { + fetchLabels(searchTerm) { this.loading = true; this.config .fetchLabels(searchTerm) @@ -98,10 +102,10 @@ export default { :active="active" :suggestions-loading="loading" :suggestions="labels" - :fn-active-token-value="getActiveLabel" + :get-active-token-value="getActiveLabel" :default-suggestions="defaultLabels" :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" - @fetch-suggestions="fetchLabelBySearchTerm" + @fetch-suggestions="fetchLabels" v-on="$listeners" > <template diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 66ad5ef5b4e..4b9ad6d8f91 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -1,27 +1,22 @@ <script> -import { - GlFilteredSearchToken, - GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; - +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import createFlash from '~/flash'; import { __ } from '~/locale'; import { sortMilestonesByDueDate } from '~/milestones/milestone_utils'; - -import { DEFAULT_MILESTONES, DEBOUNCE_DELAY } from '../constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { DEFAULT_MILESTONES } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; export default { components: { - GlFilteredSearchToken, + BaseToken, GlFilteredSearchSuggestion, - GlDropdownDivider, - GlLoadingIcon, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -34,36 +29,21 @@ export default { data() { return { milestones: this.config.initialMilestones || [], - defaultMilestones: this.config.defaultMilestones || DEFAULT_MILESTONES, loading: false, }; }, computed: { - currentValue() { - return this.value.data.toLowerCase(); - }, - activeMilestone() { - return this.milestones.find( - (milestone) => milestone.title.toLowerCase() === stripQuotes(this.currentValue), - ); - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.milestones.length) { - this.fetchMilestoneBySearchTerm(this.value.data); - } - }, + defaultMilestones() { + return this.config.defaultMilestones || DEFAULT_MILESTONES; }, }, methods: { - fetchMilestoneBySearchTerm(searchTerm = '') { - if (this.loading) { - return; - } - + getActiveMilestone(milestones, data) { + return milestones.find( + (milestone) => milestone.title.toLowerCase() === stripQuotes(data).toLowerCase(), + ); + }, + fetchMilestones(searchTerm) { this.loading = true; this.config .fetchMilestones(searchTerm) @@ -71,47 +51,40 @@ export default { const data = Array.isArray(response) ? response : response.data; this.milestones = data.slice().sort(sortMilestonesByDueDate); }) - .catch(() => createFlash({ message: __('There was a problem fetching milestones.') })) + .catch(() => { + createFlash({ message: __('There was a problem fetching milestones.') }); + }) .finally(() => { this.loading = false; }); }, - searchMilestones: debounce(function debouncedSearch({ data }) { - this.fetchMilestoneBySearchTerm(data); - }, DEBOUNCE_DELAY), }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultMilestones" + :suggestions="milestones" + :suggestions-loading="loading" + :get-active-token-value="getActiveMilestone" + @fetch-suggestions="fetchMilestones" v-on="$listeners" - @input="searchMilestones" > - <template #view="{ inputValue }"> - <span>%{{ activeMilestone ? activeMilestone.title : inputValue }}</span> + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + %{{ activeTokenValue ? activeTokenValue.title : inputValue }} </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="milestone in defaultMilestones" - :key="milestone.value" - :value="milestone.value" + v-for="milestone in suggestions" + :key="milestone.id" + :value="milestone.title" > - {{ milestone.text }} + {{ milestone.title }} </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultMilestones.length" /> - <gl-loading-icon v-if="loading" size="sm" /> - <template v-else> - <gl-filtered-search-suggestion - v-for="milestone in milestones" - :key="milestone.id" - :value="milestone.title" - > - <div>{{ milestone.title }}</div> - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue index 72116f0e991..280fb234576 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue @@ -1,15 +1,20 @@ <script> -import { GlDropdownDivider, GlFilteredSearchSuggestion, GlFilteredSearchToken } from '@gitlab/ui'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { GlFilteredSearchSuggestion } from '@gitlab/ui'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { DEFAULT_NONE_ANY, WEIGHT_TOKEN_SUGGESTIONS_SIZE } from '../constants'; + +const weights = Array.from(Array(WEIGHT_TOKEN_SUGGESTIONS_SIZE), (_, index) => index.toString()); export default { - baseWeights: ['0', '1', '2', '3', '4', '5'], components: { - GlDropdownDivider, + BaseToken, GlFilteredSearchSuggestion, - GlFilteredSearchToken, }, props: { + active: { + type: Boolean, + required: true, + }, config: { type: Object, required: true, @@ -21,38 +26,41 @@ export default { }, data() { return { - weights: this.$options.baseWeights, - defaultWeights: this.config.defaultWeights || DEFAULT_NONE_ANY, + weights, }; }, + computed: { + defaultWeights() { + return this.config.defaultWeights || DEFAULT_NONE_ANY; + }, + }, methods: { - updateWeights({ data }) { - const weight = parseInt(data, 10); - this.weights = Number.isNaN(weight) ? this.$options.baseWeights : [String(weight)]; + getActiveWeight(weightSuggestions, data) { + return weightSuggestions.find((weight) => weight === data); + }, + updateWeights(searchTerm) { + const weight = parseInt(searchTerm, 10); + this.weights = Number.isNaN(weight) ? weights : [String(weight)]; }, }, }; </script> <template> - <gl-filtered-search-token + <base-token + :active="active" :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :default-suggestions="defaultWeights" + :suggestions="weights" + :get-active-token-value="getActiveWeight" + @fetch-suggestions="updateWeights" v-on="$listeners" - @input="updateWeights" > - <template #suggestions> - <gl-filtered-search-suggestion - v-for="weight in defaultWeights" - :key="weight.value" - :value="weight.value" - > - {{ weight.text }} - </gl-filtered-search-suggestion> - <gl-dropdown-divider v-if="defaultWeights.length" /> - <gl-filtered-search-suggestion v-for="weight of weights" :key="weight" :value="weight"> + <template #suggestions-list="{ suggestions }"> + <gl-filtered-search-suggestion v-for="weight of suggestions" :key="weight" :value="weight"> {{ weight }} </gl-filtered-search-suggestion> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index d343ba700ab..3ed9de6c133 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -1,5 +1,5 @@ <script> -import { GlPopover, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui'; import $ from 'jquery'; import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings'; import { getSelectedFragment } from '~/lib/utils/common_utils'; @@ -10,7 +10,6 @@ import ToolbarButton from './toolbar_button.vue'; export default { components: { ToolbarButton, - GlIcon, GlPopover, GlButton, }, @@ -46,6 +45,7 @@ export default { data() { return { tag: '> ', + suggestPopoverVisible: false, }; }, computed: { @@ -76,15 +76,27 @@ export default { return this.isMac ? '⌘' : s__('KeyboardKey|Ctrl+'); }, }, + watch: { + showSuggestPopover() { + this.updateSuggestPopoverVisibility(); + }, + }, mounted() { $(document).on('markdown-preview:show.vue', this.previewMarkdownTab); $(document).on('markdown-preview:hide.vue', this.writeMarkdownTab); + + this.updateSuggestPopoverVisibility(); }, beforeDestroy() { $(document).off('markdown-preview:show.vue', this.previewMarkdownTab); $(document).off('markdown-preview:hide.vue', this.writeMarkdownTab); }, methods: { + async updateSuggestPopoverVisibility() { + await this.$nextTick(); + + this.suggestPopoverVisible = this.showSuggestPopover && this.canSuggest; + }, isValid(form) { return ( !form || @@ -153,127 +165,114 @@ export default { </button> </li> <li :class="{ active: !previewMarkdown }" class="md-header-toolbar"> - <div class="d-inline-block"> - <toolbar-button - tag="**" - :button-title=" - sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey }) - " - :shortcuts="$options.shortcuts.bold" - icon="bold" - /> - <toolbar-button - tag="_" - :button-title=" - sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey }) - " - :shortcuts="$options.shortcuts.italic" - icon="italic" - /> + <toolbar-button + tag="**" + :button-title=" + sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey }) + " + :shortcuts="$options.shortcuts.bold" + icon="bold" + /> + <toolbar-button + tag="_" + :button-title=" + sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey }) + " + :shortcuts="$options.shortcuts.italic" + icon="italic" + /> + <toolbar-button + :prepend="true" + :tag="tag" + :button-title="__('Insert a quote')" + icon="quote" + @click="handleQuote" + /> + <template v-if="canSuggest"> <toolbar-button + ref="suggestButton" + :tag="mdSuggestion" :prepend="true" - :tag="tag" - :button-title="__('Insert a quote')" - icon="quote" - @click="handleQuote" + :button-title="__('Insert suggestion')" + :cursor-offset="4" + :tag-content="lineContent" + icon="doc-code" + data-qa-selector="suggestion_button" + class="js-suggestion-btn" + @click="handleSuggestDismissed" /> - </div> - <div class="d-inline-block ml-md-2 ml-0"> - <template v-if="canSuggest"> - <toolbar-button - ref="suggestButton" - :tag="mdSuggestion" - :prepend="true" - :button-title="__('Insert suggestion')" - :cursor-offset="4" - :tag-content="lineContent" - icon="doc-code" - data-qa-selector="suggestion_button" - class="js-suggestion-btn" + <gl-popover + v-if="suggestPopoverVisible" + :target="$refs.suggestButton.$el" + :css-classes="['diff-suggest-popover']" + placement="bottom" + :show="suggestPopoverVisible" + > + <strong>{{ __('New! Suggest changes directly') }}</strong> + <p class="mb-2"> + {{ + __( + 'Suggest code changes which can be immediately applied in one click. Try it out!', + ) + }} + </p> + <gl-button + variant="info" + category="primary" + size="small" @click="handleSuggestDismissed" - /> - <gl-popover - v-if="showSuggestPopover && $refs.suggestButton" - :target="$refs.suggestButton" - :css-classes="['diff-suggest-popover']" - placement="bottom" - :show="showSuggestPopover" > - <strong>{{ __('New! Suggest changes directly') }}</strong> - <p class="mb-2"> - {{ - __( - 'Suggest code changes which can be immediately applied in one click. Try it out!', - ) - }} - </p> - <gl-button - variant="info" - category="primary" - size="sm" - @click="handleSuggestDismissed" - > - {{ __('Got it') }} - </gl-button> - </gl-popover> - </template> - <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> - <toolbar-button - tag="[{text}](url)" - tag-select="url" - :button-title=" - sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey }) - " - :shortcuts="$options.shortcuts.link" - icon="link" - /> - </div> - <div class="d-inline-block ml-md-2 ml-0"> - <toolbar-button - :prepend="true" - tag="- " - :button-title="__('Add a bullet list')" - icon="list-bulleted" - /> - <toolbar-button - :prepend="true" - tag="1. " - :button-title="__('Add a numbered list')" - icon="list-numbered" - /> - <toolbar-button - :prepend="true" - tag="- [ ] " - :button-title="__('Add a task list')" - icon="list-task" - /> - <toolbar-button - :tag="mdCollapsibleSection" - :prepend="true" - tag-select="Click to expand" - :button-title="__('Add a collapsible section')" - icon="details-block" - /> - <toolbar-button - :tag="mdTable" - :prepend="true" - :button-title="__('Add a table')" - icon="table" - /> - </div> - <div class="d-inline-block ml-md-2 ml-0"> - <button - v-gl-tooltip - :aria-label="__('Go full screen')" - class="toolbar-btn toolbar-fullscreen-btn js-zen-enter" - data-container="body" - tabindex="-1" - :title="__('Go full screen')" - type="button" - > - <gl-icon name="maximize" /> - </button> - </div> + {{ __('Got it') }} + </gl-button> + </gl-popover> + </template> + <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> + <toolbar-button + tag="[{text}](url)" + tag-select="url" + :button-title=" + sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey }) + " + :shortcuts="$options.shortcuts.link" + icon="link" + /> + <toolbar-button + :prepend="true" + tag="- " + :button-title="__('Add a bullet list')" + icon="list-bulleted" + /> + <toolbar-button + :prepend="true" + tag="1. " + :button-title="__('Add a numbered list')" + icon="list-numbered" + /> + <toolbar-button + :prepend="true" + tag="- [ ] " + :button-title="__('Add a task list')" + icon="list-task" + /> + <toolbar-button + :tag="mdCollapsibleSection" + :prepend="true" + tag-select="Click to expand" + :button-title="__('Add a collapsible section')" + icon="details-block" + /> + <toolbar-button + :tag="mdTable" + :prepend="true" + :button-title="__('Add a table')" + icon="table" + /> + <toolbar-button + class="js-zen-enter" + :prepend="true" + :button-title="__('Go full screen')" + icon="maximize" + /> </li> </ul> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue index 6c35741e7e5..6a83939795c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -1,9 +1,9 @@ <script> -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; export default { components: { - GlIcon, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -19,7 +19,8 @@ export default { }, tag: { type: String, - required: true, + required: false, + default: '', }, tagBlock: { type: String, @@ -71,7 +72,7 @@ export default { </script> <template> - <button + <gl-button v-gl-tooltip :data-md-tag="tag" :data-md-cursor-offset="cursorOffset" @@ -82,11 +83,11 @@ export default { :data-md-shortcuts="shortcutsString" :title="buttonTitle" :aria-label="buttonTitle" + :icon="icon" type="button" - class="toolbar-btn js-md" + category="tertiary" + class="js-md" data-container="body" @click="() => $emit('click')" - > - <gl-icon :name="icon" /> - </button> + /> </template> diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index 79a9e1fca8c..8a67754993d 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -42,12 +42,12 @@ export default { itemsCount: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, pageInfo: { type: Object, required: false, - default: () => {}, + default: () => ({}), }, statusTabs: { type: Array, diff --git a/app/assets/javascripts/vue_shared/components/papa_parse_alert.vue b/app/assets/javascripts/vue_shared/components/papa_parse_alert.vue new file mode 100644 index 00000000000..fa11661255f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/papa_parse_alert.vue @@ -0,0 +1,44 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlAlert, + }, + i18n: { + genericErrorMessage: s__('CsvParser|Failed to render the CSV file for the following reasons:'), + MissingQuotes: s__('CsvParser|Quoted field unterminated'), + InvalidQuotes: s__('CsvParser|Trailing quote on quoted field is malformed'), + UndetectableDelimiter: s__('CsvParser|Unable to auto-detect delimiter; defaulted to ","'), + TooManyFields: s__('CsvParser|Too many fields'), + TooFewFields: s__('CsvParser|Too few fields'), + }, + props: { + papaParseErrors: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + errorMessages() { + const errorMessages = this.papaParseErrors.map( + (error) => this.$options.i18n[error.code] ?? error.message, + ); + return new Set(errorMessages); + }, + }, +}; +</script> + +<template> + <gl-alert variant="danger" :dismissible="false"> + {{ $options.i18n.genericErrorMessage }} + <ul class="gl-mb-0!"> + <li v-for="error in errorMessages" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> +</template> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index a0c5a0559de..f21092af501 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -15,6 +15,11 @@ export default { ProjectListItem, }, props: { + maxListHeight: { + type: Number, + required: false, + default: 402, + }, projectSearchResults: { type: Array, required: true, @@ -101,7 +106,7 @@ export default { <div class="d-flex flex-column"> <gl-loading-icon v-if="showLoadingIndicator" size="sm" class="py-2 px-4" /> <gl-infinite-scroll - :max-list-height="402" + :max-list-height="maxListHeight" :fetched-items="projectSearchResults.length" :total-items="totalResults" @bottomReached="bottomReached" diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue deleted file mode 100644 index 07272a5b8d6..00000000000 --- a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue +++ /dev/null @@ -1,116 +0,0 @@ -<script> -import { GlFormCheckbox, GlModal } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import csrf from '~/lib/utils/csrf'; -import { s__, __ } from '~/locale'; -import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; - -export default { - actionCancel: { - text: __('Cancel'), - }, - csrf, - components: { - GlFormCheckbox, - GlModal, - OncallSchedulesList, - }, - data() { - return { - modalData: {}, - }; - }, - computed: { - isAccessRequest() { - return parseBoolean(this.modalData.isAccessRequest); - }, - isInvite() { - return parseBoolean(this.modalData.isInvite); - }, - isGroupMember() { - return this.modalData.memberType === 'GroupMember'; - }, - actionText() { - if (this.isAccessRequest) { - return __('Deny access request'); - } else if (this.isInvite) { - return s__('Member|Revoke invite'); - } - - return __('Remove member'); - }, - actionPrimary() { - return { - text: this.actionText, - attributes: { - variant: 'danger', - }, - }; - }, - showUnassignIssuablesCheckbox() { - return !this.isAccessRequest && !this.isInvite; - }, - isPartOfOncallSchedules() { - return !this.isAccessRequest && this.oncallSchedules.schedules?.length; - }, - oncallSchedules() { - try { - return JSON.parse(this.modalData.oncallSchedules); - } catch (e) { - Sentry.captureException(e); - } - return {}; - }, - }, - mounted() { - document.addEventListener('click', this.handleClick); - }, - beforeDestroy() { - document.removeEventListener('click', this.handleClick); - }, - methods: { - handleClick(event) { - const removeButton = event.target.closest('.js-remove-member-button'); - if (removeButton) { - this.modalData = removeButton.dataset; - this.$refs.modal.show(); - } - }, - submitForm() { - this.$refs.form.submit(); - }, - }, -}; -</script> - -<template> - <gl-modal - ref="modal" - modal-id="remove-member-modal" - :action-cancel="$options.actionCancel" - :action-primary="actionPrimary" - :title="actionText" - data-qa-selector="remove_member_modal_content" - @primary="submitForm" - > - <form ref="form" :action="modalData.memberPath" method="post"> - <p data-testid="modal-message">{{ modalData.message }}</p> - - <oncall-schedules-list - v-if="isPartOfOncallSchedules" - :schedules="oncallSchedules.schedules" - :user-name="oncallSchedules.name" - /> - - <input ref="method" type="hidden" name="_method" value="delete" /> - <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships"> - {{ __('Also remove direct user membership from subgroups and projects') }} - </gl-form-checkbox> - <gl-form-checkbox v-if="showUnassignIssuablesCheckbox" name="unassign_issuables"> - {{ __('Also unassign this user from related issues and merge requests') }} - </gl-form-checkbox> - </form> - </gl-modal> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index 9914bfc6026..623e7799493 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -132,6 +132,9 @@ export default { } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) { this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]); this.searchKey = ''; + + // Prevent parent form submission upon hitting enter. + e.preventDefault(); } else if (e.keyCode === ESC_KEY_CODE) { this.toggleDropdownContents(); } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue index aad754e15b0..7989ad40b5a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue @@ -28,8 +28,9 @@ export default { <template v-if="allowLabelEdit"> <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline /> <gl-button - variant="link" - class="float-right gl-text-gray-900! gl-hover-text-blue-800! js-sidebar-dropdown-toggle" + category="tertiary" + size="small" + class="float-right js-sidebar-dropdown-toggle gl-mr-n2" data-qa-selector="labels_edit_button" @click="toggleDropdownContents" > diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 87af3ffc52c..4234bc72f3a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -142,6 +142,7 @@ export default { this.setInitialState({ selectedLabels, }); + setTimeout(() => this.updateLabelsSetState(), 100); }, showDropdownContents(showDropdownContents) { this.setContentIsOnViewport(showDropdownContents); @@ -184,7 +185,7 @@ export default { document.removeEventListener('click', this.handleDocumentClick); }, methods: { - ...mapActions(['setInitialState', 'toggleDropdownContents']), + ...mapActions(['setInitialState', 'toggleDropdownContents', 'updateLabelsSetState']), /** * This method differentiates between * dispatched actions and calls necessary method. @@ -315,7 +316,7 @@ export default { </dropdown-value> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> <dropdown-contents - v-show="dropdownButtonVisible && showDropdownContents" + v-if="dropdownButtonVisible && showDropdownContents" ref="dropdownContents" :render-on-top="!contentIsOnViewport" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index 178be0f6da0..0c697e624ab 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -20,7 +20,11 @@ export const receiveLabelsFailure = ({ commit }) => { message: __('Error fetching labels.'), }); }; -export const fetchLabels = ({ state, dispatch }) => { +export const fetchLabels = ({ state, dispatch }, options) => { + if (state.labelsFetched && (!options || !options.refetch)) { + return Promise.resolve(); + } + dispatch('requestLabels'); return axios .get(state.labelsFetchPath) @@ -46,6 +50,7 @@ export const createLabel = ({ state, dispatch }, label) => { }) .then(({ data }) => { if (data.id) { + dispatch('fetchLabels', { refetch: true }); dispatch('receiveCreateLabelSuccess'); dispatch('toggleDropdownContentsCreateView'); } else { @@ -60,3 +65,5 @@ export const createLabel = ({ state, dispatch }, label) => { export const updateSelectedLabels = ({ commit }, labels) => commit(types.UPDATE_SELECTED_LABELS, { labels }); + +export const updateLabelsSetState = ({ commit }) => commit(types.UPDATE_LABELS_SET_STATE); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js index 2e044dc3b3c..f26e36031f4 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js @@ -18,3 +18,5 @@ export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS'; export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS'; export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW'; + +export const UPDATE_LABELS_SET_STATE = 'UPDATE_LABELS_SET_STATE'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js index 2e0a57f15dd..8853dc8b9e3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js @@ -34,15 +34,12 @@ export default { // Iterate over every label and add a `set` prop // to determine whether it is already a part of // selectedLabels array. - const selectedLabelIds = state.selectedLabels.map((label) => label.id); state.labelsFetchInProgress = false; - state.labels = labels.reduce((allLabels, label) => { - allLabels.push({ - ...label, - set: selectedLabelIds.includes(label.id), - }); - return allLabels; - }, []); + state.labelsFetched = true; + state.labels = labels.map((label) => ({ + ...label, + set: state.selectedLabels.some((selectedLabel) => selectedLabel.id === label.id), + })); }, [types.RECEIVE_SET_LABELS_FAILURE](state) { state.labelsFetchInProgress = false; @@ -79,4 +76,11 @@ export default { } } }, + + [types.UPDATE_LABELS_SET_STATE](state) { + state.labels = state.labels.map((label) => ({ + ...label, + set: state.selectedLabels.some((selectedLabel) => selectedLabel.id === label.id), + })); + }, }; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js index d66cfed4163..0185d5f88e1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js @@ -1,6 +1,7 @@ export default () => ({ // Initial Data labels: [], + labelsFetched: false, selectedLabels: [], labelsListTitle: '', labelsCreateTitle: '', diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue index 1f0704f7308..6694e349b6e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue @@ -21,9 +21,29 @@ export default { type: String, required: true, }, + selectedLabels: { + type: Array, + required: true, + }, + allowMultiselect: { + type: Boolean, + required: true, + }, + labelsListTitle: { + type: String, + required: true, + }, + footerCreateLabelTitle: { + type: String, + required: true, + }, + footerManageLabelTitle: { + type: String, + required: true, + }, }, computed: { - ...mapState(['showDropdownContentsCreateView', 'labelsListTitle']), + ...mapState(['showDropdownContentsCreateView']), ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), dropdownContentsView() { if (this.showDropdownContentsCreateView) { @@ -75,6 +95,16 @@ export default { @click="toggleDropdownContents" /> </div> - <component :is="dropdownContentsView" @hideCreateView="toggleDropdownContentsCreateView" /> + <component + :is="dropdownContentsView" + :selected-labels="selectedLabels" + :allow-multiselect="allowMultiselect" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + @hideCreateView="toggleDropdownContentsCreateView" + @closeDropdown="$emit('closeDropdown', $event)" + @toggleDropdownContentsCreateView="toggleDropdownContentsCreateView" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue index bff34743344..ffa37424c2c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue @@ -1,38 +1,91 @@ <script> -import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import { mapState, mapGetters, mapActions } from 'vuex'; - +import { debounce } from 'lodash'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; - +import { __ } from '~/locale'; +import { DropdownVariant } from './constants'; +import projectLabelsQuery from './graphql/project_labels.query.graphql'; import LabelItem from './label_item.vue'; export default { components: { - GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink, LabelItem, }, + inject: ['projectPath', 'allowLabelCreate', 'labelsManagePath', 'variant'], + props: { + selectedLabels: { + type: Array, + required: true, + }, + allowMultiselect: { + type: Boolean, + required: true, + }, + labelsListTitle: { + type: String, + required: true, + }, + footerCreateLabelTitle: { + type: String, + required: true, + }, + footerManageLabelTitle: { + type: String, + required: true, + }, + }, data() { return { searchKey: '', + labels: [], currentHighlightItem: -1, + localSelectedLabels: [...this.selectedLabels], }; }, + apollo: { + labels: { + query: projectLabelsQuery, + variables() { + return { + fullPath: this.projectPath, + searchTerm: this.searchKey, + }; + }, + skip() { + return this.searchKey.length === 1; + }, + update: (data) => data.workspace?.labels?.nodes || [], + async result() { + if (this.$refs.searchInput) { + await this.$nextTick(); + this.$refs.searchInput.focusInput(); + } + }, + error() { + createFlash({ message: __('Error fetching labels.') }); + }, + }, + }, computed: { - ...mapState([ - 'allowLabelCreate', - 'allowMultiselect', - 'labelsManagePath', - 'labels', - 'labelsFetchInProgress', - 'labelsListTitle', - 'footerCreateLabelTitle', - 'footerManageLabelTitle', - ]), - ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), + isDropdownVariantSidebar() { + return this.variant === DropdownVariant.Sidebar; + }, + isDropdownVariantEmbedded() { + return this.variant === DropdownVariant.Embedded; + }, + labelsFetchInProgress() { + return this.$apollo.queries.labels.loading; + }, + localSelectedLabelsIds() { + return this.localSelectedLabels.map((label) => label.id); + }, visibleLabels() { if (this.searchKey) { return fuzzaldrinPlus.filter(this.labels, this.searchKey, { @@ -55,17 +108,16 @@ export default { } }, }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + beforeDestroy() { + this.$emit('closeDropdown', this.localSelectedLabels); + this.debouncedSearchKeyUpdate.cancel(); + }, methods: { - ...mapActions([ - 'toggleDropdownContents', - 'toggleDropdownContentsCreateView', - 'fetchLabels', - 'receiveLabelsSuccess', - 'updateSelectedLabels', - 'toggleDropdownContents', - ]), isLabelSelected(label) { - return this.selectedLabelsList.includes(label.id); + return this.localSelectedLabelsIds.includes(getIdFromGraphQLId(label.id)); }, /** * This method scrolls item from dropdown into @@ -86,23 +138,17 @@ export default { } } }, - handleComponentAppear() { - // We can avoid putting `catch` block here - // as failure is handled within actions.js already. - return this.fetchLabels().then(() => { - this.$refs.searchInput.focusInput(); - }); - }, - /** - * We want to remove loaded labels to ensure component - * fetches fresh set of labels every time when shown. - */ - handleComponentDisappear() { - this.receiveLabelsSuccess([]); - }, - handleCreateLabelClick() { - this.receiveLabelsSuccess([]); - this.toggleDropdownContentsCreateView(); + updateSelectedLabels(label) { + if (this.isLabelSelected(label)) { + this.localSelectedLabels = this.localSelectedLabels.filter( + ({ id }) => id !== getIdFromGraphQLId(label.id), + ); + } else { + this.localSelectedLabels.push({ + ...label, + id: getIdFromGraphQLId(label.id), + }); + } }, /** * This method enables keyboard navigation support for @@ -117,10 +163,10 @@ export default { ) { this.currentHighlightItem += 1; } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) { - this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]); + this.updateSelectedLabels(this.visibleLabels[this.currentHighlightItem]); this.searchKey = ''; } else if (e.keyCode === ESC_KEY_CODE) { - this.toggleDropdownContents(); + this.$emit('closeDropdown', this.localSelectedLabels); } if (e.keyCode !== ESC_KEY_CODE) { @@ -132,68 +178,82 @@ export default { } }, handleLabelClick(label) { - this.updateSelectedLabels([label]); - if (!this.allowMultiselect) this.toggleDropdownContents(); + this.updateSelectedLabels(label); + if (!this.allowMultiselect) { + this.$emit('closeDropdown', this.localSelectedLabels); + } + }, + setSearchKey(value) { + this.searchKey = value; }, }, }; </script> <template> - <gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear"> - <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> - <div class="dropdown-input" @click.stop="() => {}"> - <gl-search-box-by-type - ref="searchInput" - v-model="searchKey" - :disabled="labelsFetchInProgress" - data-qa-selector="dropdown_input_field" - /> - </div> - <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content"> - <gl-loading-icon - v-if="labelsFetchInProgress" - class="labels-fetch-loading gl-align-items-center w-100 h-100" - size="md" + <div + class="labels-select-contents-list js-labels-list" + data-testid="dropdown-wrapper" + @keydown="handleKeyDown" + > + <div class="dropdown-input" @click.stop="() => {}"> + <gl-search-box-by-type + ref="searchInput" + :value="searchKey" + :disabled="labelsFetchInProgress" + data-qa-selector="dropdown_input_field" + data-testid="dropdown-input-field" + @input="debouncedSearchKeyUpdate" + /> + </div> + <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content"> + <gl-loading-icon + v-if="labelsFetchInProgress" + class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full" + size="md" + /> + <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word" data-testid="labels-list"> + <label-item + v-for="(label, index) in visibleLabels" + :key="label.id" + :label="label" + :is-label-set="isLabelSelected(label)" + :highlight="index === currentHighlightItem" + @clickLabel="handleLabelClick(label)" /> - <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word"> - <label-item - v-for="(label, index) in visibleLabels" - :key="label.id" - :label="label" - :is-label-set="label.set" - :highlight="index === currentHighlightItem" - @clickLabel="handleLabelClick(label)" - /> - <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center"> - {{ __('No matching results') }} - </li> - </ul> - </div> - <div - v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" - class="dropdown-footer" - data-testid="dropdown-footer" - > - <ul class="list-unstyled"> - <li v-if="allowLabelCreate"> - <gl-link - class="gl-display-flex w-100 flex-row text-break-word label-item" - @click="handleCreateLabelClick" - > - {{ footerCreateLabelTitle }} - </gl-link> - </li> - <li> - <gl-link - :href="labelsManagePath" - class="gl-display-flex flex-row text-break-word label-item" - > - {{ footerManageLabelTitle }} - </gl-link> - </li> - </ul> - </div> + <li + v-show="showNoMatchingResultsMessage" + class="gl-p-3 gl-text-center" + data-testid="no-results" + > + {{ __('No matching results') }} + </li> + </ul> + </div> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-footer" + data-testid="dropdown-footer" + > + <ul class="list-unstyled"> + <li v-if="allowLabelCreate"> + <gl-link + class="gl-display-flex gl-flex-direction-row gl-w-full gl-overflow-break-word label-item" + data-testid="create-label-button" + @click="$emit('toggleDropdownContentsCreateView')" + > + {{ footerCreateLabelTitle }} + </gl-link> + </li> + <li> + <gl-link + :href="labelsManagePath" + class="gl-display-flex gl-flex-direction-row gl-w-full gl-overflow-break-word label-item" + > + {{ footerManageLabelTitle }} + </gl-link> + </li> + </ul> </div> - </gl-intersection-observer> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue index b6d14965cfa..46edfa1c42a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue @@ -28,8 +28,9 @@ export default { <template v-if="allowLabelEdit"> <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline /> <gl-button - variant="link" - class="float-right js-sidebar-dropdown-toggle" + category="tertiary" + size="small" + class="float-right js-sidebar-dropdown-toggle gl-mr-n2" data-qa-selector="labels_edit_button" @click="toggleDropdownContents" >{{ __('Edit') }}</gl-button diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql new file mode 100644 index 00000000000..dc39220487d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql @@ -0,0 +1,12 @@ +query projectLabels($fullPath: ID!, $searchTerm: String) { + workspace: project(fullPath: $fullPath) { + labels(searchTerm: $searchTerm, includeAncestorGroups: true) { + nodes { + id + title + color + description + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue index 87f36a5bb72..0499dfe468f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue @@ -197,23 +197,6 @@ export default { methods: { ...mapActions(['setInitialState', 'toggleDropdownContents']), /** - * This method differentiates between - * dispatched actions and calls necessary method. - */ - handleVuexActionDispatch(action, state) { - if ( - action.type === 'toggleDropdownContents' && - !state.showDropdownButton && - !state.showDropdownContents - ) { - let filterFn = (label) => label.touched; - if (this.isDropdownVariantEmbedded) { - filterFn = (label) => label.set; - } - this.handleDropdownClose(state.labels.filter(filterFn)); - } - }, - /** * This method stores a mousedown event's target. * Required by the click listener because the click * event itself has no reference to this element. @@ -276,6 +259,9 @@ export default { handleDropdownClose(labels) { // Only emit label updates if there are any labels to update // on UI. + if (this.showDropdownContents) { + this.toggleDropdownContents(); + } if (labels.length) this.$emit('updateSelectedLabels', labels); this.$emit('onDropdownClose'); }, @@ -330,10 +316,16 @@ export default { </dropdown-value> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> <dropdown-contents - v-show="dropdownButtonVisible && showDropdownContents" + v-if="dropdownButtonVisible && showDropdownContents" ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" :render-on-top="!contentIsOnViewport" :labels-create-title="labelsCreateTitle" + :selected-labels="selectedLabels" + @closeDropdown="handleDropdownClose" /> </template> <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded"> @@ -341,7 +333,13 @@ export default { <dropdown-contents v-if="dropdownButtonVisible && showDropdownContents" ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" :render-on-top="!contentIsOnViewport" + :selected-labels="selectedLabels" + @closeDropdown="handleDropdownClose" /> </template> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js index 935f020f559..b3d4a204a81 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js @@ -1,6 +1,3 @@ -import createFlash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; import * as types from './mutation_types'; export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props); @@ -11,24 +8,5 @@ export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDO export const toggleDropdownContentsCreateView = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW); -export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS); -export const receiveLabelsSuccess = ({ commit }, labels) => - commit(types.RECEIVE_SET_LABELS_SUCCESS, labels); -export const receiveLabelsFailure = ({ commit }) => { - commit(types.RECEIVE_SET_LABELS_FAILURE); - createFlash({ - message: __('Error fetching labels.'), - }); -}; -export const fetchLabels = ({ state, dispatch }) => { - dispatch('requestLabels'); - return axios - .get(state.labelsFetchPath) - .then(({ data }) => { - dispatch('receiveLabelsSuccess', data); - }) - .catch(() => dispatch('receiveLabelsFailure')); -}; - export const updateSelectedLabels = ({ commit }, labels) => commit(types.UPDATE_SELECTED_LABELS, { labels }); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js index b8da7a90b36..bd71c3b85f1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js @@ -1,13 +1,5 @@ export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; -export const REQUEST_LABELS = 'REQUEST_LABELS'; -export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS'; -export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE'; - -export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS'; -export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS'; -export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE'; - export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY'; export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js index 1c03d95f37b..45ec4d7ae04 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js @@ -26,27 +26,6 @@ export default { [types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) { state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView; }, - - [types.REQUEST_LABELS](state) { - state.labelsFetchInProgress = true; - }, - [types.RECEIVE_SET_LABELS_SUCCESS](state, labels) { - // Iterate over every label and add a `set` prop - // to determine whether it is already a part of - // selectedLabels array. - const selectedLabelIds = state.selectedLabels.map((label) => label.id); - state.labelsFetchInProgress = false; - state.labels = labels.reduce((allLabels, label) => { - allLabels.push({ - ...label, - set: selectedLabelIds.includes(label.id), - }); - return allLabels; - }, []); - }, - [types.RECEIVE_SET_LABELS_FAILURE](state) { - state.labelsFetchInProgress = false; - }, [types.UPDATE_SELECTED_LABELS](state, { labels }) { // Find the label to update from all the labels // and change `set` prop value to represent their current state. diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue index e6229cf0a93..cdc7422c7df 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { todoLabel } from './utils'; +import { todoLabel, updateGlobalTodoCount } from './utils'; export default { components: { @@ -19,23 +19,11 @@ export default { }, }, methods: { - updateGlobalTodoCount(additionalTodoCount) { - const countContainer = document.querySelector('.js-todos-count'); - if (countContainer === null) return; - const currentCount = parseInt(countContainer.innerText, 10); - const todoToggleEvent = new CustomEvent('todo:toggle', { - detail: { - count: Math.max(currentCount + additionalTodoCount, 0), - }, - }); - - document.dispatchEvent(todoToggleEvent); - }, incrementGlobalTodoCount() { - this.updateGlobalTodoCount(1); + updateGlobalTodoCount(1); }, decrementGlobalTodoCount() { - this.updateGlobalTodoCount(-1); + updateGlobalTodoCount(-1); }, onToggle(event) { if (this.isTodo) { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js index 59e72a2ffe3..098ab72dfb5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js @@ -3,3 +3,19 @@ import { __ } from '~/locale'; export const todoLabel = (hasTodo) => { return hasTodo ? __('Mark as done') : __('Add a to do'); }; + +export const updateGlobalTodoCount = (additionalTodoCount) => { + const countContainer = document.querySelector('.js-todos-count'); + + if (countContainer === null) return; + + const currentCount = parseInt(countContainer.innerText, 10); + + const todoToggleEvent = new CustomEvent('todo:toggle', { + detail: { + count: Math.max(currentCount + additionalTodoCount, 0), + }, + }); + + document.dispatchEvent(todoToggleEvent); +}; diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue index 55e2a786c8f..04423aac651 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_link.vue @@ -30,6 +30,11 @@ export default { GlTooltip: GlTooltipDirective, }, props: { + lazy: { + type: Boolean, + required: false, + default: false, + }, linkHref: { type: String, required: false, @@ -91,6 +96,7 @@ export default { :size="imgSize" :tooltip-text="avatarTooltipText" :tooltip-placement="tooltipPlacement" + :lazy="lazy" > <slot></slot> </user-avatar-image ><span diff --git a/app/assets/javascripts/vue_shared/components/user_date.vue b/app/assets/javascripts/vue_shared/components/user_date.vue index 38dddbf72c2..33531cc3278 100644 --- a/app/assets/javascripts/vue_shared/components/user_date.vue +++ b/app/assets/javascripts/vue_shared/components/user_date.vue @@ -1,7 +1,7 @@ <script> import { formatDate } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; -import { SHORT_DATE_FORMAT } from '../constants'; +import { SHORT_DATE_FORMAT, DATE_FORMATS } from '../constants'; export default { props: { @@ -10,6 +10,12 @@ export default { required: false, default: null, }, + dateFormat: { + type: String, + required: false, + default: SHORT_DATE_FORMAT, + validator: (dateFormat) => DATE_FORMATS.includes(dateFormat), + }, }, computed: { formattedDate() { @@ -17,7 +23,7 @@ export default { if (date === null) { return __('Never'); } - return formatDate(new Date(date), SHORT_DATE_FORMAT); + return formatDate(new Date(date), this.dateFormat); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 5ba7c107c12..df0981aea7a 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -59,11 +59,21 @@ export default { required: false, default: '', }, + webIdeText: { + type: String, + required: false, + default: '', + }, gitpodUrl: { type: String, required: false, default: '', }, + gitpodText: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -99,6 +109,17 @@ export default { ...handleOptions, }; }, + webIdeActionText() { + if (this.webIdeText) { + return this.webIdeText; + } else if (this.isBlob) { + return __('Edit in Web IDE'); + } else if (this.isFork) { + return __('Edit fork in Web IDE'); + } + + return __('Web IDE'); + }, webIdeAction() { if (!this.showWebIdeButton) { return null; @@ -111,17 +132,9 @@ export default { } : { href: this.webIdeUrl }; - let text = __('Web IDE'); - - if (this.isBlob) { - text = __('Edit in Web IDE'); - } else if (this.isFork) { - text = __('Edit fork in Web IDE'); - } - return { key: KEY_WEB_IDE, - text, + text: this.webIdeActionText, secondaryText: __('Quickly and easily edit multiple files in your project.'), tooltip: '', attrs: { @@ -132,6 +145,9 @@ export default { ...handleOptions, }; }, + gitpodActionText() { + return this.gitpodText || __('Gitpod'); + }, gitpodAction() { if (!this.showGitpodButton) { return null; @@ -145,7 +161,7 @@ export default { return { key: KEY_GITPOD, - text: __('Gitpod'), + text: this.gitpodActionText, secondaryText, tooltip: secondaryText, attrs: { diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 9a5ad195de9..33fac5ebdbb 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -10,6 +10,10 @@ export const FILE_SYMLINK_MODE = '120000'; export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; +export const ISO_SHORT_FORMAT = 'yyyy-mm-dd'; + +export const DATE_FORMATS = [SHORT_DATE_FORMAT, ISO_SHORT_FORMAT]; + export const timeRanges = [ { label: __('30 minutes'), diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue index 1b20ae57563..5cd2018bb8c 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -1,12 +1,12 @@ <script> import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import Vue from 'vue'; import Tracking from '~/tracking'; export default { directives: { SafeHtml, }, + mixins: [Tracking.mixin()], props: { title: { type: String, @@ -17,16 +17,6 @@ export default { required: true, }, }, - created() { - const trackingMixin = Tracking.mixin(); - const trackingInstance = new Vue({ - ...trackingMixin, - render() { - return null; - }, - }); - this.track = trackingInstance.track; - }, }; </script> <template> diff --git a/app/assets/javascripts/vue_shared/security_configuration/provider.js b/app/assets/javascripts/vue_shared/security_configuration/provider.js index ef96b443da8..fa23669b615 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/provider.js +++ b/app/assets/javascripts/vue_shared/security_configuration/provider.js @@ -5,5 +5,5 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); export default new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), }); diff --git a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue index f3dd26b02cb..3a4453bc7ae 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue @@ -3,7 +3,7 @@ import { reportTypeToSecurityReportTypeEnum } from 'ee_else_ce/vue_shared/securi import createFlash from '~/flash'; import { s__ } from '~/locale'; import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; -import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import { extractSecurityReportArtifactsFromMergeRequest } from '~/vue_shared/security_reports/utils'; export default { diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue index 4178c5d1170..28618cb96a3 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -32,6 +32,11 @@ export default { default: '', }, }, + computed: { + showDropdown() { + return this.loading || this.artifacts.length > 0; + }, + }, methods: { artifactText({ name }) { return sprintf(s__('SecurityReports|Download %{artifactName}'), { @@ -44,6 +49,7 @@ export default { <template> <gl-dropdown + v-if="showDropdown" v-gl-tooltip :text="text" :title="title" diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql new file mode 100644 index 00000000000..ae77a2ce5e4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/fragments/job_artifacts.fragment.graphql @@ -0,0 +1,13 @@ +fragment JobArtifacts on Pipeline { + jobs(securityReportTypes: $reportTypes) { + nodes { + name + artifacts { + nodes { + downloadPath + fileType + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql index 4ce13827da2..4ce13827da2 100644 --- a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql diff --git a/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql new file mode 100644 index 00000000000..b5858ab012b --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/graphql/queries/security_report_pipeline_download_paths.query.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job_artifacts.fragment.graphql" + +query getCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { + project(fullPath: $projectPath) { + pipeline(iid: $iid) { + id + ...JobArtifacts + } + } +} diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql deleted file mode 100644 index c7e9fa16418..00000000000 --- a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql +++ /dev/null @@ -1,18 +0,0 @@ -query getCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) { - project(fullPath: $projectPath) { - pipeline(iid: $iid) { - id - jobs(securityReportTypes: $reportTypes) { - nodes { - name - artifacts { - nodes { - downloadPath - fileType - } - } - } - } - } - } -} diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue index 3e0310e173e..ad40ea6a964 100644 --- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue +++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue @@ -13,7 +13,7 @@ import { REPORT_TYPE_SECRET_DETECTION, reportTypeToSecurityReportTypeEnum, } from './constants'; -import securityReportMergeRequestDownloadPathsQuery from './queries/security_report_merge_request_download_paths.query.graphql'; +import securityReportMergeRequestDownloadPathsQuery from './graphql/queries/security_report_merge_request_download_paths.query.graphql'; import store from './store'; import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants'; import { extractSecurityReportArtifactsFromMergeRequest } from './utils'; diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js index c3f24a7e52f..0add91c402e 100644 --- a/app/assets/javascripts/vue_shared/security_reports/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/utils.js @@ -14,7 +14,7 @@ const addReportTypeIfExists = (acc, reportTypes, reportType, getName, downloadPa } }; -const extractSecurityReportArtifacts = (reportTypes, jobs) => { +export const extractSecurityReportArtifacts = (reportTypes, jobs) => { return jobs.reduce((acc, job) => { const artifacts = job.artifacts?.nodes ?? []; |