diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 11:43:02 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 11:43:02 +0300 |
commit | d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb (patch) | |
tree | 2341ef426af70ad1e289c38036737e04b0aa5007 /app/assets/javascripts/vue_shared/components | |
parent | d6e514dd13db8947884cd58fe2a9c2a063400a9b (diff) |
Add latest changes from gitlab-org/gitlab@14-4-stable-eev14.4.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
37 files changed, 779 insertions, 318 deletions
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js index 0c1d55ae707..4cab5e964de 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js @@ -27,6 +27,11 @@ export default { required: false, default: '', }, + hideLineNumbers: { + type: Boolean, + required: false, + default: false, + }, }, mounted() { eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT); diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index 84770dbac6f..40044e518c3 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -8,8 +8,6 @@ export default { name: 'SimpleViewer', components: { GlIcon, - SourceEditor: () => - import(/* webpackChunkName: 'SourceEditor' */ '~/vue_shared/components/source_editor.vue'), }, mixins: [ViewerMixin, glFeatureFlagsMixin()], inject: ['blobHash'], @@ -22,9 +20,6 @@ export default { lineNumbers() { return this.content.split('\n').length; }, - refactorBlobViewerEnabled() { - return this.glFeatures.refactorBlobViewer; - }, }, mounted() { const { hash } = window.location; @@ -52,14 +47,8 @@ export default { </script> <template> <div> - <source-editor - v-if="isRawContent && refactorBlobViewerEnabled" - :value="content" - :file-name="fileName" - :editor-options="{ readOnly: true }" - /> - <div v-else class="file-content code js-syntax-highlight" :class="$options.userColorScheme"> - <div class="line-numbers"> + <div class="file-content code js-syntax-highlight" :class="$options.userColorScheme"> + <div v-if="!hideLineNumbers" class="line-numbers"> <a v-for="line in lineNumbers" :id="`L${line}`" diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue index 3c21b14894b..7563c35dfc8 100644 --- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue +++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue @@ -81,8 +81,8 @@ export default { }, }, i18n: { - fullDescription: __('Choose any color. Or you can choose one of the suggested colors below'), - shortDescription: __('Choose any color'), + fullDescription: __('Enter any color or choose one of the suggested colors below.'), + shortDescription: __('Enter any color.'), }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 7b88b36aa0f..ea507017caa 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -97,7 +97,7 @@ export default { }); }) .catch(() => { - this.previewContent = __('An error occurred while fetching markdown preview'); + this.previewContent = __('An error occurred while fetching Markdown preview'); this.isLoading = false; }); } diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue index c4dfcf93a18..014276c7e36 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue @@ -1,13 +1,11 @@ <script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; import { slugifyWithUnderscore } from '~/lib/utils/text_utility'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; export default { components: { GlAlert, - GlSprintf, - GlLink, LocalStorageSync, }, props: { @@ -15,10 +13,6 @@ export default { type: String, required: true, }, - feedbackLink: { - type: String, - required: true, - }, }, data() { return { @@ -44,19 +38,8 @@ export default { <template> <div v-show="showAlert"> <local-storage-sync v-model="isDismissed" :storage-key="storageKey" as-json /> - <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert"> - <gl-sprintf - :message=" - __( - 'Please share your feedback about %{featureName} %{linkStart}in this issue%{linkEnd} to help us improve the experience.', - ) - " - > - <template #featureName>{{ featureName }}</template> - <template #link="{ content }"> - <gl-link :href="feedbackLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> + <gl-alert v-if="showAlert" @dismiss="dismissFeedbackAlert"> + <slot></slot> </gl-alert> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue new file mode 100644 index 00000000000..5d0ed8b0821 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown_keyboard_navigation.vue @@ -0,0 +1,81 @@ +<script> +import { UP_KEY_CODE, DOWN_KEY_CODE, TAB_KEY_CODE } from '~/lib/utils/keycodes'; + +export default { + model: { + prop: 'index', + event: 'change', + }, + props: { + /* v-model property to manage location in list */ + index: { + type: Number, + required: true, + }, + /* Highest index that can be navigated to */ + max: { + type: Number, + required: true, + }, + /* Lowest index that can be navigated to */ + min: { + type: Number, + required: true, + }, + /* Which index to set v-model to on init */ + defaultIndex: { + type: Number, + required: true, + }, + }, + watch: { + max() { + // If the max index (list length) changes, reset the index + this.$emit('change', this.defaultIndex); + }, + }, + created() { + this.$emit('change', this.defaultIndex); + document.addEventListener('keydown', this.handleKeydown); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.handleKeydown); + }, + methods: { + handleKeydown(event) { + if (event.keyCode === DOWN_KEY_CODE) { + // Prevents moving scrollbar + event.preventDefault(); + event.stopPropagation(); + // Moves to next index + this.increment(1); + } else if (event.keyCode === UP_KEY_CODE) { + // Prevents moving scrollbar + event.preventDefault(); + event.stopPropagation(); + // Moves to previous index + this.increment(-1); + } else if (event.keyCode === TAB_KEY_CODE) { + this.$emit('tab'); + } + }, + increment(val) { + if (this.max === 0) { + return; + } + + const nextIndex = Math.max(this.min, Math.min(this.index + val, this.max)); + + // Return if the index didn't change + if (nextIndex === this.index) { + return; + } + + this.$emit('change', nextIndex); + }, + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql new file mode 100644 index 00000000000..9e9bda8ad3e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/epic.fragment.graphql @@ -0,0 +1,15 @@ +fragment EpicNode on Epic { + id + iid + group { + fullPath + } + title + state + reference + referencePath: reference(full: true) + webPath + webUrl + createdAt + closedAt +} diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql new file mode 100644 index 00000000000..4bb4b586fc9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/queries/search_epics.query.graphql @@ -0,0 +1,16 @@ +#import "./epic.fragment.graphql" + +query searchEpics($fullPath: ID!, $search: String, $state: EpicState) { + group(fullPath: $fullPath) { + epics( + search: $search + state: $state + includeAncestorGroups: true + includeDescendantGroups: false + ) { + nodes { + ...EpicNode + } + } + } +} 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 d1326e96794..cee7c40aa83 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 @@ -67,6 +67,11 @@ export default { required: false, default: 'id', }, + searchBy: { + type: String, + required: false, + default: undefined, + }, }, data() { return { @@ -112,16 +117,18 @@ export default { ); }, showDefaultSuggestions() { - return this.availableDefaultSuggestions.length; + return this.availableDefaultSuggestions.length > 0; }, showRecentSuggestions() { - return this.isRecentSuggestionsEnabled && this.recentSuggestions.length && !this.searchKey; + return ( + this.isRecentSuggestionsEnabled && this.recentSuggestions.length > 0 && !this.searchKey + ); }, showPreloadedSuggestions() { - return this.preloadedSuggestions.length && !this.searchKey; + return this.preloadedSuggestions.length > 0 && !this.searchKey; }, showAvailableSuggestions() { - return this.availableSuggestions.length; + return this.availableSuggestions.length > 0; }, showSuggestions() { // These conditions must match the template under `#suggestions` slot @@ -134,13 +141,19 @@ export default { this.showAvailableSuggestions ); }, + searchTerm() { + return this.searchBy && this.activeTokenValue + ? this.activeTokenValue[this.searchBy] + : undefined; + }, }, watch: { active: { immediate: true, handler(newValue) { if (!newValue && !this.suggestions.length) { - this.$emit('fetch-suggestions', this.value.data); + const search = this.searchTerm ? this.searchTerm : this.value.data; + this.$emit('fetch-suggestions', search); } }, }, @@ -148,8 +161,10 @@ export default { methods: { handleInput: debounce(function debouncedSearch({ data }) { this.searchKey = data; - if (!this.suggestionsLoading) { - this.$emit('fetch-suggestions', data); + + if (!this.suggestionsLoading && !this.activeTokenValue) { + const search = this.searchTerm ? this.searchTerm : data; + this.$emit('fetch-suggestions', search); } }, DEBOUNCE_DELAY), handleTokenValueSelected(activeTokenValue) { 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 9f68308808e..9c2f5306654 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 @@ -1,22 +1,19 @@ <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_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; +import { DEFAULT_NONE_ANY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; +import searchEpicsQuery from '../queries/search_epics.query.graphql'; + +import BaseToken from './base_token.vue'; export default { - separator: '::&', + prefix: '&', + separator: '::', components: { - GlDropdownDivider, - GlFilteredSearchToken, + BaseToken, GlFilteredSearchSuggestion, - GlLoadingIcon, }, props: { config: { @@ -27,11 +24,15 @@ export default { type: Object, required: true, }, + active: { + type: Boolean, + required: true, + }, }, data() { return { epics: this.config.initialEpics || [], - loading: true, + loading: false, }; }, computed: { @@ -56,98 +57,73 @@ export default { } return this.defaultEpics; }, - activeEpic() { - if (this.currentValue && this.epics.length) { - // Check if current value is an epic ID. - if (typeof this.currentValue === 'number') { - return this.epics.find((epic) => epic[this.idProperty] === this.currentValue); - } - - // Current value is a string. - const [groupPath, idProperty] = this.currentValue?.split(this.$options.separator); - return this.epics.find( - (epic) => - epic.group_full_path === groupPath && - epic[this.idProperty] === parseInt(idProperty, 10), - ); - } - return null; - }, - displayText() { - return `${this.activeEpic?.title}${this.$options.separator}${this.activeEpic?.iid}`; - }, - }, - watch: { - active: { - immediate: true, - handler(newValue) { - if (!newValue && !this.epics.length) { - this.searchEpics({ data: this.currentValue }); - } - }, - }, }, methods: { - fetchEpicsBySearchTerm({ epicPath = '', search = '' }) { + fetchEpics(search = '') { + return this.$apollo + .query({ + query: searchEpicsQuery, + variables: { fullPath: this.config.fullPath, search }, + }) + .then(({ data }) => data.group?.epics.nodes); + }, + fetchEpicsBySearchTerm(search) { this.loading = true; - this.config - .fetchEpics({ epicPath, search }) + this.fetchEpics(search) .then((response) => { - this.epics = Array.isArray(response) ? response : response.data; + this.epics = Array.isArray(response) ? response : response?.data; }) .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) .finally(() => { this.loading = false; }); }, - searchEpics: debounce(function debouncedSearch({ data }) { - let epicPath = this.activeEpic?.web_url; - - // When user visits the page with token value already included in filters - // 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)) { - const [groupPath, epicIid] = data.split(this.$options.separator); - epicPath = `/groups/${groupPath}/-/epics/${epicIid}`; + getActiveEpic(epics, data) { + if (data && epics.length) { + return epics.find((epic) => this.getValue(epic) === data); } - this.fetchEpicsBySearchTerm({ epicPath, search: data }); - }, DEBOUNCE_DELAY), - + return undefined; + }, getValue(epic) { - return this.config.useIdValue - ? String(epic[this.idProperty]) - : `${epic.group_full_path}${this.$options.separator}${epic[this.idProperty]}`; + return this.getEpicIdProperty(epic).toString(); + }, + displayValue(epic) { + return `${this.$options.prefix}${this.getEpicIdProperty(epic)}${this.$options.separator}${ + epic?.title + }`; + }, + getEpicIdProperty(epic) { + return getIdFromGraphQLId(epic[this.idProperty]); }, }, }; </script> <template> - <gl-filtered-search-token + <base-token :config="config" - v-bind="{ ...$props, ...$attrs }" + :value="value" + :active="active" + :suggestions-loading="loading" + :suggestions="epics" + :get-active-token-value="getActiveEpic" + :default-suggestions="availableDefaultEpics" + :recent-suggestions-storage-key="config.recentSuggestionsStorageKey" + search-by="title" + @fetch-suggestions="fetchEpicsBySearchTerm" v-on="$listeners" - @input="searchEpics" > - <template #view="{ inputValue }"> - {{ activeEpic ? displayText : inputValue }} + <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> + {{ activeTokenValue ? displayValue(activeTokenValue) : inputValue }} </template> - <template #suggestions> + <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="epic in availableDefaultEpics" - :key="epic.value" - :value="epic.value" + v-for="epic in suggestions" + :key="epic.id" + :value="getValue(epic)" > - {{ epic.text }} + {{ epic.title }} </gl-filtered-search-suggestion> - <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)"> - {{ epic.title }} - </gl-filtered-search-suggestion> - </template> </template> - </gl-filtered-search-token> + </base-token> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue index b2f077f5329..5955f31fc70 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -77,7 +77,7 @@ export default { }; </script> <template> - <div class="issue-assignees"> + <div> <user-avatar-link v-for="assignee in assigneesToShow" :key="assignee.id" @@ -97,10 +97,9 @@ export default { </user-avatar-link> <span v-if="numHiddenAssignees > 0" - v-gl-tooltip + v-gl-tooltip.bottom :title="assigneesCounterTooltip" class="avatar-counter" - data-placement="bottom" data-qa-selector="avatar_counter_content" >{{ assigneeCounterLabel }}</span > diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index 095d1854c8b..8aeff9257a5 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -1,6 +1,12 @@ <script> import '~/commons/bootstrap'; -import { GlIcon, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { + GlIcon, + GlTooltip, + GlTooltipDirective, + GlButton, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { sprintf } from '~/locale'; import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; @@ -22,6 +28,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + SafeHtml, }, mixins: [relatedIssuableMixin], props: { @@ -84,7 +91,7 @@ export default { /> </div> <gl-tooltip :target="() => $refs.iconElementXL"> - <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html="stateTitle"></span> </gl-tooltip> <gl-icon v-if="confidential" @@ -110,7 +117,7 @@ export default { class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2" > <gl-tooltip :target="() => this.$refs.iconElement"> - <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span> + <span v-safe-html="stateTitle"></span> </gl-tooltip> <span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{ itemPath diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue index d6a20984ad1..ce7cbafb97d 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -1,5 +1,6 @@ <script> import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; export default { components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton }, @@ -13,12 +14,26 @@ export default { type: String, required: true, }, + batchSuggestionsCount: { + type: Number, + required: false, + default: 0, + }, }, data() { return { message: null, }; }, + computed: { + dropdownText() { + if (this.batchSuggestionsCount <= 1) { + return __('Apply suggestion'); + } + + return n__('Apply %d suggestion', 'Apply %d suggestions', this.batchSuggestionsCount); + }, + }, methods: { onApply() { this.$emit('apply', this.message); @@ -29,10 +44,11 @@ export default { <template> <gl-dropdown - :text="__('Apply suggestion')" + :text="dropdownText" :disabled="disabled" boundary="window" right + lazy menu-class="gl-w-full!" data-qa-selector="apply_suggestion_dropdown" @shown="$refs.commitMessage.$el.focus()" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 77730ada9bb..86f04c78ebe 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -254,7 +254,7 @@ export default { .then(() => $(this.$refs['markdown-preview']).renderGFM()) .catch(() => createFlash({ - message: __('Error rendering markdown preview'), + message: __('Error rendering Markdown preview'), }), ); }, diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index 9c954fce322..7d8d8c0b90e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -54,8 +54,8 @@ export default { applySuggestion(callback, message) { this.$emit('apply', { suggestionId: this.suggestion.id, callback, message }); }, - applySuggestionBatch() { - this.$emit('applyBatch'); + applySuggestionBatch(message) { + this.$emit('applyBatch', message); }, addSuggestionToBatch() { this.$emit('addToBatch', this.suggestion.id); diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 5fdef0b1a23..f9ae59567b2 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -58,12 +58,19 @@ export default { isApplyingSingle: false, }; }, + computed: { isApplying() { return this.isApplyingSingle || this.isApplyingBatch; }, tooltipMessage() { - return this.canApply ? __('This also resolves this thread') : this.inapplicableReason; + if (!this.canApply) { + return this.inapplicableReason; + } + + return this.batchSuggestionsCount > 1 + ? __('This also resolves all related threads') + : __('This also resolves this thread'); }, isDisableButton() { return this.isApplying || !this.canApply; @@ -72,13 +79,30 @@ export default { if (this.isApplyingSingle || this.batchSuggestionsCount < 2) { return __('Applying suggestion...'); } + return __('Applying suggestions...'); }, isLoggedIn() { return isLoggedIn(); }, + showApplySuggestion() { + if (!this.isLoggedIn) return false; + + if (this.batchSuggestionsCount >= 1 && !this.isBatched) { + return false; + } + + return true; + }, }, methods: { + apply(message) { + if (this.batchSuggestionsCount > 1) { + this.applySuggestionBatch(message); + } else { + this.applySuggestion(message); + } + }, applySuggestion(message) { if (!this.canApply) return; this.isApplyingSingle = true; @@ -88,9 +112,9 @@ export default { applySuggestionCallback() { this.isApplyingSingle = false; }, - applySuggestionBatch() { + applySuggestionBatch(message) { if (!this.canApply) return; - this.$emit('applyBatch'); + this.$emit('applyBatch', message); }, addSuggestionToBatch() { this.$emit('addToBatch'); @@ -115,45 +139,34 @@ export default { <gl-loading-icon size="sm" class="d-flex-center mr-2" /> <span>{{ applyingSuggestionsMessage }}</span> </div> - <div v-else-if="canApply && isBatched" class="d-flex align-items-center"> - <gl-button - class="btn-inverted js-remove-from-batch-btn btn-grouped" - :disabled="isApplying" - @click="removeSuggestionFromBatch" - > - {{ __('Remove from batch') }} - </gl-button> - <gl-button - v-gl-tooltip.viewport="__('This also resolves all related threads')" - class="btn-inverted js-apply-batch-btn btn-grouped" - data-qa-selector="apply_suggestions_batch_button" - :disabled="isApplying" - variant="success" - @click="applySuggestionBatch" - > - {{ __('Apply suggestions') }} - <span class="badge badge-pill badge-pill-success"> - {{ batchSuggestionsCount }} - </span> - </gl-button> - </div> - <div v-else class="d-flex align-items-center"> - <gl-button - v-if="suggestionsCount > 1 && !isDisableButton" - class="btn-inverted js-add-to-batch-btn btn-grouped" - data-qa-selector="add_suggestion_batch_button" - :disabled="isDisableButton" - @click="addSuggestionToBatch" - > - {{ __('Add suggestion to batch') }} - </gl-button> + <div v-else-if="isLoggedIn" class="d-flex align-items-center"> + <div v-if="isBatched"> + <gl-button + class="btn-inverted js-remove-from-batch-btn btn-grouped" + :disabled="isApplying" + @click="removeSuggestionFromBatch" + > + {{ __('Remove from batch') }} + </gl-button> + </div> + <div v-else-if="!isDisableButton && suggestionsCount > 1"> + <gl-button + class="btn-inverted js-add-to-batch-btn btn-grouped" + data-qa-selector="add_suggestion_batch_button" + :disabled="isDisableButton" + @click="addSuggestionToBatch" + > + {{ __('Add suggestion to batch') }} + </gl-button> + </div> <apply-suggestion - v-if="isLoggedIn" + v-if="showApplySuggestion" v-gl-tooltip.viewport="tooltipMessage" :disabled="isDisableButton" :default-commit-message="defaultCommitMessage" + :batch-suggestions-count="batchSuggestionsCount" class="gl-ml-3" - @apply="applySuggestion" + @apply="apply" /> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 63774c6c498..e36cfb3b275 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -68,6 +68,10 @@ export default { if (this.suggestionsWatch) { this.suggestionsWatch(); } + + if (this.defaultCommitMessageWatch) { + this.defaultCommitMessageWatch(); + } }, methods: { renderSuggestions() { @@ -123,12 +127,16 @@ export default { suggestionDiff.suggestionsCount = this.suggestionsCount; }); + this.defaultCommitMessageWatch = this.$watch('defaultCommitMessage', () => { + suggestionDiff.defaultCommitMessage = this.defaultCommitMessage; + }); + suggestionDiff.$on('apply', ({ suggestionId, callback, message }) => { this.$emit('apply', { suggestionId, callback, flashContainer: this.$el, message }); }); - suggestionDiff.$on('applyBatch', () => { - this.$emit('applyBatch', { flashContainer: this.$el }); + suggestionDiff.$on('applyBatch', (message) => { + this.$emit('applyBatch', { message, flashContainer: this.$el }); }); suggestionDiff.$on('addToBatch', (suggestionId) => { diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index d6501a37a35..9ea14ed506c 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -34,12 +34,23 @@ export default { type: Object, required: true, }, + line: { + type: Object, + required: false, + default: null, + }, }, computed: { ...mapGetters(['getUserData']), renderedNote() { return renderMarkdown(this.note.body); }, + avatarSize() { + if (this.line) { + return 16; + } + return 40; + }, }, }; </script> @@ -50,7 +61,7 @@ export default { <user-avatar-link :link-href="getUserData.path" :img-src="getUserData.avatar_url" - :img-size="40" + :img-size="avatarSize" /> </div> <div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content"> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js new file mode 100644 index 00000000000..9700117a3da --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.stories.js @@ -0,0 +1,34 @@ +import ProjectListItem from './project_list_item.vue'; + +export default { + component: ProjectListItem, + title: 'vue_shared/components/project_selector/project_list_item', +}; + +const Template = (args, { argTypes }) => ({ + components: { ProjectListItem }, + props: Object.keys(argTypes), + template: '<project-list-item v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + project: { + id: '1', + name: 'MyProject', + name_with_namespace: 'path / to / MyProject', + }, + selected: false, +}; + +export const SelectedProject = Template.bind({}); +SelectedProject.args = { + ...Default.args, + selected: true, +}; + +export const MatchedProject = Template.bind({}); +MatchedProject.args = { + ...Default.args, + matcher: 'proj', +}; diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue index 36d3696ec36..0bd57c84018 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { isString } from 'lodash'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; @@ -8,6 +8,7 @@ import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/def export default { name: 'ProjectListItem', components: { GlIcon, ProjectAvatar, GlButton }, + directives: { SafeHtml }, props: { project: { type: Object, @@ -58,9 +59,9 @@ export default { <span v-if="truncatedNamespace" class="text-secondary">/ </span> </div> <div + v-safe-html="highlightedProjectName" :title="project.name" class="js-project-name text-truncate" - v-html="highlightedProjectName /* eslint-disable-line vue/no-v-html */" ></div> </div> </gl-button> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue index 5c3a6852219..6538de085b0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue @@ -62,7 +62,7 @@ export default { <div> <clipboard-button v-if="!isLoading" - css-class="sidebar-collapsed-icon dont-change-state gl-rounded-0! gl-hover-bg-transparent" + css-class="sidebar-collapsed-icon js-dont-change-state gl-rounded-0! gl-hover-bg-transparent" v-bind="clipboardProps" /> 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 8853dc8b9e3..0ea22eb7aea 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 @@ -1,4 +1,5 @@ import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; +import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import { DropdownVariant } from '../constants'; import * as types from './mutation_types'; @@ -66,10 +67,10 @@ export default { } if (isScopedLabel(candidateLabel)) { - const scopedBase = scopedLabelKey(candidateLabel); - const currentActiveScopedLabel = state.labels.find(({ title }) => { - return title.startsWith(scopedBase) && title !== '' && title !== candidateLabel.title; - }); + const scopedKeyWithDelimiter = `${scopedLabelKey(candidateLabel)}${SCOPED_LABEL_DELIMITER}`; + const currentActiveScopedLabel = state.labels.find( + ({ title }) => title.startsWith(scopedKeyWithDelimiter) && title !== candidateLabel.title, + ); if (currentActiveScopedLabel) { currentActiveScopedLabel.set = false; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js index 00c54313292..389eb174c0e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js @@ -1,3 +1,5 @@ +export const SCOPED_LABEL_DELIMITER = '::'; + export const DropdownVariant = { Sidebar: 'sidebar', Standalone: 'standalone', 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 0fcc67c0ffa..3ee0baf8812 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 @@ -1,9 +1,9 @@ <script> import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; - +import { __, s__, sprintf } from '~/locale'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; -import { isDropdownVariantSidebar, isDropdownVariantEmbedded } from './utils'; +import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils'; export default { components: { @@ -48,10 +48,30 @@ export default { type: String, required: true, }, + issuableType: { + type: String, + required: true, + }, + isVisible: { + type: Boolean, + required: false, + default: false, + }, + fullPath: { + type: String, + required: true, + }, + attrWorkspacePath: { + type: String, + required: false, + default: undefined, + }, }, data() { return { showDropdownContentsCreateView: false, + localSelectedLabels: [...this.selectedLabels], + isDirty: false, }; }, computed: { @@ -64,28 +84,66 @@ export default { dropdownTitle() { return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle; }, + buttonText() { + if (!this.localSelectedLabels.length) { + return this.dropdownButtonText || __('Label'); + } else if (this.localSelectedLabels.length > 1) { + return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { + firstLabelName: this.localSelectedLabels[0].title, + remainingLabelCount: this.localSelectedLabels.length - 1, + }); + } + return this.localSelectedLabels[0].title; + }, showDropdownFooter() { - return ( - !this.showDropdownContentsCreateView && - (this.isDropdownVariantSidebar(this.variant) || - this.isDropdownVariantEmbedded(this.variant)) - ); + return !this.showDropdownContentsCreateView && !this.isStandalone; + }, + isStandalone() { + return isDropdownVariantStandalone(this.variant); }, }, - methods: { - showDropdown() { - this.$refs.dropdown.show(); + watch: { + localSelectedLabels: { + handler() { + this.isDirty = true; + }, + deep: true, + }, + isVisible(newVal) { + if (newVal) { + this.$refs.dropdown.show(); + this.isDirty = false; + } else { + this.$refs.dropdown.hide(); + this.setLabels(); + } }, + selectedLabels(newVal) { + this.localSelectedLabels = newVal; + }, + }, + methods: { toggleDropdownContentsCreateView() { this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView; }, toggleDropdownContent() { this.toggleDropdownContentsCreateView(); // Required to recalculate dropdown position as its size changes - this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); + if (this.$refs.dropdown?.$refs.dropdown) { + this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); + } + }, + setLabels() { + if (!this.isDirty) { + return; + } + this.$emit('setLabels', this.localSelectedLabels); + }, + handleDropdownHide() { + if (!isDropdownVariantSidebar(this.variant)) { + this.setLabels(); + } }, - isDropdownVariantSidebar, - isDropdownVariantEmbedded, }, }; </script> @@ -93,14 +151,16 @@ export default { <template> <gl-dropdown ref="dropdown" - :text="dropdownButtonText" + :text="buttonText" class="gl-w-full gl-mt-2" data-qa-selector="labels_dropdown_content" + @hide="handleDropdownHide" > <template #header> <div - v-if="isDropdownVariantSidebar(variant) || isDropdownVariantEmbedded(variant)" + v-if="!isStandalone" class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + data-testid="dropdown-header" > <gl-button v-if="showDropdownContentsCreateView" @@ -119,27 +179,33 @@ export default { size="small" class="dropdown-header-button gl-p-0!" icon="close" + data-testid="close-button" @click="$emit('closeDropdown')" /> </div> </template> - <component - :is="dropdownContentsView" - :selected-labels="selectedLabels" - :allow-multiselect="allowMultiselect" - @hideCreateView="toggleDropdownContentsCreateView" - @setLabels="$emit('setLabels', $event)" - /> + <template #default> + <component + :is="dropdownContentsView" + v-model="localSelectedLabels" + :selected-labels="selectedLabels" + :allow-multiselect="allowMultiselect" + :issuable-type="issuableType" + :full-path="fullPath" + :attr-workspace-path="attrWorkspacePath" + @hideCreateView="toggleDropdownContentsCreateView" + /> + </template> <template #footer> <div v-if="showDropdownFooter" data-testid="dropdown-footer"> <gl-dropdown-item v-if="allowLabelCreate" data-testid="create-label-button" - @click.native.capture.stop="toggleDropdownContent" + @click.capture.native.stop="toggleDropdownContent" > {{ footerCreateLabelTitle }} </gl-dropdown-item> - <gl-dropdown-item :href="labelsManagePath" @click.native.capture.stop> + <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop> {{ footerManageLabelTitle }} </gl-dropdown-item> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue index 2e31b386fdd..a2ed08e6b28 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue @@ -2,9 +2,10 @@ import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; import produce from 'immer'; import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; import { __ } from '~/locale'; +import { labelsQueries } from '~/sidebar/constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; -import projectLabelsQuery from './graphql/project_labels.query.graphql'; const errorMessage = __('Error creating label.'); @@ -18,9 +19,19 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: { - projectPath: { - default: '', + props: { + issuableType: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + attrWorkspacePath: { + type: String, + required: false, + default: undefined, }, }, data() { @@ -38,6 +49,27 @@ export default { const colorsMap = gon.suggested_label_colors; return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); }, + mutationVariables() { + if (this.issuableType === IssuableType.Epic) { + return { + title: this.labelTitle, + color: this.selectedColor, + groupPath: this.fullPath, + }; + } + + return this.attrWorkspacePath !== undefined + ? { + title: this.labelTitle, + color: this.selectedColor, + groupPath: this.attrWorkspacePath, + } + : { + title: this.labelTitle, + color: this.selectedColor, + projectPath: this.fullPath, + }; + }, }, methods: { getColorCode(color) { @@ -51,8 +83,8 @@ export default { }, updateLabelsInCache(store, label) { const sourceData = store.readQuery({ - query: projectLabelsQuery, - variables: { fullPath: this.projectPath, searchTerm: '' }, + query: labelsQueries[this.issuableType].workspaceQuery, + variables: { fullPath: this.fullPath, searchTerm: '' }, }); const collator = new Intl.Collator('en'); @@ -63,8 +95,8 @@ export default { }); store.writeQuery({ - query: projectLabelsQuery, - variables: { fullPath: this.projectPath, searchTerm: '' }, + query: labelsQueries[this.issuableType].workspaceQuery, + variables: { fullPath: this.fullPath, searchTerm: '' }, data, }); }, @@ -75,11 +107,7 @@ export default { data: { labelCreate }, } = await this.$apollo.mutate({ mutation: createLabelMutation, - variables: { - title: this.labelTitle, - color: this.selectedColor, - projectPath: this.projectPath, - }, + variables: this.mutationVariables, update: ( store, { 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 857367a0721..e6a25362ff0 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,12 +1,18 @@ <script> -import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlDropdownForm, + GlDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + GlIntersectionObserver, +} from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; 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 { __ } from '~/locale'; -import projectLabelsQuery from './graphql/project_labels.query.graphql'; +import { labelsQueries } from '~/sidebar/constants'; import LabelItem from './label_item.vue'; export default { @@ -15,9 +21,12 @@ export default { GlDropdownItem, GlLoadingIcon, GlSearchBoxByType, + GlIntersectionObserver, LabelItem, }, - inject: ['projectPath'], + model: { + prop: 'localSelectedLabels', + }, props: { selectedLabels: { type: Array, @@ -27,30 +36,44 @@ export default { type: Boolean, required: true, }, + issuableType: { + type: String, + required: true, + }, + localSelectedLabels: { + type: Array, + required: true, + }, + fullPath: { + type: String, + required: true, + }, }, data() { return { searchKey: '', labels: [], - localSelectedLabels: [...this.selectedLabels], + isVisible: false, }; }, apollo: { labels: { - query: projectLabelsQuery, + query() { + return labelsQueries[this.issuableType].workspaceQuery; + }, variables() { return { - fullPath: this.projectPath, + fullPath: this.fullPath, searchTerm: this.searchKey, }; }, skip() { - return this.searchKey.length === 1; + return this.searchKey.length === 1 || !this.isVisible; }, update: (data) => data.workspace?.labels?.nodes || [], async result() { if (this.$refs.searchInput) { - await this.$nextTick(); + await this.$nextTick; this.$refs.searchInput.focusInput(); } }, @@ -64,7 +87,7 @@ export default { return this.$apollo.queries.labels.loading; }, localSelectedLabelsIds() { - return this.localSelectedLabels.map((label) => label.id); + return this.localSelectedLabels.map((label) => getIdFromGraphQLId(label.id)); }, visibleLabels() { if (this.searchKey) { @@ -82,7 +105,6 @@ export default { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, beforeDestroy() { - this.$emit('setLabels', this.localSelectedLabels); this.debouncedSearchKeyUpdate.cancel(); }, methods: { @@ -109,16 +131,21 @@ export default { } }, updateSelectedLabels(label) { + let labels; if (this.isLabelSelected(label)) { - this.localSelectedLabels = this.localSelectedLabels.filter( - ({ id }) => id !== getIdFromGraphQLId(label.id), + labels = this.localSelectedLabels.filter( + ({ id }) => id !== getIdFromGraphQLId(label.id) && id !== label.id, ); } else { - this.localSelectedLabels.push({ - ...label, - id: getIdFromGraphQLId(label.id), - }); + labels = [ + ...this.localSelectedLabels, + { + ...label, + id: getIdFromGraphQLId(label.id), + }, + ]; } + this.$emit('input', labels); }, handleLabelClick(label) { this.updateSelectedLabels(label); @@ -129,46 +156,52 @@ export default { setSearchKey(value) { this.searchKey = value; }, + onDropdownAppear() { + this.isVisible = true; + this.$refs.searchInput.focusInput(); + }, }, }; </script> <template> - <gl-dropdown-form class="labels-select-contents-list js-labels-list"> - <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 ref="labelsListContainer" 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" + <gl-intersection-observer @appear="onDropdownAppear"> + <gl-dropdown-form class="labels-select-contents-list js-labels-list"> + <gl-search-box-by-type + ref="searchInput" + :value="searchKey" + :disabled="labelsFetchInProgress" + data-qa-selector="dropdown_input_field" + data-testid="dropdown-input-field" + @input="debouncedSearchKeyUpdate" /> - <template v-else> - <gl-dropdown-item - v-for="label in visibleLabels" - :key="label.id" - :is-checked="isLabelSelected(label)" - :is-check-centered="true" - :is-check-item="true" - data-testid="labels-list" - @click.native.capture.stop="handleLabelClick(label)" - > - <label-item :label="label" /> - </gl-dropdown-item> - <gl-dropdown-item - v-show="showNoMatchingResultsMessage" - class="gl-p-3 gl-text-center" - data-testid="no-results" - > - {{ __('No matching results') }} - </gl-dropdown-item> - </template> - </div> - </gl-dropdown-form> + <div ref="labelsListContainer" data-testid="dropdown-content"> + <gl-loading-icon + v-if="labelsFetchInProgress" + class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3" + size="md" + /> + <template v-else> + <gl-dropdown-item + v-for="label in visibleLabels" + :key="label.id" + :is-checked="isLabelSelected(label)" + :is-check-centered="true" + :is-check-item="true" + data-testid="labels-list" + @click.native.capture.stop="handleLabelClick(label)" + > + <label-item :label="label" /> + </gl-dropdown-item> + <gl-dropdown-item + v-show="showNoMatchingResultsMessage" + class="gl-p-3 gl-text-center" + data-testid="no-results" + > + {{ __('No matching results') }} + </gl-dropdown-item> + </template> + </div> + </gl-dropdown-form> + </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql new file mode 100644 index 00000000000..a2e8579486f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql @@ -0,0 +1,15 @@ +query epicLabels($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + issuable: epic(iid: $iid) { + id + labels { + nodes { + id + title + color + description + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql new file mode 100644 index 00000000000..acc9bcd2015 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql @@ -0,0 +1,12 @@ +query groupLabels($fullPath: ID!, $searchTerm: String) { + workspace: group(fullPath: $fullPath) { + labels(searchTerm: $searchTerm, onlyGroupLabels: 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 3c834770563..6bd43da2203 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 @@ -1,21 +1,18 @@ <script> -import Vue from 'vue'; -import Vuex from 'vuex'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { labelsQueries } from '~/sidebar/constants'; import { DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; -import issueLabelsQuery from './graphql/issue_labels.query.graphql'; import { isDropdownVariantSidebar, isDropdownVariantStandalone, isDropdownVariantEmbedded, } from './utils'; -Vue.use(Vuex); - export default { components: { DropdownValue, @@ -23,8 +20,21 @@ export default { DropdownValueCollapsed, SidebarEditableItem, }, - inject: ['iid', 'projectPath', 'allowLabelEdit'], + inject: { + allowLabelEdit: { + default: false, + }, + }, props: { + iid: { + type: String, + required: false, + default: '', + }, + fullPath: { + type: String, + required: true, + }, allowLabelRemove: { type: Boolean, required: false, @@ -90,43 +100,60 @@ export default { required: false, default: false, }, + issuableType: { + type: String, + required: true, + }, + attrWorkspacePath: { + type: String, + required: false, + default: undefined, + }, }, data() { return { contentIsOnViewport: true, - issueLabels: [], + issuableLabels: [], }; }, + computed: { + isLoading() { + return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading; + }, + }, apollo: { - issueLabels: { - query: issueLabelsQuery, + issuableLabels: { + query() { + return labelsQueries[this.issuableType].issuableQuery; + }, + skip() { + return !isDropdownVariantSidebar(this.variant); + }, variables() { return { iid: this.iid, - fullPath: this.projectPath, + fullPath: this.fullPath, }; }, update(data) { return data.workspace?.issuable?.labels.nodes || []; }, + error() { + createFlash({ message: __('Error fetching labels.') }); + }, }, }, methods: { handleDropdownClose(labels) { - if (labels.length) this.$emit('updateSelectedLabels', labels); - this.$emit('onDropdownClose'); + this.$emit('updateSelectedLabels', labels); + this.collapseEditableItem(); }, - collapseDropdown() { - this.$refs.editable.collapse(); + collapseEditableItem() { + this.$refs.editable?.collapse(); }, handleCollapsedValueClick() { this.$emit('toggleCollapse'); }, - showDropdown() { - this.$nextTick(() => { - this.$refs.dropdownContents.showDropdown(); - }); - }, isDropdownVariantSidebar, isDropdownVariantStandalone, isDropdownVariantEmbedded, @@ -145,20 +172,19 @@ export default { <template v-if="isDropdownVariantSidebar(variant)"> <dropdown-value-collapsed ref="dropdownButtonCollapsed" - :labels="issueLabels" + :labels="issuableLabels" @onValueClick="handleCollapsedValueClick" /> <sidebar-editable-item ref="editable" :title="__('Labels')" - :loading="labelsSelectInProgress" + :loading="isLoading" :can-edit="allowLabelEdit" - @open="showDropdown" > <template #collapsed> <dropdown-value :disable-labels="labelsSelectInProgress" - :selected-labels="issueLabels" + :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @@ -170,7 +196,7 @@ export default { <template #default="{ edit }"> <dropdown-value :disable-labels="labelsSelectInProgress" - :selected-labels="issueLabels" + :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @@ -180,8 +206,6 @@ export default { <slot></slot> </dropdown-value> <dropdown-contents - v-if="edit" - ref="dropdownContents" :dropdown-button-text="dropdownButtonText" :allow-multiselect="allowMultiselect" :labels-list-title="labelsListTitle" @@ -190,11 +214,30 @@ export default { :labels-create-title="labelsCreateTitle" :selected-labels="selectedLabels" :variant="variant" - @closeDropdown="collapseDropdown" + :issuable-type="issuableType" + :is-visible="edit" + :full-path="fullPath" + :attr-workspace-path="attrWorkspacePath" @setLabels="handleDropdownClose" + @closeDropdown="collapseEditableItem" /> </template> </sidebar-editable-item> </template> + <dropdown-contents + v-else + ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :dropdown-button-text="dropdownButtonText" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + :labels-create-title="labelsCreateTitle" + :selected-labels="selectedLabels" + :variant="variant" + :issuable-type="issuableType" + :full-path="fullPath" + @setLabels="handleDropdownClose" + /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js index d2afc02233e..294e5bd9f90 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js @@ -4,7 +4,7 @@ import TodoButton from './todo_button.vue'; export default { component: TodoButton, - title: 'vue_shared/components/todo_toggle/todo_button', + title: 'vue_shared/components/sidebar/todo_toggle/todo_button', }; const Template = (args, { argTypes }) => ({ diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue index afb1ea702fa..0a7a22ed3a8 100644 --- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue @@ -45,7 +45,7 @@ export default { data() { return { dragCounter: 0, - isDragDataValid: false, + isDragDataValid: true, }; }, computed: { diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js new file mode 100644 index 00000000000..256db2ea1ce --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/constants.js @@ -0,0 +1,5 @@ +// Types of obstacles to user deletion +export const OBSTACLE_TYPES = Object.freeze({ + oncallSchedules: 'ONCALL_SCHEDULE', + escalationPolicies: 'ESCALATION_POLICY', +}); diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js new file mode 100644 index 00000000000..d2030c14029 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.stories.js @@ -0,0 +1,37 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import { OBSTACLE_TYPES } from './constants'; +import UserDeletionObstaclesList from './user_deletion_obstacles_list.vue'; + +export default { + component: UserDeletionObstaclesList, + title: 'vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list', +}; + +const Template = (args, { argTypes }) => ({ + components: { UserDeletionObstaclesList }, + props: Object.keys(argTypes), + template: '<user-deletion-obstacles-list v-bind="$props" v-on="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + obstacles: [ + { + type: OBSTACLE_TYPES.oncallSchedules, + name: 'APAC', + url: 'https://domain.com/group/main-application/oncall_schedules', + projectName: 'main-application', + projectUrl: 'https://domain.com/group/main-application', + }, + { + type: OBSTACLE_TYPES.escalationPolicies, + name: 'Engineering On-call', + url: 'https://domain.com/group/microservice-backend/escalation_policies', + projectName: 'Microservice Backend', + projectUrl: 'https://domain.com/group/microservice-backend', + }, + ], + userName: 'Thomspon Smith', + isCurrentUser: false, +}; diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue index e37a663ace3..1eea660d527 100644 --- a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue @@ -1,6 +1,16 @@ <script> import { GlSprintf, GlLink } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; +import { OBSTACLE_TYPES } from './constants'; + +const OBSTACLE_TEXT = { + [OBSTACLE_TYPES.oncallSchedules]: s__( + 'OnCallSchedules|On-call schedule %{obstacle} in Project %{project}', + ), + [OBSTACLE_TYPES.escalationPolicies]: s__( + 'EscalationPolicies|Escalation policy %{obstacle} in Project %{project}', + ), +}; export default { components: { @@ -8,7 +18,7 @@ export default { GlLink, }, props: { - schedules: { + obstacles: { type: Array, required: true, }, @@ -45,6 +55,15 @@ export default { ); }, }, + methods: { + textForObstacle(obstacle) { + return OBSTACLE_TEXT[obstacle.type]; + }, + urlForObstacle(obstacle) { + // Fallback to scheduleUrl for backwards compatibility + return obstacle.url || obstacle.scheduleUrl; + }, + }, }; </script> @@ -52,17 +71,15 @@ export default { <div> <p data-testid="title">{{ title }}</p> - <ul data-testid="schedules-list"> - <li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`"> - <gl-sprintf - :message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')" - > - <template #schedule> - <gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link> + <ul data-testid="obstacles-list"> + <li v-for="(obstacle, index) in obstacles" :key="`${obstacle.name}-${index}`"> + <gl-sprintf :message="textForObstacle(obstacle)"> + <template #obstacle> + <gl-link :href="urlForObstacle(obstacle)" target="_blank">{{ obstacle.name }}</gl-link> </template> <template #project> - <gl-link :href="schedule.projectUrl" target="_blank">{{ - schedule.projectName + <gl-link :href="obstacle.projectUrl" target="_blank">{{ + obstacle.projectName }}</gl-link> </template> </gl-sprintf> diff --git a/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js new file mode 100644 index 00000000000..502302a1ef2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_deletion_obstacles/utils.js @@ -0,0 +1,19 @@ +import { OBSTACLE_TYPES } from './constants'; + +const addTypeToObstacles = (obstacles, type) => { + if (!obstacles) return []; + + return obstacles?.map((obstacle) => ({ type, ...obstacle })); +}; + +// For use with user objects formatted via internal REST API. +// If the removal/deletion of a user could cause critical +// problems, return a single array containing all affected +// associations including their type. +export const parseUserDeletionObstacles = (user) => { + if (!user) return []; + + return Object.keys(OBSTACLE_TYPES).flatMap((type) => { + return addTypeToObstacles(user[type], OBSTACLE_TYPES[type]); + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 74616763f8f..05e0c3b0be3 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -93,19 +93,27 @@ export default { </div> <div class="gl-text-gray-500"> <div v-if="user.bio" class="gl-display-flex gl-mb-2"> - <gl-icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" /> + <gl-icon name="profile" class="gl-flex-shrink-0" /> <span ref="bio" class="gl-ml-2 gl-overflow-hidden">{{ user.bio }}</span> </div> <div v-if="user.workInformation" class="gl-display-flex gl-mb-2"> - <gl-icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" /> + <gl-icon name="work" class="gl-flex-shrink-0" /> <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span> </div> + <div v-if="user.location" class="gl-display-flex gl-mb-2"> + <gl-icon name="location" class="gl-flex-shrink-0" /> + <span class="gl-ml-2">{{ user.location }}</span> + </div> + <div + v-if="user.localTime && !user.bot" + class="gl-display-flex gl-mb-2" + data-testid="user-popover-local-time" + > + <gl-icon name="clock" class="gl-flex-shrink-0" /> + <span class="gl-ml-2">{{ user.localTime }}</span> + </div> </div> - <div v-if="user.location" class="js-location gl-text-gray-500 gl-display-flex"> - <gl-icon name="location" class="gl-text-gray-400 flex-shrink-0" /> - <span class="gl-ml-2">{{ user.location }}</span> - </div> - <div v-if="statusHtml" class="js-user-status gl-mt-3"> + <div v-if="statusHtml" class="gl-mb-2" data-testid="user-popover-status"> <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span> </div> <div v-if="user.bot" class="gl-text-blue-500"> 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 df0981aea7a..6da2d39a95a 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -92,7 +92,10 @@ export default { const handleOptions = this.needsToFork ? { href: '#modal-confirm-fork-edit', - handle: () => this.showModal('#modal-confirm-fork-edit'), + handle: () => { + this.$emit('edit', 'simple'); + this.showModal('#modal-confirm-fork-edit'); + }, } : { href: this.editUrl }; @@ -128,7 +131,10 @@ export default { const handleOptions = this.needsToFork ? { href: '#modal-confirm-fork-webide', - handle: () => this.showModal('#modal-confirm-fork-webide'), + handle: () => { + this.$emit('edit', 'ide'); + this.showModal('#modal-confirm-fork-webide'); + }, } : { href: this.webIdeUrl }; |