diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-20 16:18:24 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-20 16:18:24 +0300 |
commit | 0653e08efd039a5905f3fa4f6e9cef9f5d2f799c (patch) | |
tree | 4dcc884cf6d81db44adae4aa99f8ec1233a41f55 /app/assets/javascripts/vue_shared/components | |
parent | 744144d28e3e7fddc117924fef88de5d9674fe4c (diff) |
Add latest changes from gitlab-org/gitlab@14-3-stable-eev14.3.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared/components')
47 files changed, 893 insertions, 793 deletions
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index f4c73d12923..82a28d4cb5f 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -1,6 +1,5 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlButton, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui'; import { groupBy } from 'lodash'; import EmojiPicker from '~/emoji/components/picker.vue'; import { __, sprintf } from '~/locale'; @@ -18,6 +17,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, }, mixins: [glFeatureFlagsMixin()], props: { @@ -164,6 +164,7 @@ export default { this.isMenuOpen = menuOpen; }, }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> @@ -180,7 +181,11 @@ export default { @click="handleAward(awardList.name)" > <template #emoji> - <span class="award-emoji-block" data-testid="award-html" v-html="awardList.html"></span> + <span + v-safe-html:[$options.safeHtmlConfig]="awardList.html" + class="award-emoji-block" + data-testid="award-html" + ></span> </template> <span class="js-counter">{{ awardList.list.length }}</span> </gl-button> 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 0589b47edbd..84770dbac6f 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 @@ -1,5 +1,4 @@ <script> -/* eslint-disable vue/no-v-html */ import { GlIcon } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { HIGHLIGHT_CLASS_NAME } from './constants'; @@ -75,7 +74,9 @@ export default { </a> </div> <div class="blob-content"> - <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre> + <pre + class="code highlight" + ><code :data-blob-hash="blobHash" v-html="content /* eslint-disable-line vue/no-v-html */"></code></pre> </div> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue index 1928bf6dac5..9856f35c7f6 100644 --- a/app/assets/javascripts/vue_shared/components/code_block.vue +++ b/app/assets/javascripts/vue_shared/components/code_block.vue @@ -24,8 +24,13 @@ export default { return isScrollable ? scrollableStyles : null; }, }, + userColorScheme: window.gon.user_color_scheme, }; </script> <template> - <pre class="code-block rounded" :style="styleObject"><code class="d-block">{{ code }}</code></pre> + <pre + class="code-block rounded code" + :class="$options.userColorScheme" + :style="styleObject" + ><code class="d-block">{{ code }}</code></pre> </template> 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 0ff33e462b4..3c21b14894b 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 @@ -110,7 +110,7 @@ export default { <div :class="previewColorClasses" :style="previewColor" data-testid="color-preview"> <gl-form-input type="color" - class="gl-absolute gl-top-0 gl-left-0 gl-h-full! gl-p-0! gl-m-0! gl-cursor-pointer gl-opacity-0" + class="gl-absolute gl-top-0 gl-left-0 gl-h-full! gl-p-0! gl-m-0! gl-opacity-0" tabindex="-1" :value="value" @input="handleColorChange" diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue index d1eee62683b..5f50a699034 100644 --- a/app/assets/javascripts/vue_shared/components/commit.vue +++ b/app/assets/javascripts/vue_shared/components/commit.vue @@ -136,6 +136,9 @@ export default { refUrl() { return this.commitRef.ref_url || this.commitRef.path; }, + tooltipTitle() { + return this.mergeRequestRef ? this.mergeRequestRef.title : this.commitRef.name; + }, }, }; </script> @@ -148,23 +151,14 @@ export default { <gl-icon v-else name="branch" /> </div> - <gl-link - v-if="mergeRequestRef" - v-gl-tooltip - :href="mergeRequestRef.path" - :title="mergeRequestRef.title" - class="ref-name" - >{{ mergeRequestRef.iid }}</gl-link - > - <gl-link - v-else - v-gl-tooltip - :href="refUrl" - :title="commitRef.name" - class="ref-name" - data-testid="ref-name" - >{{ commitRef.name }}</gl-link - > + <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top"> + <gl-link v-if="mergeRequestRef" :href="mergeRequestRef.path" class="ref-name"> + {{ mergeRequestRef.iid }} + </gl-link> + <gl-link v-else :href="refUrl" class="ref-name" data-testid="ref-name"> + {{ commitRef.name }} + </gl-link> + </tooltip-on-truncate> </template> <gl-icon name="commit" class="commit-icon js-commit-icon" /> 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 3790a509f26..7b88b36aa0f 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 @@ -1,5 +1,4 @@ <script> -/* eslint-disable vue/no-v-html */ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; @@ -110,6 +109,10 @@ export default { <template> <div ref="markdownPreview" class="md-previewer" data-testid="md-previewer"> <gl-skeleton-loading v-if="isLoading" /> - <div v-else class="md gl-ml-auto gl-mr-auto" v-html="previewContent"></div> + <div + v-else + class="md gl-ml-auto gl-mr-auto" + v-html="previewContent /* eslint-disable-line vue/no-v-html */" + ></div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue new file mode 100644 index 00000000000..56e6399a1b7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue @@ -0,0 +1,159 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlSprintf, +} from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { __, n__, s__, sprintf } from '~/locale'; + +export const i18n = { + messageAdditionsDeletions: s__('Diffs|with %{additions} and %{deletions}'), + noFilesFound: __('No files found.'), + noFileNameAvailable: s__('Diffs|No file name available'), + searchFiles: __('Search files'), +}; + +export default { + i18n, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlSprintf, + }, + props: { + changed: { + type: Number, + required: true, + }, + added: { + type: Number, + required: true, + }, + deleted: { + type: Number, + required: true, + }, + files: { + type: Array, + required: true, + }, + }, + data() { + return { + search: '', + }; + }, + computed: { + filteredFiles() { + return this.search.length > 0 + ? fuzzaldrinPlus.filter(this.files, this.search, { key: 'name' }) + : this.files; + }, + messageChanged() { + return sprintf( + n__( + 'Diffs|Showing %{dropdownStart}%{count} changed file%{dropdownEnd}', + 'Diffs|Showing %{dropdownStart}%{count} changed files%{dropdownEnd}', + this.changed, + ), + { count: this.changed }, + ); + }, + + additionsText() { + return n__('Diffs|%d addition', 'Diffs|%d additions', this.added); + }, + deletionsText() { + return n__('Diffs|%d deletion', 'Diffs|%d deletions', this.deleted); + }, + }, + methods: { + jumpToFile(fileHash) { + window.location.hash = fileHash; + }, + focusInput() { + this.$refs.search.focusInput(); + }, + }, +}; +</script> + +<template> + <div> + <gl-sprintf :message="messageChanged"> + <template #dropdown="{ content: dropdownText }"> + <gl-dropdown + category="tertiary" + variant="confirm" + :text="dropdownText" + data-testid="diff-stats-dropdown" + class="gl-vertical-align-baseline" + toggle-class="gl-px-0! gl-font-weight-bold!" + menu-class="gl-w-auto!" + no-flip + @shown="focusInput" + > + <template #header> + <gl-search-box-by-type + ref="search" + v-model.trim="search" + :placeholder="$options.i18n.searchFiles" + /> + </template> + <gl-dropdown-item + v-for="file in filteredFiles" + :key="file.href" + :icon-name="file.icon" + :icon-color="file.iconColor" + @click="jumpToFile(file.href)" + > + <div class="gl-display-flex"> + <span v-if="file.name" class="gl-font-weight-bold gl-mr-3 gl-text-truncate">{{ + file.name + }}</span> + <span v-else class="gl-mr-3 gl-font-weight-bold gl-font-style-italic gl-gray-400">{{ + $options.i18n.noFileNameAvailable + }}</span> + <span class="gl-ml-auto gl-white-space-nowrap"> + <span class="gl-text-green-600">+{{ file.added }}</span> + <span class="gl-text-red-500">-{{ file.removed }}</span> + </span> + </div> + <div class="gl-text-gray-700 gl-overflow-hidden gl-text-overflow-ellipsis"> + {{ file.path }} + </div> + </gl-dropdown-item> + <gl-dropdown-text v-if="!filteredFiles.length"> + {{ $options.i18n.noFilesFound }} + </gl-dropdown-text> + </gl-dropdown> + </template> + </gl-sprintf> + <span + class="diff-stats-additions-deletions-expanded" + data-testid="diff-stats-additions-deletions-expanded" + > + <gl-sprintf :message="$options.i18n.messageAdditionsDeletions"> + <template #additions> + <span class="gl-text-green-600 gl-font-weight-bold">{{ additionsText }}</span> + </template> + <template #deletions> + <span class="gl-text-red-500 gl-font-weight-bold">{{ deletionsText }}</span> + </template> + </gl-sprintf> + </span> + + <div + class="diff-stats-additions-deletions-collapsed gl-float-right gl-display-none" + data-testid="diff-stats-additions-deletions-collapsed" + > + <span class="gl-text-green-600 gl-font-weight-bold">+{{ added }}</span> + <span class="gl-text-red-500 gl-font-weight-bold">-{{ deleted }}</span> + </div> + </div> +</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 2e9634819a0..1df65d0a666 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 @@ -20,19 +20,26 @@ export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_ export const OPERATOR_IS_NOT_ONLY = [{ value: OPERATOR_IS_NOT, description: OPERATOR_IS_NOT_TEXT }]; export const OPERATOR_IS_AND_IS_NOT = [...OPERATOR_IS_ONLY, ...OPERATOR_IS_NOT_ONLY]; -export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) }; -export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) }; +export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') }; +export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([ - { value: FILTER_CURRENT, text: __(FILTER_CURRENT) }, + { value: FILTER_CURRENT, text: __('Current') }, ]); export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ - { value: FILTER_UPCOMING, text: __(FILTER_UPCOMING) }, - { value: FILTER_STARTED, text: __(FILTER_STARTED) }, + { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') }, + { value: FILTER_STARTED, text: __('Started'), title: __('Started') }, ]); +export const DEFAULT_MILESTONES_GRAPHQL = [ + { value: 'any', text: __('Any'), title: __('Any') }, + { value: 'none', text: __('None'), title: __('None') }, + { value: '#upcoming', text: __('Upcoming'), title: __('Upcoming') }, + { value: '#started', text: __('Started'), title: __('Started') }, +]; + export const SortDirection = { descending: 'descending', ascending: 'ascending', 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 6573f366b52..5cc96471aef 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 @@ -177,13 +177,10 @@ function filteredSearchTermValue(value) { * @param {Object} options * @param {String} [options.filteredSearchTermKey] if set, a FILTERED_SEARCH_TERM filter is created to this parameter. `'search'` is suggested * @param {String[]} [options.filterNamesAllowList] if set, only this list of filters names is mapped - * @param {Boolean} [options.legacySpacesDecode] if set, plus symbols (+) are not encoded as spaces. `false` is suggested * @return {Object} filter object with filter names and their values */ -export function urlQueryToFilter(query = '', options = {}) { - const { filteredSearchTermKey, filterNamesAllowList, legacySpacesDecode = true } = options; - - const filters = queryToObject(query, { gatherArrays: true, legacySpacesDecode }); +export function urlQueryToFilter(query = '', { filteredSearchTermKey, filterNamesAllowList } = {}) { + const filters = queryToObject(query, { gatherArrays: true }); return Object.keys(filters).reduce((memo, key) => { const value = filters[key]; if (!value) { @@ -222,7 +219,7 @@ export function urlQueryToFilter(query = '', options = {}) { */ export function getRecentlyUsedSuggestions(recentSuggestionsStorageKey) { let recentlyUsedSuggestions = []; - if (AccessorUtilities.isLocalStorageAccessSafe()) { + if (AccessorUtilities.canUseLocalStorage()) { recentlyUsedSuggestions = JSON.parse(localStorage.getItem(recentSuggestionsStorageKey)) || []; } return recentlyUsedSuggestions; @@ -240,7 +237,7 @@ export function setTokenValueToRecentlyUsed(recentSuggestionsStorageKey, tokenVa recentlyUsedSuggestions.splice(0, 0, { ...tokenValue }); - if (AccessorUtilities.isLocalStorageAccessSafe()) { + if (AccessorUtilities.canUseLocalStorage()) { localStorage.setItem( recentSuggestionsStorageKey, JSON.stringify(uniqWith(recentlyUsedSuggestions, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)), 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 4b9ad6d8f91..523438f459c 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 @@ -39,8 +39,16 @@ export default { }, methods: { getActiveMilestone(milestones, data) { - return milestones.find( - (milestone) => milestone.title.toLowerCase() === stripQuotes(data).toLowerCase(), + /* We need to check default milestones against the value not the + * title because there is a discrepancy between the value graphql + * accepts and the title. + * https://gitlab.com/gitlab-org/gitlab/-/issues/337687#note_648058797 + */ + + return ( + milestones.find( + (milestone) => milestone.title.toLowerCase() === stripQuotes(data).toLowerCase(), + ) || this.defaultMilestones.find(({ value }) => value === data) ); }, fetchMilestones(searchTerm) { diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index f169921d8a6..41613bb3307 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,6 +1,5 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlTooltipDirective, GlLink, GlButton, GlTooltip } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlTooltip, GlSafeHtmlDirective } from '@gitlab/ui'; import { glEmojiTag } from '../../emoji'; import { __, sprintf } from '../../locale'; import CiIconBadge from './ci_badge_link.vue'; @@ -25,6 +24,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, }, EMOJI_REF: 'EMOJI_REF', props: { @@ -37,8 +37,9 @@ export default { required: true, }, itemId: { - type: Number, - required: true, + type: String, + required: false, + default: '', }, time: { type: String, @@ -86,6 +87,13 @@ export default { message() { return this.user?.status?.message; }, + item() { + if (this.itemId) { + return `${this.itemName} #${this.itemId}`; + } + + return this.itemName; + }, }, methods: { @@ -93,6 +101,7 @@ export default { this.$emit('clickedSidebarButton'); }, }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> @@ -105,7 +114,7 @@ export default { <section class="header-main-content gl-mr-3"> <ci-icon-badge :status="status" /> - <strong data-testid="ci-header-item-text"> {{ itemName }} #{{ itemId }} </strong> + <strong data-testid="ci-header-item-text">{{ item }}</strong> <template v-if="shouldRenderTriggeredLabel">{{ __('triggered') }}</template> <template v-else>{{ __('created') }}</template> @@ -130,8 +139,8 @@ export default { <span v-if="statusTooltipHTML" :ref="$options.EMOJI_REF" + v-safe-html:[$options.safeHtmlConfig]="statusTooltipHTML" :data-testid="message" - v-html="statusTooltipHTML" ></span> </template> </section> diff --git a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js index 18bfcc268dc..28aa93d6680 100644 --- a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js +++ b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js @@ -1,10 +1,20 @@ import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; import IssuableHeaderWarnings from './issuable_header_warnings.vue'; export default function issuableHeaderWarnings(store) { + const el = document.getElementById('js-issuable-header-warnings'); + + if (!el) { + return false; + } + + const { hidden } = el.dataset; + return new Vue({ - el: document.getElementById('js-issuable-header-warnings'), + el, store, + provide: { hidden: parseBoolean(hidden) }, render(createElement) { return createElement(IssuableHeaderWarnings); }, diff --git a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue index 56adbe8c606..82223ab9ef4 100644 --- a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue +++ b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue @@ -1,11 +1,16 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import { __ } from '~/locale'; export default { components: { GlIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['hidden'], computed: { ...mapGetters(['getNoteableData']), isLocked() { @@ -26,6 +31,12 @@ export default { visible: this.isConfidential, dataTestId: 'confidential', }, + { + iconName: 'spam', + visible: this.hidden, + dataTestId: 'hidden', + tooltip: __('This issue is hidden because its author has been banned'), + }, ]; }, }, @@ -35,8 +46,15 @@ export default { <template> <div class="gl-display-inline-block"> <template v-for="meta in warningIconsMeta"> - <div v-if="meta.visible" :key="meta.iconName" class="issuable-warning-icon inline"> - <gl-icon :name="meta.iconName" :data-testid="meta.dataTestId" class="icon" /> + <div + v-if="meta.visible" + :key="meta.iconName" + v-gl-tooltip + :data-testid="meta.dataTestId" + :title="meta.tooltip || null" + class="issuable-warning-icon inline" + > + <gl-icon :name="meta.iconName" class="icon" /> </div> </template> </div> 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 ccdb47e3144..095d1854c8b 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,5 +1,4 @@ <script> -/* eslint-disable vue/no-v-html */ import '~/commons/bootstrap'; import { GlIcon, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; @@ -72,7 +71,7 @@ export default { class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap gl-min-h-7" > <!-- Title area: Status icon (XL) and title --> - <div class="item-title d-flex align-items-xl-center mb-xl-0"> + <div class="item-title d-flex align-items-xl-center mb-xl-0 gl-min-w-0"> <div ref="iconElementXL"> <gl-icon v-if="hasState" @@ -85,7 +84,7 @@ export default { /> </div> <gl-tooltip :target="() => $refs.iconElementXL"> - <span v-html="stateTitle"></span> + <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span> </gl-tooltip> <gl-icon v-if="confidential" @@ -111,7 +110,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"></span> + <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></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/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 9ea48050079..77730ada9bb 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable vue/no-v-html */ import { GlIcon } from '@gitlab/ui'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; @@ -15,6 +14,10 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MarkdownHeader from './header.vue'; import MarkdownToolbar from './toolbar.vue'; +function cleanUpLine(content) { + return unescape(stripHtml(content).replace(/\\n/g, '%br').replace(/\n/g, '')); +} + export default { components: { GfmAutocomplete, @@ -129,7 +132,7 @@ export default { return text; } - return unescape(stripHtml(richText).replace(/\n/g, '')); + return cleanUpLine(richText); }) .join('\\n'); } @@ -141,7 +144,7 @@ export default { return text; } - return unescape(stripHtml(richText).replace(/\n/g, '')); + return cleanUpLine(richText); } return ''; @@ -272,6 +275,7 @@ export default { :can-suggest="canSuggest" :show-suggest-popover="showSuggestPopover" :suggestion-start-index="suggestionsStartIndex" + data-testid="markdownHeader" @preview-markdown="showPreviewTab" @write-markdown="showWriteTab" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" @@ -319,14 +323,20 @@ export default { v-show="previewMarkdown" ref="markdown-preview" class="js-vue-md-preview md md-preview-holder" - v-html="markdownPreview" + v-html="markdownPreview /* eslint-disable-line vue/no-v-html */" ></div> </template> <template v-if="previewMarkdown && !markdownPreviewLoading"> - <div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div> + <div + v-if="referencedCommands" + class="referenced-commands" + v-html="referencedCommands /* eslint-disable-line vue/no-v-html */" + ></div> <div v-if="shouldShowReferencedUsers" class="referenced-users"> <gl-icon name="warning-solid" /> - <span v-html="addMultipleToDiscussionWarning"></span> + <span + v-html="addMultipleToDiscussionWarning /* eslint-disable-line vue/no-v-html */" + ></span> </div> </template> </div> 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 065d9b1b5dd..5fdef0b1a23 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 @@ -39,7 +39,8 @@ export default { }, defaultCommitMessage: { type: String, - required: true, + required: false, + default: null, }, inapplicableReason: { type: String, diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 7112295fa57..912aa8ce294 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -75,7 +75,7 @@ export default { variant="link" :track-experiment="$options.inviteMembersInComment" :trigger-source="$options.inviteMembersInComment" - data-track-event="comment_invite_click" + data-track-action="comment_invite_click" /> <span class="uploading-progress-container hide"> <gl-icon name="media" /> diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index ad6f6e0e2e3..0b302f22062 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable vue/no-v-html */ import { GlLink, GlIcon } from '@gitlab/ui'; import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; @@ -92,7 +91,9 @@ export default { <gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> <span v-if="isLockedAndConfidential" ref="lockedAndConfidential"> - <span v-html="confidentialAndLockedDiscussionText"></span> + <span + v-html="confidentialAndLockedDiscussionText /* eslint-disable-line vue/no-v-html */" + ></span> {{ __("People without permission will never get a notification and won't be able to comment.") }} diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index c3d861d74bc..755e6f1f224 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -1,6 +1,4 @@ <script> -/* eslint-disable vue/no-v-html */ - /** * Common component to render a system note, icon and user information. * @@ -97,6 +95,9 @@ export default { methods: { ...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']), }, + safeHtmlConfig: { + ADD_TAGS: ['use'], // to support icon SVGs + }, }; </script> @@ -106,7 +107,7 @@ export default { :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" class="note system-note note-wrapper" > - <div class="timeline-icon" v-html="iconHtml"></div> + <div v-safe-html:[$options.safeHtmlConfig]="iconHtml" class="timeline-icon"></div> <div class="timeline-content"> <div class="note-header"> <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> 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 8a67754993d..6867b5a75e3 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 @@ -1,5 +1,12 @@ <script> -import { GlAlert, GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; +import { + GlAlert, + GlBadge, + GlPagination, + GlTab, + GlTabs, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; import Api from '~/api'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -20,6 +27,9 @@ export default { GlTab, FilteredSearchBar, }, + directives: { + SafeHtml, + }, inject: { projectPath: { default: '', @@ -265,8 +275,7 @@ export default { <template> <div class="incident-management-list"> <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="$emit('error-alert-dismissed')"> - <!-- eslint-disable-next-line vue/no-v-html --> - <p v-html="serverErrorMessage || i18n.errorMsg"></p> + <p v-safe-html="serverErrorMessage || i18n.errorMsg"></p> </gl-alert> <div 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 69f43c9e464..36d3696ec36 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,4 @@ <script> -/* eslint-disable vue/no-v-html */ import { GlButton, GlIcon } from '@gitlab/ui'; import { isString } from 'lodash'; import highlight from '~/lib/utils/highlight'; @@ -61,7 +60,7 @@ export default { <div :title="project.name" class="js-project-name text-truncate" - v-html="highlightedProjectName" + v-html="highlightedProjectName /* eslint-disable-line vue/no-v-html */" ></div> </div> </gl-button> diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue index c63d91b78d3..4b21ec0330a 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -1,5 +1,6 @@ <script> import { GlAvatar, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui'; +import { isEqual } from 'lodash'; export default { name: 'TitleArea', @@ -36,13 +37,21 @@ export default { metadataSlots: [], }; }, - async mounted() { - const METADATA_PREFIX = 'metadata-'; - this.metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX)); + mounted() { + this.recalculateMetadataSlots(); + }, + updated() { + this.recalculateMetadataSlots(); + }, + methods: { + recalculateMetadataSlots() { + const METADATA_PREFIX = 'metadata-'; + const metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX)); - // we need to wait for next tick to ensure that dynamic names slots are picked up - await this.$nextTick(); - this.metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX)); + if (!isEqual(metadataSlots, this.metadataSlots)) { + this.metadataSlots = metadataSlots; + } + }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue index f21dea468cb..57cc25caa25 100644 --- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue +++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue @@ -1,5 +1,6 @@ <script> import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; +import awsCloudFormationImageUrl from 'images/aws-cloud-formation.png'; import ExperimentTracking from '~/experimentation/experiment_tracking'; import { getBaseURL, objectToQuery } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; @@ -22,6 +23,11 @@ export default { type: String, required: true, }, + imgSrc: { + type: String, + required: false, + default: awsCloudFormationImageUrl, + }, }, methods: { easyButtonUrl(easyButton) { @@ -76,7 +82,7 @@ export default { <img :title="easyButton.stackName" :alt="easyButton.stackName" - src="/assets/aws-cloud-formation.png" + :src="imgSrc" width="46" height="46" class="gl-mt-2 gl-mr-5 gl-mb-6" diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js new file mode 100644 index 00000000000..5242743ad30 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js @@ -0,0 +1,26 @@ +import SettingsBlock from './settings_block.vue'; + +export default { + component: SettingsBlock, + title: 'vue_shared/components/settings/settings_block', +}; + +const Template = (args, { argTypes }) => ({ + components: { SettingsBlock }, + props: Object.keys(argTypes), + template: ` + <settings-block v-bind="$props"> + <template #title>Settings section title</template> + <template #description>Settings section description</template> + <template #default> + <p>Content</p> + <p>More content</p> + <p>Content</p> + <p>More content...</p> + <p>Content</p> + </template> + </settings-block> + `, +}); + +export const Default = Template.bind({}); diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue index 92ae4575c52..e75fedbb1d7 100644 --- a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue +++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue @@ -1,5 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; + import { __ } from '~/locale'; export default { @@ -15,35 +17,99 @@ export default { default: false, required: false, }, + collapsible: { + type: Boolean, + default: true, + required: false, + }, }, data() { return { - sectionExpanded: false, + // Non-collapsible sections should always be expanded. + // For collapsible sections, fall back to defaultExpanded. + sectionExpanded: !this.collapsible || this.defaultExpanded, }; }, computed: { - expanded() { - return this.defaultExpanded || this.sectionExpanded; - }, toggleText() { - return this.expanded ? __('Collapse') : __('Expand'); + const { collapseText, expandText } = this.$options.i18n; + return this.sectionExpanded ? collapseText : expandText; + }, + settingsContentId() { + return uniqueId('settings_content_'); }, + settingsLabelId() { + return uniqueId('settings_label_'); + }, + toggleButtonAriaLabel() { + const { collapseAriaLabel, expandAriaLabel } = this.$options.i18n; + return this.sectionExpanded ? collapseAriaLabel : expandAriaLabel; + }, + ariaExpanded() { + return String(this.sectionExpanded); + }, + }, + methods: { + toggleSectionExpanded() { + this.sectionExpanded = !this.sectionExpanded; + + if (this.sectionExpanded) { + this.$refs.settingsContent.focus(); + } + }, + }, + i18n: { + collapseText: __('Collapse'), + expandText: __('Expand'), + collapseAriaLabel: __('Collapse settings section'), + expandAriaLabel: __('Expand settings section'), }, }; </script> <template> - <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded }"> + <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded: sectionExpanded }"> <div class="settings-header"> - <h4><slot name="title"></slot></h4> - <gl-button @click="sectionExpanded = !sectionExpanded"> + <h4> + <span + v-if="collapsible" + :id="settingsLabelId" + role="button" + tabindex="0" + class="gl-cursor-pointer" + :aria-controls="settingsContentId" + :aria-expanded="ariaExpanded" + data-testid="section-title-button" + @click="toggleSectionExpanded" + @keydown.enter.space="toggleSectionExpanded" + > + <slot name="title"></slot> + </span> + <template v-else> + <slot name="title"></slot> + </template> + </h4> + <gl-button + v-if="collapsible" + :aria-controls="settingsContentId" + :aria-expanded="ariaExpanded" + :aria-label="toggleButtonAriaLabel" + @click="toggleSectionExpanded" + > {{ toggleText }} </gl-button> <p> <slot name="description"></slot> </p> </div> - <div class="settings-content"> + <div + :id="settingsContentId" + ref="settingsContent" + :aria-labelledby="settingsLabelId" + tabindex="-1" + role="region" + class="settings-content" + > <slot></slot> </div> </section> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue index 46ccb9470e5..35ac9ef8565 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue @@ -1,5 +1,6 @@ <script> import { GlLabel } from '@gitlab/ui'; +import { sortBy } from 'lodash'; import { mapState } from 'vuex'; import { isScopedLabel } from '~/lib/utils/common_utils'; @@ -23,6 +24,9 @@ export default { 'labelsFilterBasePath', 'labelsFilterParam', ]), + sortedSelectedLabels() { + return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1)); + }, }, methods: { labelFilterUrl(label) { @@ -47,7 +51,7 @@ export default { <span v-if="!selectedLabels.length" class="text-secondary"> <slot></slot> </span> - <template v-for="label in selectedLabels" v-else> + <template v-for="label in sortedSelectedLabels" v-else> <gl-label :key="label.id" data-qa-selector="selected_label_content" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue index e8fdf4bb0c2..dd40add6376 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue @@ -56,7 +56,7 @@ export default { const labelLink = h( GlLink, { - class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal', + class: 'gl-display-flex gl-align-items-center label-item gl-text-body', on: { click: () => { listeners.clickLabel(label); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue deleted file mode 100644 index 60111210f5d..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue +++ /dev/null @@ -1,42 +0,0 @@ -<script> -import { GlButton, GlIcon } from '@gitlab/ui'; -import { mapActions, mapGetters } from 'vuex'; - -export default { - components: { - GlButton, - GlIcon, - }, - computed: { - ...mapGetters([ - 'dropdownButtonText', - 'isDropdownVariantStandalone', - 'isDropdownVariantEmbedded', - ]), - }, - methods: { - ...mapActions(['toggleDropdownContents']), - handleButtonClick(e) { - if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) { - this.toggleDropdownContents(); - } - - if (this.isDropdownVariantStandalone) { - e.stopPropagation(); - } - }, - }, -}; -</script> - -<template> - <gl-button - class="labels-select-dropdown-button js-dropdown-button w-100 text-left" - @click="handleButtonClick" - > - <span class="dropdown-toggle-text gl-pointer-events-none flex-fill"> - {{ dropdownButtonText }} - </span> - <gl-icon name="chevron-down" class="gl-pointer-events-none float-right" /> - </gl-button> -</template> 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 6694e349b6e..0fcc67c0ffa 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,22 +1,21 @@ <script> -import { GlButton } from '@gitlab/ui'; -import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; +import { isDropdownVariantSidebar, isDropdownVariantEmbedded } from './utils'; export default { components: { DropdownContentsLabelsView, DropdownContentsCreateView, GlButton, + GlDropdown, + GlDropdownItem, + GlLink, }, + inject: ['allowLabelCreate', 'labelsManagePath'], props: { - renderOnTop: { - type: Boolean, - required: false, - default: false, - }, labelsCreateTitle: { type: String, required: true, @@ -33,6 +32,10 @@ export default { type: String, required: true, }, + dropdownButtonText: { + type: String, + required: true, + }, footerCreateLabelTitle: { type: String, required: true, @@ -41,70 +44,105 @@ export default { type: String, required: true, }, + variant: { + type: String, + required: true, + }, + }, + data() { + return { + showDropdownContentsCreateView: false, + }; }, computed: { - ...mapState(['showDropdownContentsCreateView']), - ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantEmbedded']), dropdownContentsView() { if (this.showDropdownContentsCreateView) { return 'dropdown-contents-create-view'; } return 'dropdown-contents-labels-view'; }, - directionStyle() { - const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem'; - return this.renderOnTop ? { bottom } : {}; - }, dropdownTitle() { return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle; }, + showDropdownFooter() { + return ( + !this.showDropdownContentsCreateView && + (this.isDropdownVariantSidebar(this.variant) || + this.isDropdownVariantEmbedded(this.variant)) + ); + }, }, methods: { - ...mapActions(['toggleDropdownContentsCreateView', 'toggleDropdownContents']), + showDropdown() { + this.$refs.dropdown.show(); + }, + toggleDropdownContentsCreateView() { + this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView; + }, + toggleDropdownContent() { + this.toggleDropdownContentsCreateView(); + // Required to recalculate dropdown position as its size changes + this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); + }, + isDropdownVariantSidebar, + isDropdownVariantEmbedded, }, }; </script> <template> - <div - class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute" + <gl-dropdown + ref="dropdown" + :text="dropdownButtonText" + class="gl-w-full gl-mt-2" data-qa-selector="labels_dropdown_content" - :style="directionStyle" > - <div - v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" - class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" - data-testid="dropdown-title" - > - <gl-button - v-if="showDropdownContentsCreateView" - :aria-label="__('Go back')" - variant="link" - size="small" - class="js-btn-back dropdown-header-button p-0" - icon="arrow-left" - @click="toggleDropdownContentsCreateView" - /> - <span class="flex-grow-1">{{ dropdownTitle }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - size="small" - class="dropdown-header-button gl-p-0!" - icon="close" - @click="toggleDropdownContents" - /> - </div> + <template #header> + <div + v-if="isDropdownVariantSidebar(variant) || isDropdownVariantEmbedded(variant)" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + > + <gl-button + v-if="showDropdownContentsCreateView" + :aria-label="__('Go back')" + variant="link" + size="small" + class="js-btn-back dropdown-header-button gl-p-0" + icon="arrow-left" + data-testid="go-back-button" + @click.stop="toggleDropdownContent" + /> + <span class="gl-flex-grow-1">{{ dropdownTitle }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + size="small" + class="dropdown-header-button gl-p-0!" + icon="close" + @click="$emit('closeDropdown')" + /> + </div> + </template> <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" + @setLabels="$emit('setLabels', $event)" /> - </div> + <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" + > + {{ footerCreateLabelTitle }} + </gl-dropdown-item> + <gl-dropdown-item :href="labelsManagePath" @click.native.capture.stop> + {{ footerManageLabelTitle }} + </gl-dropdown-item> + </div> + </template> + </gl-dropdown> </template> 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 4651e7a1576..2e31b386fdd 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 @@ -1,8 +1,10 @@ <script> import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import produce from 'immer'; import createFlash from '~/flash'; import { __ } from '~/locale'; import createLabelMutation from './graphql/create_label.mutation.graphql'; +import projectLabelsQuery from './graphql/project_labels.query.graphql'; const errorMessage = __('Error creating label.'); @@ -47,6 +49,25 @@ export default { handleColorClick(color) { this.selectedColor = this.getColorCode(color); }, + updateLabelsInCache(store, label) { + const sourceData = store.readQuery({ + query: projectLabelsQuery, + variables: { fullPath: this.projectPath, searchTerm: '' }, + }); + + const collator = new Intl.Collator('en'); + const data = produce(sourceData, (draftData) => { + const { nodes } = draftData.workspace.labels; + nodes.push(label); + nodes.sort((a, b) => collator.compare(a.title, b.title)); + }); + + store.writeQuery({ + query: projectLabelsQuery, + variables: { fullPath: this.projectPath, searchTerm: '' }, + data, + }); + }, async createLabel() { this.labelCreateInProgress = true; try { @@ -59,6 +80,14 @@ export default { color: this.selectedColor, projectPath: this.projectPath, }, + update: ( + store, + { + data: { + labelCreate: { label }, + }, + }, + ) => this.updateLabelsInCache(store, label), }); if (labelCreate.errors.length) { createFlash({ message: errorMessage }); 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 ffa37424c2c..857367a0721 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,24 +1,23 @@ <script> -import { GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } 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 { 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: { + GlDropdownForm, + GlDropdownItem, GlLoadingIcon, GlSearchBoxByType, - GlLink, LabelItem, }, - inject: ['projectPath', 'allowLabelCreate', 'labelsManagePath', 'variant'], + inject: ['projectPath'], props: { selectedLabels: { type: Array, @@ -28,24 +27,11 @@ export default { 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], }; }, @@ -74,12 +60,6 @@ export default { }, }, computed: { - isDropdownVariantSidebar() { - return this.variant === DropdownVariant.Sidebar; - }, - isDropdownVariantEmbedded() { - return this.variant === DropdownVariant.Embedded; - }, labelsFetchInProgress() { return this.$apollo.queries.labels.loading; }, @@ -98,21 +78,11 @@ export default { return Boolean(this.searchKey) && this.visibleLabels.length === 0; }, }, - watch: { - searchKey(value) { - // When there is search string present - // and there are matching results, - // highlight first item by default. - if (value && this.visibleLabels.length) { - this.currentHighlightItem = 0; - } - }, - }, created() { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, beforeDestroy() { - this.$emit('closeDropdown', this.localSelectedLabels); + this.$emit('setLabels', this.localSelectedLabels); this.debouncedSearchKeyUpdate.cancel(); }, methods: { @@ -150,33 +120,6 @@ export default { }); } }, - /** - * This method enables keyboard navigation support for - * the dropdown. - */ - handleKeyDown(e) { - if (e.keyCode === UP_KEY_CODE && this.currentHighlightItem > 0) { - this.currentHighlightItem -= 1; - } else if ( - e.keyCode === DOWN_KEY_CODE && - this.currentHighlightItem < this.visibleLabels.length - 1 - ) { - this.currentHighlightItem += 1; - } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) { - this.updateSelectedLabels(this.visibleLabels[this.currentHighlightItem]); - this.searchKey = ''; - } else if (e.keyCode === ESC_KEY_CODE) { - this.$emit('closeDropdown', this.localSelectedLabels); - } - - if (e.keyCode !== ESC_KEY_CODE) { - // Scroll the list only after highlighting - // styles are rendered completely. - this.$nextTick(() => { - this.scrollIntoViewIfNeeded(); - }); - } - }, handleLabelClick(label) { this.updateSelectedLabels(label); if (!this.allowMultiselect) { @@ -191,69 +134,41 @@ export default { </script> <template> - <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-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" /> - <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" + <template v-else> + <gl-dropdown-item + v-for="label in visibleLabels" :key="label.id" - :label="label" - :is-label-set="isLabelSelected(label)" - :highlight="index === currentHighlightItem" - @clickLabel="handleLabelClick(label)" - /> - <li + :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') }} - </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> + </gl-dropdown-item> + </template> </div> - </div> + </gl-dropdown-form> </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 deleted file mode 100644 index 46edfa1c42a..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue +++ /dev/null @@ -1,40 +0,0 @@ -<script> -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; - -export default { - components: { - GlButton, - GlLoadingIcon, - }, - props: { - labelsSelectInProgress: { - type: Boolean, - required: true, - }, - }, - computed: { - ...mapState(['allowLabelEdit', 'labelsFetchInProgress']), - }, - methods: { - ...mapActions(['toggleDropdownContents']), - }, -}; -</script> - -<template> - <div class="title hide-collapsed gl-mb-3"> - {{ __('Labels') }} - <template v-if="allowLabelEdit"> - <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline /> - <gl-button - 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 - > - </template> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue index 58a940bca3b..71d3d87cce5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue @@ -1,5 +1,6 @@ <script> import { GlLabel } from '@gitlab/ui'; +import { sortBy } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isScopedLabel } from '~/lib/utils/common_utils'; @@ -7,6 +8,7 @@ export default { components: { GlLabel, }, + inject: ['allowScopedLabels'], props: { disableLabels: { type: Boolean, @@ -21,10 +23,6 @@ export default { type: Boolean, required: true, }, - allowScopedLabels: { - type: Boolean, - required: true, - }, labelsFilterBasePath: { type: String, required: true, @@ -34,6 +32,11 @@ export default { required: true, }, }, + computed: { + sortedSelectedLabels() { + return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1)); + }, + }, methods: { labelFilterUrl(label) { return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent( @@ -63,7 +66,7 @@ export default { </span> <template v-else> <gl-label - v-for="label in selectedLabels" + v-for="label in sortedSelectedLabels" :key="label.id" data-qa-selector="selected_label_content" :data-qa-label-name="label.title" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql index 9aa4f5d165e..eb478645a03 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql @@ -6,9 +6,7 @@ mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPa id color description - descriptionHtml title - textColor } errors } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue index e8fdf4bb0c2..f27f0b4e34c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue @@ -1,82 +1,21 @@ <script> -import { GlLink, GlIcon } from '@gitlab/ui'; - export default { - functional: true, props: { label: { type: Object, required: true, }, - isLabelSet: { - type: Boolean, - required: true, - }, - highlight: { - type: Boolean, - required: false, - default: false, - }, - }, - render(h, { props, listeners }) { - const { label, highlight, isLabelSet } = props; - - const labelColorBox = h('span', { - class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3', - style: { - backgroundColor: label.color, - }, - attrs: { - 'data-testid': 'label-color-box', - }, - }); - - const checkedIcon = h(GlIcon, { - class: { - 'gl-mr-3 gl-flex-shrink-0': true, - hidden: !isLabelSet, - }, - props: { - name: 'mobile-issue-close', - }, - }); - - const noIcon = h('span', { - class: { - 'gl-mr-5 gl-pr-3': true, - hidden: isLabelSet, - }, - attrs: { - 'data-testid': 'no-icon', - }, - }); - - const labelTitle = h('span', label.title); - - const labelLink = h( - GlLink, - { - class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal', - on: { - click: () => { - listeners.clickLabel(label); - }, - }, - }, - [noIcon, checkedIcon, labelColorBox, labelTitle], - ); - - return h( - 'li', - { - class: { - 'gl-display-block': true, - 'gl-text-left': true, - 'is-focused': highlight, - }, - }, - [labelLink], - ); }, }; </script> + +<template> + <div> + <span + class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3" + :style="{ 'background-color': label.color }" + data-testid="label-color-box" + ></span> + <span>{{ label.title }}</span> + </div> +</template> 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 0499dfe468f..3c834770563 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,57 +1,40 @@ <script> -import $ from 'jquery'; import Vue from 'vue'; -import Vuex, { mapState, mapActions, mapGetters } from 'vuex'; -import { isInViewport } from '~/lib/utils/common_utils'; +import Vuex from 'vuex'; import { __ } from '~/locale'; - +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import { DropdownVariant } from './constants'; -import DropdownButton from './dropdown_button.vue'; import DropdownContents from './dropdown_contents.vue'; -import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import issueLabelsQuery from './graphql/issue_labels.query.graphql'; -import labelsSelectModule from './store'; +import { + isDropdownVariantSidebar, + isDropdownVariantStandalone, + isDropdownVariantEmbedded, +} from './utils'; Vue.use(Vuex); export default { - store: new Vuex.Store(labelsSelectModule()), components: { - DropdownTitle, DropdownValue, - DropdownButton, DropdownContents, DropdownValueCollapsed, + SidebarEditableItem, }, - inject: ['iid', 'projectPath'], + inject: ['iid', 'projectPath', 'allowLabelEdit'], props: { allowLabelRemove: { type: Boolean, required: false, default: false, }, - allowLabelEdit: { - type: Boolean, - required: false, - default: false, - }, - allowLabelCreate: { - type: Boolean, - required: false, - default: false, - }, allowMultiselect: { type: Boolean, required: false, default: false, }, - allowScopedLabels: { - type: Boolean, - required: false, - default: false, - }, variant: { type: String, required: false, @@ -67,16 +50,6 @@ export default { required: false, default: false, }, - labelsFetchPath: { - type: String, - required: false, - default: '', - }, - labelsManagePath: { - type: String, - required: false, - default: '', - }, labelsFilterBasePath: { type: String, required: false, @@ -138,149 +111,25 @@ export default { }, }, }, - computed: { - ...mapState(['showDropdownButton', 'showDropdownContents']), - ...mapGetters([ - 'isDropdownVariantSidebar', - 'isDropdownVariantStandalone', - 'isDropdownVariantEmbedded', - ]), - dropdownButtonVisible() { - return this.isDropdownVariantSidebar ? this.showDropdownButton : true; - }, - }, - watch: { - selectedLabels(selectedLabels) { - this.setInitialState({ - selectedLabels, - }); - }, - showDropdownContents(showDropdownContents) { - this.setContentIsOnViewport(showDropdownContents); - }, - isEditing(newVal) { - if (newVal) { - this.toggleDropdownContents(); - } - }, - }, - mounted() { - this.setInitialState({ - variant: this.variant, - allowLabelRemove: this.allowLabelRemove, - allowLabelEdit: this.allowLabelEdit, - allowLabelCreate: this.allowLabelCreate, - allowMultiselect: this.allowMultiselect, - allowScopedLabels: this.allowScopedLabels, - dropdownButtonText: this.dropdownButtonText, - selectedLabels: this.selectedLabels, - labelsFetchPath: this.labelsFetchPath, - labelsManagePath: this.labelsManagePath, - labelsFilterBasePath: this.labelsFilterBasePath, - labelsFilterParam: this.labelsFilterParam, - labelsListTitle: this.labelsListTitle, - footerCreateLabelTitle: this.footerCreateLabelTitle, - footerManageLabelTitle: this.footerManageLabelTitle, - }); - - this.$store.subscribeAction({ - after: this.handleVuexActionDispatch, - }); - - document.addEventListener('mousedown', this.handleDocumentMousedown); - document.addEventListener('click', this.handleDocumentClick); - }, - beforeDestroy() { - document.removeEventListener('mousedown', this.handleDocumentMousedown); - document.removeEventListener('click', this.handleDocumentClick); - }, methods: { - ...mapActions(['setInitialState', 'toggleDropdownContents']), - /** - * This method stores a mousedown event's target. - * Required by the click listener because the click - * event itself has no reference to this element. - */ - handleDocumentMousedown({ target }) { - this.mousedownTarget = target; - }, - /** - * This method listens for document-wide click event - * and toggle dropdown if user clicks anywhere outside - * the dropdown while dropdown is visible. - */ - handleDocumentClick({ target }) { - // We also perform the toggle exception check for the - // last mousedown event's target to avoid hiding the - // box when the mousedown happened inside the box and - // only the mouseup did not. - if ( - this.showDropdownContents && - !this.preventDropdownToggleOnClick(target) && - !this.preventDropdownToggleOnClick(this.mousedownTarget) - ) { - this.toggleDropdownContents(); - } - }, - /** - * This method checks whether a given click target - * should prevent the dropdown from being toggled. - */ - preventDropdownToggleOnClick(target) { - // This approach of element detection is needed - // as the dropdown wrapper is not using `GlDropdown` as - // it will also require us to use `BDropdownForm` - // which is yet to be implemented in GitLab UI. - const hasExceptionClass = [ - 'js-dropdown-button', - 'js-btn-cancel-create', - 'js-sidebar-dropdown-toggle', - ].some( - (className) => - target?.classList.contains(className) || - target?.parentElement?.classList.contains(className), - ); - - const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some( - (className) => $(target).parents(className).length, - ); - - const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target); - - const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target); - - return ( - hasExceptionClass || - hasExceptionParent || - isInDropdownButtonCollapsed || - isInDropdownContents - ); - }, 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'); }, + collapseDropdown() { + this.$refs.editable.collapse(); + }, handleCollapsedValueClick() { this.$emit('toggleCollapse'); }, - setContentIsOnViewport(showDropdownContents) { - if (!showDropdownContents) { - this.contentIsOnViewport = true; - - return; - } - + showDropdown() { this.$nextTick(() => { - if (this.$refs.dropdownContents) { - this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el); - } + this.$refs.dropdownContents.showDropdown(); }); }, + isDropdownVariantSidebar, + isDropdownVariantStandalone, + isDropdownVariantEmbedded, }, }; </script> @@ -289,58 +138,63 @@ export default { <div class="labels-select-wrapper position-relative" :class="{ - 'is-standalone': isDropdownVariantStandalone, - 'is-embedded': isDropdownVariantEmbedded, + 'is-standalone': isDropdownVariantStandalone(variant), + 'is-embedded': isDropdownVariantEmbedded(variant), }" > - <template v-if="isDropdownVariantSidebar"> + <template v-if="isDropdownVariantSidebar(variant)"> <dropdown-value-collapsed ref="dropdownButtonCollapsed" :labels="issueLabels" @onValueClick="handleCollapsedValueClick" /> - <dropdown-title - :allow-label-edit="allowLabelEdit" - :labels-select-in-progress="labelsSelectInProgress" - /> - <dropdown-value - :disable-labels="labelsSelectInProgress" - :selected-labels="issueLabels" - :allow-label-remove="allowLabelRemove" - :allow-scoped-labels="allowScopedLabels" - :labels-filter-base-path="labelsFilterBasePath" - :labels-filter-param="labelsFilterParam" - @onLabelRemove="$emit('onLabelRemove', $event)" + <sidebar-editable-item + ref="editable" + :title="__('Labels')" + :loading="labelsSelectInProgress" + :can-edit="allowLabelEdit" + @open="showDropdown" > - <slot></slot> - </dropdown-value> - <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> - <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" - :labels-create-title="labelsCreateTitle" - :selected-labels="selectedLabels" - @closeDropdown="handleDropdownClose" - /> - </template> - <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded"> - <dropdown-button v-show="dropdownButtonVisible" /> - <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 #collapsed> + <dropdown-value + :disable-labels="labelsSelectInProgress" + :selected-labels="issueLabels" + :allow-label-remove="allowLabelRemove" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" + @onLabelRemove="$emit('onLabelRemove', $event)" + > + <slot></slot> + </dropdown-value> + </template> + <template #default="{ edit }"> + <dropdown-value + :disable-labels="labelsSelectInProgress" + :selected-labels="issueLabels" + :allow-label-remove="allowLabelRemove" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" + class="gl-mb-2" + @onLabelRemove="$emit('onLabelRemove', $event)" + > + <slot></slot> + </dropdown-value> + <dropdown-contents + v-if="edit" + ref="dropdownContents" + :dropdown-button-text="dropdownButtonText" + :allow-multiselect="allowMultiselect" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + :labels-create-title="labelsCreateTitle" + :selected-labels="selectedLabels" + :variant="variant" + @closeDropdown="collapseDropdown" + @setLabels="handleDropdownClose" + /> + </template> + </sidebar-editable-item> </template> </div> </template> 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 deleted file mode 100644 index b3d4a204a81..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js +++ /dev/null @@ -1,12 +0,0 @@ -import * as types from './mutation_types'; - -export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props); - -export const toggleDropdownButton = ({ commit }) => commit(types.TOGGLE_DROPDOWN_BUTTON); -export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS); - -export const toggleDropdownContentsCreateView = ({ commit }) => - commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW); - -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/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js deleted file mode 100644 index d14f96720b7..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js +++ /dev/null @@ -1,52 +0,0 @@ -import { __, s__, sprintf } from '~/locale'; -import { DropdownVariant } from '../constants'; - -/** - * Returns string representing current labels - * selection on dropdown button. - * - * @param {object} state - */ -export const dropdownButtonText = (state, getters) => { - const selectedLabels = getters.isDropdownVariantSidebar - ? state.labels.filter((label) => label.set) - : state.selectedLabels; - - if (!selectedLabels.length) { - return state.dropdownButtonText || __('Label'); - } else if (selectedLabels.length > 1) { - return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { - firstLabelName: selectedLabels[0].title, - remainingLabelCount: selectedLabels.length - 1, - }); - } - return selectedLabels[0].title; -}; - -/** - * Returns array containing only label IDs from - * selectedLabels array. - * @param {object} state - */ -export const selectedLabelsList = (state) => state.selectedLabels.map((label) => label.id); - -/** - * Returns boolean representing whether dropdown variant - * is `sidebar` - * @param {object} state - */ -export const isDropdownVariantSidebar = (state) => state.variant === DropdownVariant.Sidebar; - -/** - * Returns boolean representing whether dropdown variant - * is `standalone` - * @param {object} state - */ -export const isDropdownVariantStandalone = (state) => state.variant === DropdownVariant.Standalone; - -/** - * Returns boolean representing whether dropdown variant - * is `embedded` - * @param {object} state - */ -export const isDropdownVariantEmbedded = (state) => state.variant === DropdownVariant.Embedded; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js deleted file mode 100644 index 5f61cb732c8..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import * as actions from './actions'; -import * as getters from './getters'; -import mutations from './mutations'; -import state from './state'; - -export default () => ({ - namespaced: true, - state: state(), - actions, - getters, - mutations, -}); 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 deleted file mode 100644 index bd71c3b85f1..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js +++ /dev/null @@ -1,8 +0,0 @@ -export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; - -export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY'; -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'; 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 deleted file mode 100644 index 45ec4d7ae04..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js +++ /dev/null @@ -1,50 +0,0 @@ -import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; -import { DropdownVariant } from '../constants'; -import * as types from './mutation_types'; - -export default { - [types.SET_INITIAL_STATE](state, props) { - Object.assign(state, { ...props }); - }, - - [types.TOGGLE_DROPDOWN_BUTTON](state) { - state.showDropdownButton = !state.showDropdownButton; - }, - - [types.TOGGLE_DROPDOWN_CONTENTS](state) { - if (state.variant === DropdownVariant.Sidebar) { - state.showDropdownButton = !state.showDropdownButton; - } - state.showDropdownContents = !state.showDropdownContents; - // Ensure that Create View is hidden by default - // when dropdown contents are revealed. - if (state.showDropdownContents) { - state.showDropdownContentsCreateView = false; - } - }, - - [types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) { - state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView; - }, - [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. - const labelId = labels.pop()?.id; - const candidateLabel = state.labels.find((label) => labelId === label.id); - if (candidateLabel) { - candidateLabel.touched = true; - candidateLabel.set = !candidateLabel.set; - } - - if (isScopedLabel(candidateLabel)) { - const scopedBase = scopedLabelKey(candidateLabel); - const currentActiveScopedLabel = state.labels.find( - ({ title }) => title.indexOf(scopedBase) === 0 && title !== candidateLabel.title, - ); - - if (currentActiveScopedLabel) { - currentActiveScopedLabel.set = false; - } - } - }, -}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js deleted file mode 100644 index 220bab05ed2..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js +++ /dev/null @@ -1,28 +0,0 @@ -export default () => ({ - // Initial Data - labels: [], - selectedLabels: [], - labelsListTitle: '', - footerCreateLabelTitle: '', - footerManageLabelTitle: '', - dropdownButtonText: '', - - // Paths - namespace: '', - labelsFetchPath: '', - labelsFilterBasePath: '', - - // UI Flags - variant: '', - allowLabelRemove: false, - allowLabelCreate: false, - allowLabelEdit: false, - allowScopedLabels: false, - allowMultiselect: false, - showDropdownButton: false, - showDropdownContents: false, - showDropdownContentsCreateView: false, - labelsFetchInProgress: false, - labelCreateInProgress: false, - selectedLabelsUpdated: false, -}); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js new file mode 100644 index 00000000000..b5cd946a189 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js @@ -0,0 +1,22 @@ +import { DropdownVariant } from './constants'; + +/** + * Returns boolean representing whether dropdown variant + * is `sidebar` + * @param {string} variant + */ +export const isDropdownVariantSidebar = (variant) => variant === DropdownVariant.Sidebar; + +/** + * Returns boolean representing whether dropdown variant + * is `standalone` + * @param {string} variant + */ +export const isDropdownVariantStandalone = (variant) => variant === DropdownVariant.Standalone; + +/** + * Returns boolean representing whether dropdown variant + * is `embedded` + * @param {string} variant + */ +export const isDropdownVariantEmbedded = (variant) => variant === DropdownVariant.Embedded; diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js new file mode 100644 index 00000000000..00aa5519ec6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js @@ -0,0 +1,38 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +import '@gitlab/ui/dist/utility_classes.css'; +import UsageGraph from './usage_graph.vue'; + +export default { + component: UsageGraph, + title: 'vue_shared/components/storage_counter/usage_graph', +}; + +const Template = (args, { argTypes }) => ({ + components: { UsageGraph }, + props: Object.keys(argTypes), + template: '<usage-graph v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.argTypes = { + rootStorageStatistics: { + description: 'The statistics object with all its fields', + type: { name: 'object', required: true }, + defaultValue: { + buildArtifactsSize: 400000, + pipelineArtifactsSize: 38000, + lfsObjectsSize: 4800000, + packagesSize: 3800000, + repositorySize: 39000000, + snippetsSize: 2000112, + storageSize: 39930000, + uploadsSize: 7000, + wikiSize: 300000, + }, + }, + limit: { + description: + 'When a limit is set, users will see how much of their storage usage (limit) is used. In case the limit is 0 or the current usage exceeds the limit, it just renders the distribution', + defaultValue: 0, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue new file mode 100644 index 00000000000..c33d065ff4b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue @@ -0,0 +1,148 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { s__ } from '~/locale'; + +export default { + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + rootStorageStatistics: { + required: true, + type: Object, + }, + limit: { + required: true, + type: Number, + }, + }, + computed: { + storageTypes() { + const { + buildArtifactsSize, + pipelineArtifactsSize, + lfsObjectsSize, + packagesSize, + repositorySize, + storageSize, + wikiSize, + snippetsSize, + uploadsSize, + } = this.rootStorageStatistics; + const artifactsSize = buildArtifactsSize + pipelineArtifactsSize; + + if (storageSize === 0) { + return null; + } + + return [ + { + name: s__('UsageQuota|Repositories'), + style: this.usageStyle(this.barRatio(repositorySize)), + class: 'gl-bg-data-viz-blue-500', + size: repositorySize, + }, + { + name: s__('UsageQuota|LFS Objects'), + style: this.usageStyle(this.barRatio(lfsObjectsSize)), + class: 'gl-bg-data-viz-orange-600', + size: lfsObjectsSize, + }, + { + name: s__('UsageQuota|Packages'), + style: this.usageStyle(this.barRatio(packagesSize)), + class: 'gl-bg-data-viz-aqua-500', + size: packagesSize, + }, + { + name: s__('UsageQuota|Artifacts'), + style: this.usageStyle(this.barRatio(artifactsSize)), + class: 'gl-bg-data-viz-green-600', + size: artifactsSize, + tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'), + }, + { + name: s__('UsageQuota|Wikis'), + style: this.usageStyle(this.barRatio(wikiSize)), + class: 'gl-bg-data-viz-magenta-500', + size: wikiSize, + }, + { + name: s__('UsageQuota|Snippets'), + style: this.usageStyle(this.barRatio(snippetsSize)), + class: 'gl-bg-data-viz-orange-800', + size: snippetsSize, + }, + { + name: s__('UsageQuota|Uploads'), + style: this.usageStyle(this.barRatio(uploadsSize)), + class: 'gl-bg-data-viz-aqua-700', + size: uploadsSize, + }, + ] + .filter((data) => data.size !== 0) + .sort((a, b) => b.size - a.size); + }, + }, + methods: { + formatSize(size) { + return numberToHumanSize(size); + }, + usageStyle(ratio) { + return { flex: ratio }; + }, + barRatio(size) { + let max = this.rootStorageStatistics.storageSize; + + if (this.limit !== 0 && max <= this.limit) { + max = this.limit; + } + + return size / max; + }, + }, +}; +</script> +<template> + <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100"> + <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex"> + <div + v-for="storageType in storageTypes" + :key="storageType.name" + class="storage-type-usage gl-h-full gl-display-inline-block" + :class="storageType.class" + :style="storageType.style" + data-testid="storage-type-usage" + ></div> + </div> + <div class="row py-0"> + <div + v-for="storageType in storageTypes" + :key="storageType.name" + class="col-md-auto gl-display-flex gl-align-items-center" + data-testid="storage-type-legend" + > + <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div> + <span class="gl-mr-2 gl-font-weight-bold gl-font-sm"> + {{ storageType.name }} + </span> + <span class="gl-text-gray-500 gl-font-sm"> + {{ formatSize(storageType.size) }} + </span> + <span + v-if="storageType.tooltip" + v-gl-tooltip + :title="storageType.tooltip" + :aria-label="storageType.tooltip" + class="gl-ml-2" + > + <gl-icon name="question" :size="12" /> + </span> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue index b9ee74d6a03..42334d80eec 100644 --- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue @@ -66,7 +66,7 @@ export default { }; </script> <template> - <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!"> + <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!" v-bind="$attrs"> <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus /> <gl-dropdown-item v-for="timezone in filteredResults" 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 f387f8ca128..74616763f8f 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 @@ -1,6 +1,12 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlPopover, GlLink, GlSkeletonLoader, GlIcon } from '@gitlab/ui'; +import { + GlPopover, + GlLink, + GlSkeletonLoader, + GlIcon, + GlSafeHtmlDirective, + GlSprintf, +} from '@gitlab/ui'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import { glEmojiTag } from '../../../emoji'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; @@ -17,6 +23,10 @@ export default { GlSkeletonLoader, UserAvatarImage, UserNameWithStatus, + GlSprintf, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, }, props: { target: { @@ -50,6 +60,7 @@ export default { return this.user?.status?.availability || ''; }, }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> @@ -83,7 +94,7 @@ export default { <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" /> - <span ref="bio" class="gl-ml-2 gl-overflow-hidden" v-html="user.bioHtml"></span> + <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" /> @@ -95,12 +106,14 @@ export default { <span class="gl-ml-2">{{ user.location }}</span> </div> <div v-if="statusHtml" class="js-user-status gl-mt-3"> - <span v-html="statusHtml"></span> + <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span> </div> <div v-if="user.bot" class="gl-text-blue-500"> <gl-icon name="question" /> <gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl"> - {{ sprintf(__('Learn more about %{username}'), { username: user.name }) }} + <gl-sprintf :message="__('Learn more about %{username}')"> + <template #username>{{ user.name }}</template> + </gl-sprintf> </gl-link> </div> </template> |