diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared')
60 files changed, 709 insertions, 1029 deletions
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js index 106dd7a3b97..957e642fcb8 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js @@ -1 +1,5 @@ export const HIGHLIGHT_CLASS_NAME = 'hll'; +export const MARKUP_FILE_TYPE = 'markup'; +export const MARKUP_CONTENT_SELECTOR = '.js-markup-content'; +export const ELEMENTS_PER_CHUNK = 20; +export const CONTENT_LOADED_EVENT = 'richContentLoaded'; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index 11ce6afbb1d..27bdcc69120 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -3,7 +3,14 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { handleBlobRichViewer } from '~/blob/viewer'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; import { handleLocationHash } from '~/lib/utils/common_utils'; +import { sanitize } from '~/lib/dompurify'; import ViewerMixin from './mixins'; +import { + MARKUP_FILE_TYPE, + MARKUP_CONTENT_SELECTOR, + ELEMENTS_PER_CHUNK, + CONTENT_LOADED_EVENT, +} from './constants'; export default { components: { @@ -16,21 +23,77 @@ export default { data() { return { isLoading: true, + initialContent: null, + remainingContent: [], }; }, + computed: { + rawContent() { + return this.initialContent || this.richViewer || this.content; + }, + isMarkup() { + return this.type === MARKUP_FILE_TYPE; + }, + }, + created() { + this.optimizeMarkupRendering(); + }, mounted() { - window.requestIdleCallback(async () => { + this.renderRemainingMarkup(); + handleBlobRichViewer(this.$refs.content, this.type); + handleLocationHash(); + }, + methods: { + optimizeMarkupRendering() { + /** + * If content is markup we optimize rendering by splitting it into two parts: + * - initialContent (top section of the file - is rendered right away) + * - remainingContent (remaining content - is rendered over a longer time period) + * + * This is done so that the browser doesn't render the whole file at once (improves TBT) + */ + + if (!this.isMarkup) return; + + const tmpWrapper = document.createElement('div'); + tmpWrapper.innerHTML = sanitize(this.rawContent, this.$options.safeHtmlConfig); + + const fileContent = tmpWrapper.querySelector(MARKUP_CONTENT_SELECTOR); + if (!fileContent) return; + + const initialContent = [...fileContent.childNodes].slice(0, ELEMENTS_PER_CHUNK); + this.remainingContent = [...fileContent.childNodes].slice(ELEMENTS_PER_CHUNK); + + fileContent.innerHTML = ''; + fileContent.append(...initialContent); + this.initialContent = tmpWrapper.outerHTML; + }, + renderRemainingMarkup() { /** - * Rendering Markdown usually takes long due to the amount of HTML being parsed. - * This ensures that content is loaded only when the browser goes into idle. + * Rendering large Markdown files can block the main thread due to the amount of HTML being parsed. + * The optimization below ensures that content is rendered over a longer time period instead of all at once. * More details here: https://gitlab.com/gitlab-org/gitlab/-/issues/331448 * */ - this.isLoading = false; - await this.$nextTick(); - handleBlobRichViewer(this.$refs.content, this.type); - handleLocationHash(); - this.$emit('richContentLoaded'); - }); + + if (!this.isMarkup || !this.remainingContent.length) { + this.$emit(CONTENT_LOADED_EVENT); + this.isLoading = false; + return; + } + + const fileContent = this.$refs.content.$el.querySelector(MARKUP_CONTENT_SELECTOR); + + for (let i = 0; i < this.remainingContent.length; i += ELEMENTS_PER_CHUNK) { + const nextChunkEnd = i + ELEMENTS_PER_CHUNK; + const content = this.remainingContent.slice(i, nextChunkEnd); + setTimeout(() => { + fileContent.append(...content); + if (nextChunkEnd < this.remainingContent.length) return; + this.$emit(CONTENT_LOADED_EVENT); + this.isLoading = false; + }, i); + } + }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'], @@ -39,8 +102,8 @@ export default { </script> <template> <markdown-field-view - v-if="!isLoading" ref="content" - v-safe-html:[$options.safeHtmlConfig]="richViewer || content" + v-safe-html:[$options.safeHtmlConfig]="rawContent" + :is-loading="isLoading" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 14e99977a85..2a47e96b2e2 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -50,11 +50,14 @@ export default { tooltipTitle() { if (!this.showTooltip) { return undefined; - } else if (this.file.deleted) { + } + if (this.file.deleted) { return __('Deleted'); - } else if (this.file.tempFile) { + } + if (this.file.tempFile) { return __('Added'); - } else if (this.file.changed) { + } + if (this.file.changed) { return __('Modified'); } diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue index 9aa7a7d6c49..1f45b4c5c9d 100644 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue @@ -1,6 +1,7 @@ <script> import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; import CiIcon from './ci_icon.vue'; + /** * Renders CI Badge link with CI icon and status text based on * API response shared between all places where it is used. @@ -48,7 +49,7 @@ export default { required: false, default: true, }, - badgeSize: { + size: { type: String, required: false, default: badgeSizeOptions.md, @@ -59,7 +60,7 @@ export default { }, computed: { isSmallBadgeSize() { - return this.badgeSize === badgeSizeOptions.sm; + return this.size === badgeSizeOptions.sm; }, title() { return !this.showText ? this.status?.text : ''; @@ -120,13 +121,12 @@ export default { <template> <gl-badge v-gl-tooltip - :class="{ 'gl-pl-0!': isSmallBadgeSize }" + :class="{ 'gl-pl-2': isSmallBadgeSize }" :title="title" :href="detailsPath" - :size="badgeSize" + :size="size" :variant="badgeStyles.variant" - :data-testid="`ci-badge-${status.text}`" - data-qa-selector="status_badge_link" + data-testid="ci-badge-link" @click="$emit('ciStatusBadgeClick')" > <ci-icon :status="status" /> diff --git a/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue index 31c98d1e3a7..025e38a55ad 100644 --- a/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue +++ b/app/assets/javascripts/vue_shared/components/confidentiality_badge.vue @@ -1,10 +1,11 @@ <script> -import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { confidentialityInfoText } from '../constants'; export default { components: { GlBadge, + GlIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -18,22 +19,31 @@ export default { type: String, required: true, }, + hideTextInSmallScreens: { + type: Boolean, + required: false, + default: false, + }, }, computed: { confidentialTooltip() { return confidentialityInfoText(this.workspaceType, this.issuableType); }, + confidentialTextClass() { + return { + 'gl-display-none gl-sm-display-block': this.hideTextInSmallScreens, + 'gl-ml-2': true, + }; + }, }, }; </script> <template> - <gl-badge - v-gl-tooltip.bottom - :title="confidentialTooltip" - icon="eye-slash" - variant="warning" - class="gl-display-inline gl-mr-3" - >{{ __('Confidential') }}</gl-badge - > + <gl-badge v-gl-tooltip :title="confidentialTooltip" variant="warning"> + <gl-icon name="eye-slash" :size="16" /> + <span data-testid="confidential-badge-text" :class="confidentialTextClass">{{ + __('Confidential') + }}</span> + </gl-badge> </template> diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 65a601ed927..a1ef1f30ebb 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -38,7 +38,16 @@ export default { default: CONFIRM_DANGER_MODAL_CANCEL, }, }, + model: { + prop: 'visible', + event: 'change', + }, props: { + visible: { + type: Boolean, + required: false, + default: null, + }, modalId: { type: String, required: true, @@ -89,12 +98,15 @@ export default { <template> <gl-modal ref="modal" + :visible="visible" :modal-id="modalId" :data-testid="modalId" :title="$options.i18n.CONFIRM_DANGER_MODAL_TITLE" :action-primary="actionPrimary" :action-cancel="actionCancel" + size="sm" @primary="$emit('confirm')" + @change="$emit('change', $event)" > <gl-alert v-if="confirmDangerMessage" diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue deleted file mode 100644 index d8a2789a419..00000000000 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ /dev/null @@ -1,283 +0,0 @@ -<script> -import { GlIcon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui'; -import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range'; -import { __, sprintf } from '~/locale'; - -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import DateTimePickerInput from './date_time_picker_input.vue'; -import { - defaultTimeRanges, - defaultTimeRange, - isValidInputString, - inputStringToIsoDate, - isoDateToInputString, -} from './date_time_picker_lib'; - -const events = { - input: 'input', - invalid: 'invalid', -}; - -export default { - components: { - GlIcon, - GlButton, - GlDropdown, - GlDropdownItem, - GlFormGroup, - TooltipOnTruncate, - DateTimePickerInput, - }, - props: { - value: { - type: Object, - required: false, - default: () => defaultTimeRange, - }, - options: { - type: Array, - required: false, - default: () => defaultTimeRanges, - }, - customEnabled: { - type: Boolean, - required: false, - default: true, - }, - utc: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - timeRange: this.value, - - /** - * Valid start iso date string, null if not valid value - */ - startDate: null, - /** - * Invalid start date string as input by the user - */ - startFallbackVal: '', - - /** - * Valid end iso date string, null if not valid value - */ - endDate: null, - /** - * Invalid end date string as input by the user - */ - endFallbackVal: '', - }; - }, - computed: { - startInputValid() { - return isValidInputString(this.startDate); - }, - endInputValid() { - return isValidInputString(this.endDate); - }, - isValid() { - return this.startInputValid && this.endInputValid; - }, - - startInput: { - get() { - return this.dateToInput(this.startDate) || this.startFallbackVal; - }, - set(val) { - try { - this.startDate = this.inputToDate(val); - this.startFallbackVal = null; - } catch (e) { - this.startDate = null; - this.startFallbackVal = val; - } - this.timeRange = null; - }, - }, - endInput: { - get() { - return this.dateToInput(this.endDate) || this.endFallbackVal; - }, - set(val) { - try { - this.endDate = this.inputToDate(val); - this.endFallbackVal = null; - } catch (e) { - this.endDate = null; - this.endFallbackVal = val; - } - this.timeRange = null; - }, - }, - - timeWindowText() { - try { - const timeRange = findTimeRange(this.value, this.options); - if (timeRange) { - return timeRange.label; - } - - const { start, end } = convertToFixedRange(this.value); - if (isValidInputString(start) && isValidInputString(end)) { - return sprintf(__('%{start} to %{end}'), { - start: this.stripZerosInDateTime(this.dateToInput(start)), - end: this.stripZerosInDateTime(this.dateToInput(end)), - }); - } - } catch { - return __('Invalid date range'); - } - return ''; - }, - - customLabel() { - if (this.utc) { - return __('Custom range (UTC)'); - } - return __('Custom range'); - }, - }, - watch: { - value(newValue) { - const { start, end } = convertToFixedRange(newValue); - this.timeRange = this.value; - this.startDate = start; - this.endDate = end; - }, - }, - mounted() { - try { - const { start, end } = convertToFixedRange(this.timeRange); - this.startDate = start; - this.endDate = end; - } catch { - // when dates cannot be parsed, emit error. - this.$emit(events.invalid); - } - - // Validate on mounted, and trigger an update if needed - if (!this.isValid) { - this.$emit(events.invalid); - } - }, - methods: { - dateToInput(date) { - if (date === null) { - return null; - } - return isoDateToInputString(date, this.utc); - }, - inputToDate(value) { - return inputStringToIsoDate(value, this.utc); - }, - stripZerosInDateTime(str = '') { - return str.replace(' 00:00:00', ''); - }, - closeDropdown() { - this.$refs.dropdown.hide(); - }, - isOptionActive(option) { - return isEqualTimeRanges(option, this.timeRange); - }, - setQuickRange(option) { - this.timeRange = option; - this.$emit(events.input, this.timeRange); - }, - setFixedRange() { - this.timeRange = convertToFixedRange({ - start: this.startDate, - end: this.endDate, - }); - this.$emit(events.input, this.timeRange); - }, - }, -}; -</script> -<template> - <tooltip-on-truncate - :title="timeWindowText" - :truncate-target="(elem) => elem.querySelector('.gl-dropdown-toggle-text')" - placement="top" - class="d-inline-block" - > - <gl-dropdown - ref="dropdown" - :text="timeWindowText" - v-bind="$attrs" - class="date-time-picker w-100" - menu-class="date-time-picker-menu" - toggle-class="date-time-picker-toggle text-truncate" - > - <template #button-content> - <span class="gl-flex-grow-1 text-truncate">{{ timeWindowText }}</span> - <span v-if="utc" class="gl-text-gray-500 gl-font-weight-bold gl-font-sm">{{ - __('UTC') - }}</span> - <gl-icon class="gl-dropdown-caret" name="chevron-down" /> - </template> - - <div class="d-flex justify-content-between gl-p-2"> - <gl-form-group - v-if="customEnabled" - :label="customLabel" - label-for="custom-from-time" - label-class="gl-pb-2" - class="custom-time-range-form-group col-md-7 gl-pl-2 gl-pr-0 m-0" - > - <div class="gl-pt-3"> - <date-time-picker-input - id="custom-time-from" - v-model="startInput" - :label="__('From')" - :state="startInputValid" - /> - <date-time-picker-input - id="custom-time-to" - v-model="endInput" - :label="__('To')" - :state="endInputValid" - /> - </div> - <gl-form-group> - <gl-button data-testid="cancelButton" @click="closeDropdown">{{ - __('Cancel') - }}</gl-button> - <gl-button - variant="confirm" - category="primary" - :disabled="!isValid" - @click="setFixedRange()" - > - {{ __('Apply') }} - </gl-button> - </gl-form-group> - </gl-form-group> - <gl-form-group label-for="group-id-dropdown" class="col-md-5 gl-px-2 m-0"> - <template #label> - <span class="gl-pl-7">{{ __('Quick range') }}</span> - </template> - - <gl-dropdown-item - v-for="(option, index) in options" - :key="index" - :active="isOptionActive(option)" - active-class="active" - @click="setQuickRange(option)" - > - <gl-icon - name="mobile-issue-close" - class="align-bottom" - :class="{ invisible: !isOptionActive(option) }" - /> - {{ option.label }} - </gl-dropdown-item> - </gl-form-group> - </div> - </gl-dropdown> - </tooltip-on-truncate> -</template> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue deleted file mode 100644 index 190d4e1f104..00000000000 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_input.vue +++ /dev/null @@ -1,77 +0,0 @@ -<script> -import { GlFormGroup, GlFormInput } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import { __, sprintf } from '~/locale'; -import { dateFormats } from './date_time_picker_lib'; - -const inputGroupText = { - invalidFeedback: sprintf(__('Format: %{dateFormat}'), { - dateFormat: dateFormats.inputFormat, - }), - placeholder: dateFormats.inputFormat, -}; - -export default { - components: { - GlFormGroup, - GlFormInput, - }, - props: { - state: { - default: null, - required: true, - validator: (prop) => typeof prop === 'boolean' || prop === null, - }, - value: { - default: null, - required: false, - validator: (prop) => typeof prop === 'string' || prop === null, - }, - label: { - type: String, - default: '', - required: true, - }, - id: { - type: String, - required: false, - default: () => uniqueId('dateTimePicker_'), - }, - }, - data() { - return { - inputGroupText, - }; - }, - computed: { - invalidFeedback() { - return this.state ? '' : this.inputGroupText.invalidFeedback; - }, - inputState() { - // When the state is valid we want to show no - // green outline. Hence passing null and not true. - if (this.state === true) { - return null; - } - return this.state; - }, - }, - methods: { - onInputBlur(e) { - this.$emit('input', e.target.value.trim() || null); - }, - }, -}; -</script> - -<template> - <gl-form-group :label="label" label-size="sm" :label-for="id" :invalid-feedback="invalidFeedback"> - <gl-form-input - :id="id" - :value="value" - :state="inputState" - :placeholder="inputGroupText.placeholder" - @blur="onInputBlur" - /> - </gl-form-group> -</template> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js deleted file mode 100644 index 38b1a587b34..00000000000 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js +++ /dev/null @@ -1,91 +0,0 @@ -import dateformat from '~/lib/dateformat'; -import { __ } from '~/locale'; - -/** - * Default time ranges for the date picker. - * @see app/assets/javascripts/lib/utils/datetime_range.js - */ -export const defaultTimeRanges = [ - { - duration: { seconds: 60 * 30 }, - label: __('30 minutes'), - }, - { - duration: { seconds: 60 * 60 * 3 }, - label: __('3 hours'), - }, - { - duration: { seconds: 60 * 60 * 8 }, - label: __('8 hours'), - default: true, - }, - { - duration: { seconds: 60 * 60 * 24 * 1 }, - label: __('1 day'), - }, -]; - -export const defaultTimeRange = defaultTimeRanges.find((tr) => tr.default); - -export const dateFormats = { - /** - * Format used by users to input dates - * - * Note: Should be a format that can be parsed by Date.parse. - */ - inputFormat: 'yyyy-mm-dd HH:MM:ss', - /** - * Format used to strip timezone from inputs - */ - stripTimezoneFormat: "yyyy-mm-dd'T'HH:MM:ss'Z'", -}; - -/** - * Returns true if the date can be parsed succesfully after - * being typed by a user. - * - * It allows some ambiguity so validation is not strict. - * - * @param {string} value - Value as typed by the user - * @returns true if the value can be parsed as a valid date, false otherwise - */ -export const isValidInputString = (value) => { - try { - // dateformat throws error that can be caught. - // This is better than using `new Date()` - if (value && value.trim()) { - dateformat(value, 'isoDateTime'); - return true; - } - return false; - } catch (e) { - return false; - } -}; - -/** - * Convert the input in time picker component to an ISO date. - * - * @param {string} value - * @param {Boolean} utc - If true, it forces the date to by - * formatted using UTC format, ignoring the local time. - * @returns {Date} - */ -export const inputStringToIsoDate = (value, utc = false) => { - let date = new Date(value); - if (utc) { - // Forces date to be interpreted as UTC by stripping the timezone - // by formatting to a string with 'Z' and skipping timezone - date = dateformat(date, dateFormats.stripTimezoneFormat); - } - return dateformat(date, 'isoUtcDateTime'); -}; - -/** - * Converts a iso date string to a formatted string for the Time picker component. - * - * @param {String} ISO Formatted date - * @returns {string} - */ -export const isoDateToInputString = (date, utc = false) => - dateformat(date, dateFormats.inputFormat, utc); diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index 7080e046b30..535f1c5f645 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -65,7 +65,8 @@ export default { viewer() { if (this.diffViewerMode === diffViewerModes.renamed) { return RenamedFile; - } else if (this.diffMode === diffModes.mode_changed) { + } + if (this.diffMode === diffModes.mode_changed) { return ModeChanged; } diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue deleted file mode 100644 index 53210cbcc93..00000000000 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/empty_file.vue +++ /dev/null @@ -1,3 +0,0 @@ -<template> - <div class="nothing-here-block">{{ __('Empty file') }}</div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue index 3bb168e9051..b34a6b11092 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue @@ -64,6 +64,11 @@ export default { required: false, default: undefined, }, + noOptionsText: { + type: String, + required: false, + default: __('No options found'), + }, }, computed: { isSearchEmpty() { @@ -72,6 +77,9 @@ export default { noOptionsFound() { return !this.isSearchEmpty && this.options.length === 0; }, + noOptions() { + return this.isSearchEmpty && this.options.length === 0; + }, }, methods: { selectOption(option) { @@ -177,6 +185,9 @@ export default { <gl-dropdown-item v-if="noOptionsFound" class="gl-pl-6!"> {{ $options.i18n.noMatchingResults }} </gl-dropdown-item> + <gl-dropdown-item v-if="noOptions"> + {{ noOptionsText }} + </gl-dropdown-item> </template> </gl-dropdown-form> </slot> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue index 71e3bf4ff63..eb7b20fa4c1 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue @@ -19,6 +19,11 @@ export default { EntitySelect, }, props: { + apiParams: { + type: Object, + required: false, + default: () => ({}), + }, label: { type: String, required: false, @@ -48,7 +53,7 @@ export default { default: null, }, groupsFilter: { - type: String, + type: String, // Two supported values: `descendant_groups` and `subgroups` See app/assets/javascripts/vue_shared/components/entity_select/utils.js. required: false, default: null, }, @@ -62,17 +67,15 @@ export default { async fetchGroups(searchString = '', page = 1) { let groups = []; let totalPages = 0; + const params = { + search: searchString, + per_page: DEFAULT_PER_PAGE, + page, + ...this.apiParams, + }; try { - const { data = [], headers } = await axios.get( - Api.buildUrl(groupsPath(this.groupsFilter, this.parentGroupID)), - { - params: { - search: searchString, - per_page: DEFAULT_PER_PAGE, - page, - }, - }, - ); + const url = groupsPath(this.groupsFilter, this.parentGroupID); + const { data = [], headers } = await axios.get(url, { params }); groups = data.map((group) => ({ ...group, text: group.full_name, diff --git a/app/assets/javascripts/vue_shared/components/entity_select/utils.js b/app/assets/javascripts/vue_shared/components/entity_select/utils.js index 0a4622269f4..857a3ab4c74 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/utils.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/utils.js @@ -1,15 +1,26 @@ import Api from '~/api'; +/** + * @param {'descendant_groups'|'subgroups'|null} [groupsFilter] - type of group filtering + * @param {string|null} [parentGroupID] - parent group is needed for 'descendant_groups' and 'subgroups' filtering. + */ export const groupsPath = (groupsFilter, parentGroupID) => { - if (groupsFilter !== undefined && parentGroupID === undefined) { + if (groupsFilter && !parentGroupID) { throw new Error('Cannot use groupsFilter without a parentGroupID'); } + + let url = ''; switch (groupsFilter) { case 'descendant_groups': - return Api.descendantGroupsPath.replace(':id', parentGroupID); + url = Api.descendantGroupsPath.replace(':id', parentGroupID); + break; case 'subgroups': - return Api.subgroupsPath.replace(':id', parentGroupID); + url = Api.subgroupsPath.replace(':id', parentGroupID); + break; default: - return Api.groupsPath; + url = Api.groupsPath; + break; } + + return Api.buildUrl(url); }; diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index db0b0ea185b..226f44a1541 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -145,7 +145,8 @@ export default { el.classList.contains('inputarea') ) { return true; - } else if (combo === 'mod+p') { + } + if (combo === 'mod+p') { return false; } diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 721f87ff4d6..cecd1be82e9 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -141,7 +141,6 @@ export default { ref="textOutput" class="file-row-name" :title="file.name" - data-qa-selector="file_name_content" :data-qa-file-name="file.name" data-testid="file-row-name-container" :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]" 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 2b3d1b2c1f5..c698b94749d 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 @@ -73,6 +73,7 @@ export const TOKEN_TITLE_RELEASE = __('Release'); export const TOKEN_TITLE_REVIEWER = s__('SearchToken|Reviewer'); export const TOKEN_TITLE_SOURCE_BRANCH = __('Source Branch'); export const TOKEN_TITLE_STATUS = __('Status'); +export const TOKEN_TITLE_JOBS_RUNNER_TYPE = s__('Job|Runner type'); export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch'); export const TOKEN_TITLE_TYPE = __('Type'); export const TOKEN_TITLE_SEARCH_WITHIN = __('Search Within'); @@ -100,6 +101,7 @@ export const TOKEN_TYPE_RELEASE = 'release'; export const TOKEN_TYPE_REVIEWER = 'reviewer'; export const TOKEN_TYPE_SOURCE_BRANCH = 'source-branch'; export const TOKEN_TYPE_STATUS = 'status'; +export const TOKEN_TYPE_JOBS_RUNNER_TYPE = 'jobs-runner-type'; export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_WEIGHT = 'weight'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index f31d4d53a23..346384e3023 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -73,7 +73,8 @@ export default { }, searchInputPlaceholder: { type: String, - required: true, + required: false, + default: __('Search or filter results…'), }, suggestionsListClass: { type: String, 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 8322fe92de4..77108ad3628 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 @@ -2,6 +2,8 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; +import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; +import searchMilestonesQuery from '~/issues/list/queries/search_milestones.query.graphql'; import { sortMilestonesByDueDate } from '~/milestones/utils'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { stripQuotes } from '~/lib/utils/text_utility'; @@ -36,6 +38,14 @@ export default { defaultMilestones() { return this.config.defaultMilestones || DEFAULT_MILESTONES; }, + namespace() { + return this.config.isProject ? WORKSPACE_PROJECT : WORKSPACE_GROUP; + }, + fetchMilestonesQuery() { + return this.config.fetchMilestones + ? this.config.fetchMilestones + : this.fetchMilestonesBySearchTerm; + }, }, methods: { getActiveMilestone(milestones, data) { @@ -51,10 +61,17 @@ export default { ) || this.defaultMilestones.find(({ value }) => value === data) ); }, + fetchMilestonesBySearchTerm(search) { + return this.$apollo + .query({ + query: searchMilestonesQuery, + variables: { fullPath: this.config.fullPath, search, isProject: this.config.isProject }, + }) + .then(({ data }) => data[this.namespace]?.milestones.nodes); + }, fetchMilestones(searchTerm) { this.loading = true; - this.config - .fetchMilestones(searchTerm) + this.fetchMilestonesQuery(searchTerm) .then((response) => { const data = Array.isArray(response) ? response : response.data; diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue index 7da45169fee..a375a167c68 100644 --- a/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list.vue @@ -24,6 +24,7 @@ export default { :key="group.id" :group="group" :show-group-icon="showGroupIcon" + @delete="$emit('delete', $event)" /> </ul> </template> diff --git a/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue index 8a301cd0dd0..ca1e7400f2d 100644 --- a/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/groups_list/groups_list_item.vue @@ -1,5 +1,6 @@ <script> import { GlAvatarLabeled, GlIcon, GlTooltipDirective, GlTruncateText } from '@gitlab/ui'; +import uniqueId from 'lodash/uniqueId'; import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/visibility_level/constants'; import { ACCESS_LEVEL_LABELS } from '~/access_level/constants'; @@ -7,6 +8,9 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge. import { __ } from '~/locale'; import { numberToMetricPrefix } from '~/lib/utils/number_utils'; import SafeHtml from '~/vue_shared/directives/safe_html'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; +import ListActions from '~/vue_shared/components/list_actions/list_actions.vue'; +import DangerConfirmModal from '~/vue_shared/components/confirm_danger/confirm_danger_modal.vue'; export default { i18n: { @@ -25,6 +29,8 @@ export default { GlIcon, UserAccessRoleBadge, GlTruncateText, + ListActions, + DangerConfirmModal, }, directives: { GlTooltip: GlTooltipDirective, @@ -41,6 +47,12 @@ export default { default: false, }, }, + data() { + return { + isDeleteModalVisible: false, + modalId: uniqueId('groups-list-item-modal-id-'), + }; + }, computed: { visibility() { return this.group.visibility; @@ -75,94 +87,131 @@ export default { groupMembersCount() { return numberToMetricPrefix(this.group.groupMembersCount); }, + actions() { + return { + [ACTION_EDIT]: { + href: this.group.editPath, + }, + [ACTION_DELETE]: { + action: this.onActionDelete, + }, + }; + }, + hasActions() { + return this.group.availableActions?.length; + }, + hasActionDelete() { + return this.group.availableActions?.includes(ACTION_DELETE); + }, + }, + methods: { + onActionDelete() { + this.isDeleteModalVisible = true; + }, }, }; </script> <template> - <li class="groups-list-item gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b"> - <div class="gl-display-flex gl-flex-grow-1"> - <gl-icon - v-if="showGroupIcon" - class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" - :name="groupIconName" - /> - <gl-avatar-labeled - :entity-id="group.id" - :entity-name="group.fullName" - :label="group.fullName" - :label-link="group.webUrl" - shape="rect" - :size="$options.avatarSize" - > - <template #meta> - <div class="gl-px-2"> - <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap"> - <div class="gl-px-2"> - <gl-icon - v-if="visibility" - v-gl-tooltip="visibilityTooltip" - :name="visibilityIcon" - class="gl-text-secondary" - /> - </div> - <div class="gl-px-2"> - <user-access-role-badge v-if="shouldShowAccessLevel">{{ - accessLevelLabel - }}</user-access-role-badge> + <li class="groups-list-item gl-py-5 gl-border-b gl-display-flex gl-align-items-flex-start"> + <div class="gl-md-display-flex gl-align-items-center gl-flex-grow-1"> + <div class="gl-display-flex gl-flex-grow-1"> + <gl-icon + v-if="showGroupIcon" + class="gl-mr-3 gl-mt-3 gl-md-mt-5 gl-flex-shrink-0 gl-text-secondary" + :name="groupIconName" + /> + <gl-avatar-labeled + :entity-id="group.id" + :entity-name="group.fullName" + :label="group.fullName" + :label-link="group.webUrl" + shape="rect" + :size="$options.avatarSize" + > + <template #meta> + <div class="gl-px-2"> + <div class="gl-mx-n2 gl-display-flex gl-align-items-center gl-flex-wrap"> + <div class="gl-px-2"> + <gl-icon + v-if="visibility" + v-gl-tooltip="visibilityTooltip" + :name="visibilityIcon" + class="gl-text-secondary" + /> + </div> + <div class="gl-px-2"> + <user-access-role-badge v-if="shouldShowAccessLevel">{{ + accessLevelLabel + }}</user-access-role-badge> + </div> </div> </div> + </template> + <gl-truncate-text + v-if="group.descriptionHtml" + :lines="2" + :mobile-lines="2" + :show-more-text="$options.i18n.showMore" + :show-less-text="$options.i18n.showLess" + class="gl-mt-2" + > + <div + v-safe-html:[$options.safeHtmlConfig]="group.descriptionHtml" + class="gl-font-sm md" + data-testid="group-description" + ></div> + </gl-truncate-text> + </gl-avatar-labeled> + </div> + <div + class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0 gl-md-ml-3" + :class="statsPadding" + > + <div class="gl-display-flex gl-align-items-center gl-gap-x-3"> + <div + v-gl-tooltip="$options.i18n.subgroups" + :aria-label="$options.i18n.subgroups" + class="gl-text-secondary" + data-testid="subgroups-count" + > + <gl-icon name="subgroup" /> + <span>{{ descendantGroupsCount }}</span> </div> - </template> - <gl-truncate-text - v-if="group.descriptionHtml" - :lines="2" - :mobile-lines="2" - :show-more-text="$options.i18n.showMore" - :show-less-text="$options.i18n.showLess" - class="gl-mt-2" - > <div - v-safe-html:[$options.safeHtmlConfig]="group.descriptionHtml" - class="gl-font-sm md" - data-testid="group-description" - ></div> - </gl-truncate-text> - </gl-avatar-labeled> - </div> - <div - class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-pl-0 gl-md-mt-0 gl-md-ml-3" - :class="statsPadding" - > - <div class="gl-display-flex gl-align-items-center gl-gap-x-3"> - <div - v-gl-tooltip="$options.i18n.subgroups" - :aria-label="$options.i18n.subgroups" - class="gl-text-secondary" - data-testid="subgroups-count" - > - <gl-icon name="subgroup" /> - <span>{{ descendantGroupsCount }}</span> - </div> - <div - v-gl-tooltip="$options.i18n.projects" - :aria-label="$options.i18n.projects" - class="gl-text-secondary" - data-testid="projects-count" - > - <gl-icon name="project" /> - <span>{{ projectsCount }}</span> - </div> - <div - v-gl-tooltip="$options.i18n.directMembers" - :aria-label="$options.i18n.directMembers" - class="gl-text-secondary" - data-testid="members-count" - > - <gl-icon name="users" /> - <span>{{ groupMembersCount }}</span> + v-gl-tooltip="$options.i18n.projects" + :aria-label="$options.i18n.projects" + class="gl-text-secondary" + data-testid="projects-count" + > + <gl-icon name="project" /> + <span>{{ projectsCount }}</span> + </div> + <div + v-gl-tooltip="$options.i18n.directMembers" + :aria-label="$options.i18n.directMembers" + class="gl-text-secondary" + data-testid="members-count" + > + <gl-icon name="users" /> + <span>{{ groupMembersCount }}</span> + </div> </div> </div> </div> + <list-actions + v-if="hasActions" + class="gl-ml-3 gl-md-align-self-center" + :actions="actions" + :available-actions="group.availableActions" + /> + + <danger-confirm-modal + v-if="hasActionDelete" + v-model="isDeleteModalVisible" + :modal-id="modalId" + :phrase="group.fullName" + @confirm="$emit('delete', group)" + /> </li> </template> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue deleted file mode 100644 index 1adda905006..00000000000 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ /dev/null @@ -1,172 +0,0 @@ -<script> -import { GlTooltipDirective, GlButton, GlAvatarLink, GlAvatarLabeled, GlTooltip } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { glEmojiTag } from '~/emoji'; -import { __, sprintf } from '~/locale'; -import CiBadgeLink from './ci_badge_link.vue'; -import TimeagoTooltip from './time_ago_tooltip.vue'; - -/** - * Renders header component for job and pipeline page based on UI mockups - * - * Used in: - * - job show page - * - pipeline show page - */ -export default { - components: { - CiBadgeLink, - TimeagoTooltip, - GlButton, - GlAvatarLink, - GlAvatarLabeled, - GlTooltip, - }, - directives: { - GlTooltip: GlTooltipDirective, - SafeHtml, - }, - EMOJI_REF: 'EMOJI_REF', - props: { - status: { - type: Object, - required: true, - }, - itemName: { - type: String, - required: true, - }, - itemId: { - type: String, - required: false, - default: '', - }, - time: { - type: String, - required: true, - }, - user: { - type: Object, - required: false, - default: () => ({}), - }, - hasSidebarButton: { - type: Boolean, - required: false, - default: false, - }, - shouldRenderTriggeredLabel: { - type: Boolean, - required: false, - default: true, - }, - }, - - computed: { - userAvatarAltText() { - return sprintf(__(`%{username}'s avatar`), { username: this.user.name }); - }, - userPath() { - // GraphQL returns `webPath` and Rest `path` - return this.user?.webPath || this.user?.path; - }, - avatarUrl() { - // GraphQL returns `avatarUrl` and Rest `avatar_url` - return this.user?.avatarUrl || this.user?.avatar_url; - }, - webUrl() { - // GraphQL returns `webUrl` and Rest `web_url` - return this.user?.webUrl || this.user?.web_url; - }, - statusTooltipHTML() { - // Rest `status_tooltip_html` which is a ready to work - // html for the emoji and the status text inside a tooltip. - // GraphQL returns `status.emoji` and `status.message` which - // needs to be combined to make the html we want. - const { emoji } = this.user?.status || {}; - const emojiHtml = emoji ? glEmojiTag(emoji) : ''; - - return emojiHtml || this.user?.status_tooltip_html; - }, - message() { - return this.user?.status?.message; - }, - item() { - if (this.itemId) { - return `${this.itemName} #${this.itemId}`; - } - - return this.itemName; - }, - userId() { - return isGid(this.user?.id) ? getIdFromGraphQLId(this.user?.id) : this.user?.id; - }, - }, - - methods: { - onClickSidebarButton() { - this.$emit('clickedSidebarButton'); - }, - }, - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, -}; -</script> - -<template> - <header class="page-content-header gl-md-display-flex gl-min-h-7" data-testid="ci-header-content"> - <section class="header-main-content gl-mr-3"> - <ci-badge-link class="gl-mr-3" :status="status" /> - - <strong data-testid="ci-header-item-text">{{ item }}</strong> - - <template v-if="shouldRenderTriggeredLabel">{{ __('triggered') }}</template> - <template v-else>{{ __('created') }}</template> - - <timeago-tooltip :time="time" /> - - {{ __('by') }} - - <template v-if="user"> - <gl-avatar-link - :data-user-id="userId" - :data-username="user.username" - :data-name="user.name" - :href="webUrl" - target="_blank" - class="js-user-link gl-vertical-align-middle gl-mx-2 gl-align-items-center" - > - <gl-avatar-labeled - :size="24" - :src="avatarUrl" - :label="user.name" - class="gl-display-none gl-sm-display-inline-flex gl-mx-1" - /> - <strong class="author gl-display-inline gl-sm-display-none!">@{{ user.username }}</strong> - <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]"> - {{ message }} - </gl-tooltip> - <span - v-if="statusTooltipHTML" - :ref="$options.EMOJI_REF" - v-safe-html:[$options.safeHtmlConfig]="statusTooltipHTML" - class="gl-ml-2" - :data-testid="message" - ></span> - </gl-avatar-link> - </template> - </section> - - <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> - <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex"> - <slot></slot> - </section> - <gl-button - v-if="hasSidebarButton" - class="gl-md-display-none gl-ml-auto gl-align-self-start js-sidebar-build-toggle" - icon="chevron-double-lg-left" - :aria-label="__('Toggle sidebar')" - @click="onClickSidebarButton" - /> - </header> -</template> diff --git a/app/assets/javascripts/vue_shared/components/incidents/utils.js b/app/assets/javascripts/vue_shared/components/incidents/utils.js deleted file mode 100644 index bcb578a6ba6..00000000000 --- a/app/assets/javascripts/vue_shared/components/incidents/utils.js +++ /dev/null @@ -1,3 +0,0 @@ -import { noop } from 'lodash'; - -export const isValidSlaDueAt = noop; diff --git a/app/assets/javascripts/vue_shared/components/list_actions/constants.js b/app/assets/javascripts/vue_shared/components/list_actions/constants.js new file mode 100644 index 00000000000..b1506ae1e93 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_actions/constants.js @@ -0,0 +1,16 @@ +import { __ } from '~/locale'; + +export const ACTION_EDIT = 'edit'; +export const ACTION_DELETE = 'delete'; + +export const BASE_ACTIONS = { + [ACTION_EDIT]: { + text: __('Edit'), + }, + [ACTION_DELETE]: { + text: __('Delete'), + extraAttrs: { + class: 'gl-text-red-500!', + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/list_actions/list_actions.stories.js b/app/assets/javascripts/vue_shared/components/list_actions/list_actions.stories.js new file mode 100644 index 00000000000..d34729c2373 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_actions/list_actions.stories.js @@ -0,0 +1,44 @@ +import { makeContainer } from 'storybook_addons/make_container'; +import ListActions from './list_actions.vue'; +import { ACTION_DELETE, ACTION_EDIT } from './constants'; + +export default { + component: ListActions, + title: 'vue_shared/list_actions', + decorators: [makeContainer({ height: '115px' })], + parameters: { + docs: { + description: { + component: ` +This component renders actions used by lists of resources such as groups and projects. +Currently it is used by \`ProjectsListItem\`. There are base actions defined in \`~/vue_shared/components/list_actions\` +that help reduce the amount of boilerplate needed for common actions such as edit and delete. This component accepts an +\`actions\` prop that can extend the base actions and/or add custom actions. These actions should follow the format of +a [disclosure dropdown item](https://gitlab-org.gitlab.io/gitlab-ui/?path=/docs/base-new-dropdowns-disclosure--docs#setting-disclosure-dropdown-items). +The \`availableActions\` prop defines what actions to render and in what order. This prop will generally be set by checking +permissions of the current user. +`, + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + components: { ListActions }, + props: Object.keys(argTypes), + template: '<list-actions v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + actions: { + [ACTION_EDIT]: { + href: '/?path=/story/vue-shared-list-actions--default', + }, + [ACTION_DELETE]: { + // eslint-disable-next-line no-console + action: () => console.log('Deleted'), + }, + }, + availableActions: [ACTION_EDIT, ACTION_DELETE], +}; diff --git a/app/assets/javascripts/vue_shared/components/list_actions/list_actions.vue b/app/assets/javascripts/vue_shared/components/list_actions/list_actions.vue new file mode 100644 index 00000000000..7b78cc1da8f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_actions/list_actions.vue @@ -0,0 +1,52 @@ +<script> +import { GlDisclosureDropdown } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { BASE_ACTIONS } from './constants'; + +export default { + name: 'ListActions', + i18n: { + actions: __('Actions'), + }, + components: { + GlDisclosureDropdown, + }, + props: { + // Can extend `BASE_ACTIONS` and/or add new actions. + // Expected format: https://gitlab-org.gitlab.io/gitlab-ui/?path=/docs/base-new-dropdowns-disclosure--docs#setting-disclosure-dropdown-items + actions: { + type: Object, + required: true, + }, + availableActions: { + type: Array, + required: true, + }, + }, + computed: { + items() { + return this.availableActions.reduce((accumulator, action) => { + return [ + ...accumulator, + { + ...BASE_ACTIONS[action], + ...this.actions[action], + }, + ]; + }, []); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown + :items="items" + icon="ellipsis_v" + no-caret + :toggle-text="$options.i18n.actions" + text-sr-only + placement="right" + category="tertiary" + /> +</template> 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 a570abae9d3..05ce007e615 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -1,9 +1,9 @@ <script> -import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlForm, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui'; import { __, n__ } from '~/locale'; export default { - components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton, GlAlert }, + components: { GlDisclosureDropdown, GlForm, GlFormTextarea, GlButton, GlAlert }, props: { disabled: { type: Boolean, @@ -39,43 +39,58 @@ export default { return n__('Apply %d suggestion', 'Apply %d suggestions', this.batchSuggestionsCount); }, + helperText() { + if (this.batchSuggestionsCount <= 1) { + return __('This also resolves this thread'); + } + + return __('This also resolves all related threads'); + }, }, methods: { onApply() { this.$emit('apply', this.message); }, + focusCommitMessageInput() { + this.$refs.commitMessage.$el.focus(); + }, }, }; </script> <template> - <gl-dropdown - :text="dropdownText" - :disabled="disabled" - size="small" - boundary="window" - right - lazy - menu-class="gl-w-full!" + <gl-disclosure-dropdown data-qa-selector="apply_suggestion_dropdown" - @shown="$refs.commitMessage.$el.focus()" + fluid-width + placement="right" + size="small" + :disabled="disabled" + :toggle-text="dropdownText" + @shown="focusCommitMessageInput" > - <gl-dropdown-form class="gl-px-4! gl-m-0!"> + <gl-form class="gl-display-flex gl-flex-direction-column gl-px-4! gl-mx-0! gl-my-2!"> <label for="commit-message">{{ __('Commit message') }}</label> <gl-alert v-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-4"> {{ errorMessage }} </gl-alert> + <gl-form-textarea id="commit-message" ref="commitMessage" v-model="message" + class="apply-suggestions-input-min-width" :placeholder="defaultCommitMessage" submit-on-enter data-qa-selector="commit_message_field" @submit="onApply" /> + + <span class="gl-mt-2 gl-text-secondary"> + {{ helperText }} + </span> + <gl-button - class="gl-w-auto! gl-mt-3 gl-text-center! gl-transition-medium! float-right" + class="gl-w-auto! gl-mt-3 gl-align-self-end" category="primary" variant="confirm" data-qa-selector="commit_with_custom_message_button" @@ -83,6 +98,6 @@ export default { > {{ __('Apply') }} </gl-button> - </gl-dropdown-form> - </gl-dropdown> + </gl-form> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue index b1c6f5e6056..f7f5ccdbf31 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/comment_templates_dropdown.vue @@ -4,7 +4,11 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request'; import { InternalEvents } from '~/tracking'; import savedRepliesQuery from './saved_replies.query.graphql'; -import { TRACKING_SAVED_REPLIES_USE, TRACKING_SAVED_REPLIES_USE_IN_MR } from './constants'; +import { + TRACKING_SAVED_REPLIES_USE, + TRACKING_SAVED_REPLIES_USE_IN_MR, + TRACKING_SAVED_REPLIES_USE_IN_OTHER, +} from './constants'; export default { apollo: { @@ -61,9 +65,9 @@ export default { if (savedReply) { this.$emit('select', savedReply.content); this.track_event(TRACKING_SAVED_REPLIES_USE); - if (isInMr) { - this.track_event(TRACKING_SAVED_REPLIES_USE_IN_MR); - } + this.track_event( + isInMr ? TRACKING_SAVED_REPLIES_USE_IN_MR : TRACKING_SAVED_REPLIES_USE_IN_OTHER, + ); } }, }, diff --git a/app/assets/javascripts/vue_shared/components/markdown/constants.js b/app/assets/javascripts/vue_shared/components/markdown/constants.js index 47ef7cccbc2..7b31c4a59e3 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/constants.js +++ b/app/assets/javascripts/vue_shared/components/markdown/constants.js @@ -1,2 +1,3 @@ export const TRACKING_SAVED_REPLIES_USE = 'i_code_review_saved_replies_use'; export const TRACKING_SAVED_REPLIES_USE_IN_MR = 'i_code_review_saved_replies_use_in_mr'; +export const TRACKING_SAVED_REPLIES_USE_IN_OTHER = 'i_code_review_saved_replies_use_in_other'; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue index 84d40db07bb..c70197c6715 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue @@ -2,8 +2,26 @@ import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { + props: { + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + watch: { + isLoading() { + this.handleGFM(); + }, + }, mounted() { - renderGFM(this.$el); + this.handleGFM(); + }, + methods: { + handleGFM() { + if (this.isLoading) return; + renderGFM(this.$el); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index 493b329f1b1..fc7e0a7c732 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -119,9 +119,11 @@ export default { }, }, data() { + const editingMode = + localStorage.getItem(this.$options.EDITING_MODE_KEY) || EDITING_MODE_MARKDOWN_FIELD; return { markdown: this.value || (this.autosaveKey ? getDraft(this.autosaveKey) : '') || '', - editingMode: EDITING_MODE_MARKDOWN_FIELD, + editingMode, autofocused: false, }; }, diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js index 0b0867ae84c..6c2f084591e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js +++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js @@ -72,7 +72,7 @@ export function mountMarkdownEditor(options = {}) { quickActionsDocsPath, formFieldPlaceholder, formFieldClasses, - qaSelector, + testid, newIssuePath, } = el.dataset; @@ -115,7 +115,7 @@ export function mountMarkdownEditor(options = {}) { id: formFieldId, name: formFieldName, class: formFieldClasses, - 'data-qa-selector': qaSelector, + 'data-testid': testid, }, autosaveKey, enableAutocomplete, 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 855c7a449c4..8a0ca8ebac1 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 @@ -77,9 +77,7 @@ export default { return this.inapplicableReason; } - return this.batchSuggestionsCount > 1 - ? __('This also resolves all related threads') - : __('This also resolves this thread'); + return false; }, isDisableButton() { return this.isApplying || !this.canApply; 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 9179331cdec..0ec8b6e2a0a 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -6,6 +6,9 @@ const noteableTypeText = { Issue: __('issue'), Epic: __('epic'), MergeRequest: __('merge request'), + Task: __('task'), + KeyResult: __('key result'), + Objective: __('objective'), }; export default { diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js index df1188d365b..77fd197978f 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/constants.js @@ -1,5 +1,3 @@ -import { __ } from '~/locale'; - export const tdClass = 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; export const thClass = 'gl-hover-bg-blue-50'; @@ -15,7 +13,3 @@ export const initialPaginationState = { firstPageSize: defaultPageSize, lastPageSize: null, }; - -export const defaultI18n = { - searchPlaceholder: __('Search or filter results…'), -}; 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 ab9e6e092d9..0c3d175684c 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 @@ -14,11 +14,10 @@ import { } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; -import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; +import { initialPaginationState, defaultPageSize } from './constants'; import { isAny } from './utils'; export default { - defaultI18n, components: { GlAlert, GlBadge, @@ -300,7 +299,6 @@ export default { <div class="filtered-search-wrapper"> <filtered-search-bar :namespace="projectPath" - :search-input-placeholder="$options.defaultI18n.searchPlaceholder" :tokens="filteredSearchTokens" :initial-filter-value="filteredSearchValue" initial-sortby="created_desc" diff --git a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue index e1f042f78ab..76bedc0feeb 100644 --- a/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue +++ b/app/assets/javascripts/vue_shared/components/pagination/table_pagination.vue @@ -64,7 +64,7 @@ export default { <template> <gl-pagination v-if="showPagination" - class="gl-mt-3" + class="gl-mt-5" v-bind="$attrs" align="center" :value="pageInfo.page" diff --git a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue index c1246b2bf44..4f580d4a848 100644 --- a/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue +++ b/app/assets/javascripts/vue_shared/components/pagination_bar/pagination_bar.vue @@ -1,5 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { + GlButton, + GlDisclosureDropdown, + GlDisclosureDropdownItem, + GlIcon, + GlSprintf, +} from '@gitlab/ui'; import { __ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; @@ -9,8 +15,9 @@ const DEFAULT_PAGE_SIZES = [20, 50, 100]; export default { components: { PaginationLinks, - GlDropdown, - GlDropdownItem, + GlButton, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlIcon, GlSprintf, LocalStorageSync, @@ -80,25 +87,31 @@ export default { @input="setPageSize" /> <pagination-links :change="setPage" :page-info="pageInfo" class="gl-m-0" /> - <gl-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size"> - <template #button-content> - <span class="gl-font-weight-bold"> + <gl-disclosure-dropdown category="tertiary" class="gl-ml-auto" data-testid="page-size"> + <template #toggle> + <gl-button class="gl-font-weight-bold" category="tertiary"> <gl-sprintf :message="__('%{count} items per page')"> <template #count> {{ pageInfo.perPage }} </template> </gl-sprintf> - </span> - <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> + <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> + </gl-button> </template> - <gl-dropdown-item v-for="size in pageSizes" :key="size" @click="setPageSize(size)"> - <gl-sprintf :message="__('%{count} items per page')"> - <template #count> - {{ size }} - </template> - </gl-sprintf> - </gl-dropdown-item> - </gl-dropdown> + <gl-disclosure-dropdown-item + v-for="size in pageSizes" + :key="size" + @action="setPageSize(size)" + > + <template #list-item> + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ size }} + </template> + </gl-sprintf> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> <div class="gl-ml-2" data-testid="information"> <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')"> <template #start> diff --git a/app/assets/javascripts/vue_shared/components/projects_list/constants.js b/app/assets/javascripts/vue_shared/components/projects_list/constants.js deleted file mode 100644 index aa0b1418a06..00000000000 --- a/app/assets/javascripts/vue_shared/components/projects_list/constants.js +++ /dev/null @@ -1,2 +0,0 @@ -export const ACTION_EDIT = 'edit'; -export const ACTION_DELETE = 'delete'; diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue index 9fc4571b0dc..ce75e305473 100644 --- a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue @@ -7,7 +7,6 @@ import { GlTooltipDirective, GlPopover, GlSprintf, - GlDisclosureDropdown, } from '@gitlab/ui'; import uniqueId from 'lodash/uniqueId'; @@ -20,8 +19,9 @@ import { numberToMetricPrefix } from '~/lib/utils/number_utils'; import { truncate } from '~/lib/utils/text_utility'; import SafeHtml from '~/vue_shared/directives/safe_html'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import ListActions from '~/vue_shared/components/list_actions/list_actions.vue'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; import DeleteModal from '~/projects/components/shared/delete_modal.vue'; -import { ACTION_EDIT, ACTION_DELETE } from './constants'; const MAX_TOPICS_TO_SHOW = 3; const MAX_TOPIC_TITLE_LENGTH = 15; @@ -51,8 +51,8 @@ export default { GlPopover, GlSprintf, TimeAgoTooltip, - GlDisclosureDropdown, DeleteModal, + ListActions, }, directives: { GlTooltip: GlTooltipDirective, @@ -163,30 +163,21 @@ export default { return numberToMetricPrefix(this.project.openIssuesCount); }, - actionsDropdownItems() { - return [ - { - id: ACTION_EDIT, - text: __('Edit'), + actions() { + return { + [ACTION_EDIT]: { href: this.project.editPath, }, - { - id: ACTION_DELETE, - text: __('Delete'), - extraAttrs: { - class: 'gl-text-red-500!', - }, - action: () => { - this.isDeleteModalVisible = true; - }, + [ACTION_DELETE]: { + action: this.onActionDelete, }, - ].filter(({ id }) => this.project.actions?.includes(id)); + }; }, hasActions() { - return this.actionsDropdownItems.length; + return this.project.availableActions?.length; }, - hasDeleteAction() { - return this.actionsDropdownItems.find((action) => action.id === ACTION_DELETE); + hasActionDelete() { + return this.project.availableActions?.includes(ACTION_DELETE); }, }, methods: { @@ -204,6 +195,9 @@ export default { return null; }, + onActionDelete() { + this.isDeleteModalVisible = true; + }, }, }; </script> @@ -336,20 +330,15 @@ export default { </div> </div> </div> - <gl-disclosure-dropdown + <list-actions v-if="hasActions" class="gl-ml-3 gl-md-align-self-center" - :items="actionsDropdownItems" - icon="ellipsis_v" - no-caret - :toggle-text="$options.i18n.actions" - text-sr-only - placement="right" - category="tertiary" + :actions="actions" + :available-actions="project.availableActions" /> <delete-modal - v-if="hasDeleteAction" + v-if="hasActionDelete" v-model="isDeleteModalVisible" :confirm-phrase="project.name" :is-fork="project.isForked" diff --git a/app/assets/javascripts/vue_shared/components/source_editor.vue b/app/assets/javascripts/vue_shared/components/source_editor.vue index 7b7d3d48d9e..53c16fccba1 100644 --- a/app/assets/javascripts/vue_shared/components/source_editor.vue +++ b/app/assets/javascripts/vue_shared/components/source_editor.vue @@ -104,7 +104,7 @@ export default { :id="`source-editor-${fileGlobalId}`" ref="editor" data-editor-loading - data-qa-selector="source_editor_container" + data-testid="source-editor-container" @[$options.readyEvent]="$emit($options.readyEvent, $event)" > <pre class="editor-loading-content">{{ value }}</pre> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue index b89fa3f8292..8dac6327a99 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue @@ -82,6 +82,7 @@ export default { methods: { handleChunkAppear() { this.hasAppeared = true; + this.$emit('appear'); }, calculateLineNumber(index) { return this.startingFrom + index + 1; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index a4d50466f8f..797a38d8171 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -33,6 +33,7 @@ export default { components: { GlLoadingIcon, Chunk, + CodeownersValidation: () => import('ee_component/blob/components/codeowners_validation.vue'), }, mixins: [Tracking.mixin()], props: { @@ -40,6 +41,14 @@ export default { type: Object, required: true, }, + projectPath: { + type: String, + required: true, + }, + currentRef: { + type: String, + required: true, + }, }, data() { return { @@ -49,7 +58,6 @@ export default { firstChunk: null, chunks: {}, isLoading: true, - isLineSelected: false, lineHighlighter: null, }; }, @@ -66,7 +74,8 @@ export default { if (this.blob.name && this.blob.name.endsWith(`.${SVELTE_LANGUAGE}`)) { // override for svelte files until https://github.com/rouge-ruby/rouge/issues/1717 is resolved return SVELTE_LANGUAGE; - } else if (this.blob.name === this.$options.codeownersFileName) { + } + if (this.isCodeownersFile) { // override for codeowners files return this.$options.codeownersLanguage; } @@ -87,6 +96,9 @@ export default { totalChunks() { return Object.keys(this.chunks).length; }, + isCodeownersFile() { + return this.blob.name === CODEOWNERS_FILE_NAME; + }, }, async created() { if (this.isLfsBlob) { @@ -121,7 +133,7 @@ export default { this.generateRemainingChunks(); this.isLoading = false; await this.$nextTick(); - this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + this.selectLine(); }); }, methods: { @@ -227,18 +239,16 @@ export default { return languageDefinition; }, async selectLine() { - if (this.isLineSelected || !this.lineHighlighter) { - return; + if (!this.lineHighlighter) { + this.lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); } - - this.isLineSelected = true; await this.$nextTick(); - this.lineHighlighter.highlightHash(this.$route.hash); + const scrollEnabled = false; + this.lineHighlighter.highlightHash(this.$route.hash, scrollEnabled); }, }, userColorScheme: window.gon.user_color_scheme, currentlySelectedLine: null, - codeownersFileName: CODEOWNERS_FILE_NAME, codeownersLanguage: CODEOWNERS_LANGUAGE, }; </script> @@ -250,6 +260,13 @@ export default { :data-path="blob.path" data-qa-selector="blob_viewer_file_content" > + <codeowners-validation + v-if="isCodeownersFile" + class="gl-text-black-normal" + :current-ref="currentRef" + :project-path="projectPath" + :file-path="blob.path" + /> <chunk v-if="firstChunk" :lines="firstChunk.lines" @@ -263,20 +280,21 @@ export default { /> <gl-loading-icon v-if="isLoading" size="sm" class="gl-my-5" /> - <chunk - v-for="(chunk, key, index) in chunks" - v-else - :key="key" - :lines="chunk.lines" - :content="chunk.content" - :total-lines="chunk.totalLines" - :starting-from="chunk.startingFrom" - :is-highlighted="chunk.isHighlighted" - :chunk-index="index" - :language="chunk.language" - :blame-path="blob.blamePath" - :total-chunks="totalChunks" - @appear="highlightChunk" - /> + <template v-else> + <chunk + v-for="(chunk, key, index) in chunks" + :key="key" + :lines="chunk.lines" + :content="chunk.content" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :is-highlighted="chunk.isHighlighted" + :chunk-index="index" + :language="chunk.language" + :blame-path="blob.blamePath" + :total-chunks="totalChunks" + @appear="highlightChunk" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue index 0fb6e577f32..c7353ed6785 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue @@ -41,8 +41,14 @@ export default { addBlobLinksTracking(); }, mounted() { - const { hash } = this.$route; - this.lineHighlighter.highlightHash(hash); + this.selectLine(); + }, + methods: { + async selectLine() { + await this.$nextTick(); + const scrollEnabled = false; + this.lineHighlighter.highlightHash(this.$route.hash, scrollEnabled); + }, }, userColorScheme: window.gon.user_color_scheme, }; @@ -66,6 +72,7 @@ export default { :total-lines="chunk.totalLines" :starting-from="chunk.startingFrom" :blame-path="blob.blamePath" + @appear="selectLine" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue deleted file mode 100644 index c0aef42b0f2..00000000000 --- a/app/assets/javascripts/vue_shared/components/split_button.vue +++ /dev/null @@ -1,85 +0,0 @@ -<script> -import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui'; -import { isString } from 'lodash'; - -const isValidItem = (item) => - isString(item.eventName) && isString(item.title) && isString(item.description); - -export default { - components: { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, - }, - - props: { - actionItems: { - type: Array, - required: true, - validator(value) { - return value.length > 1 && value.every(isValidItem); - }, - }, - menuClass: { - type: String, - required: false, - default: '', - }, - variant: { - type: String, - required: false, - default: 'default', - }, - }, - - data() { - return { - selectedItem: this.actionItems[0], - }; - }, - - computed: { - dropdownToggleText() { - return this.selectedItem.title; - }, - }, - - methods: { - triggerEvent() { - this.$emit(this.selectedItem.eventName); - }, - changeSelectedItem(item) { - this.selectedItem = item; - this.$emit('change', item); - }, - }, -}; -</script> - -<template> - <gl-dropdown - :menu-class="menuClass" - split - :text="dropdownToggleText" - :variant="variant" - v-bind="$attrs" - @click="triggerEvent" - > - <template v-for="(item, itemIndex) in actionItems"> - <gl-dropdown-item - :key="item.eventName" - is-check-item - :is-checked="selectedItem === item" - @click="changeSelectedItem(item)" - > - <strong>{{ item.title }}</strong> - <div>{{ item.description }}</div> - </gl-dropdown-item> - - <gl-dropdown-divider - v-if="itemIndex < actionItems.length - 1" - :key="`${item.eventName}-divider`" - /> - </template> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue index bda88a48e48..9ba5e8724f9 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue @@ -70,7 +70,8 @@ export default { selectTarget() { if (isFunction(this.truncateTarget)) { return this.truncateTarget(this.$el); - } else if (this.truncateTarget === 'child') { + } + if (this.truncateTarget === 'child') { return this.$el.childNodes[0]; } return this.$el; 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 446c8c97df0..30f616dd8e1 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 @@ -83,9 +83,11 @@ export default { if (this.user.status.emoji && this.user.status.message_html) { return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`; - } else if (this.user.status.message_html) { + } + if (this.user.status.message_html) { return this.user.status.message_html; - } else if (this.user.status.emoji) { + } + if (this.user.status.emoji) { return glEmojiTag(this.user.status.emoji); } diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index 4879baced0d..863c43b0e55 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -13,7 +13,7 @@ import { __ } from '~/locale'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/issues/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { participantsQueries, userSearchQueries } from '~/sidebar/constants'; +import { participantsQueries, userSearchQueries } from '~/sidebar/queries/constants'; import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -130,11 +130,11 @@ export default { }, update(data) { return ( - data.workspace?.users?.nodes - .filter((x) => x?.user) - .map((node) => ({ - ...node.user, - canMerge: node.mergeRequestInteraction?.canMerge || false, + data.workspace?.users + .filter((user) => user) + .map((user) => ({ + ...user, + canMerge: user.mergeRequestInteraction?.canMerge || false, })) || [] ); }, 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 79d14b5f2d0..beb8321a271 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -195,9 +195,11 @@ export default { webIdeActionText() { if (this.webIdeText) { return this.webIdeText; - } else if (this.isBlob) { + } + if (this.isBlob) { return __('Open in Web IDE'); - } else if (this.isFork) { + } + if (this.isFork) { return __('Edit fork in Web IDE'); } diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index d9bc2c82688..9c001fa2e9a 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -86,7 +86,7 @@ export const confidentialityInfoText = (workspaceType, issuableType) => ), { workspaceType: workspaceType === WORKSPACE_PROJECT ? __('project') : __('group'), - issuableType: issuableType.toLowerCase(), + issuableType: issuableType.toLowerCase().replaceAll('_', ' '), permissions: issuableType === TYPE_ISSUE ? __('at least the Reporter role, the author, and assignees') diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue index 699b41f3bf3..1cfa3f6d3d7 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue @@ -36,7 +36,7 @@ export default { ariaLabel: __('Description'), class: 'rspec-issuable-form-description', placeholder: __('Write a comment or drag your files here…'), - dataQaSelector: 'issuable_form_description_field', + dataTestid: 'issuable-form-description-field', id: 'issuable-description', name: 'issuable-description', }, diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 31dd49ca415..690d9523a63 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -8,6 +8,7 @@ import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { STATE_CLOSED } from '~/work_items/constants'; import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils'; @@ -24,6 +25,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + SafeHtml, }, mixins: [timeagoMixin], props: { @@ -80,14 +82,20 @@ export default { author() { return this.issuable.author || {}; }, + externalAuthor() { + return this.issuable.externalAuthor; + }, webUrl() { return this.issuable.gitlabWebUrl || this.issuable.webUrl; }, authorId() { return getIdFromGraphQLId(this.author.id); }, + isIssueTrackerExternal() { + return Boolean(this.issuable.externalTracker); + }, isIssuableUrlExternal() { - return isExternal(this.webUrl); + return isExternal(this.webUrl ?? ''); }, reference() { return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`; @@ -130,7 +138,8 @@ export default { return sprintf(__('closed %{timeago}'), { timeago: this.timeFormatted(this.issuable.closedAt), }); - } else if (this.issuable.updatedAt !== this.issuable.createdAt) { + } + if (this.issuable.updatedAt !== this.issuable.createdAt) { return sprintf(__('updated %{timeAgo}'), { timeAgo: this.timeFormatted(this.issuable.updatedAt), }); @@ -242,6 +251,7 @@ export default { <div data-testid="issuable-title" class="issue-title title"> <work-item-type-icon v-if="showWorkItemTypeIcon" + class="gl-mr-2" :work-item-type="type" show-tooltip-on-hover /> @@ -259,18 +269,33 @@ export default { :title="__('This issue is hidden because its author has been banned')" :aria-label="__('Hidden')" /> - <gl-link - class="issue-title-text" - dir="auto" - :href="webUrl" - data-qa-selector="issuable_title_link" - data-testid="issuable-title-link" - v-bind="issuableTitleProps" - @click="handleIssuableItemClick" - > - {{ issuable.title }} + <template v-if="isIssueTrackerExternal"> + <gl-link + class="issue-title-text" + dir="auto" + :href="webUrl" + data-qa-selector="issuable_title_link" + data-testid="issuable-title-link" + v-bind="issuableTitleProps" + @click="handleIssuableItemClick" + > + {{ issuable.title }} + <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> + </gl-link> + </template> + <template v-else> + <gl-link + v-safe-html="issuable.titleHtml || issuable.title" + class="issue-title-text" + dir="auto" + :href="webUrl" + data-qa-selector="issuable_title_link" + data-testid="issuable-title-link" + v-bind="issuableTitleProps" + @click="handleIssuableItemClick" + /> <gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" /> - </gl-link> + </template> <span v-if="taskStatus" class="task-status gl-display-none gl-sm-display-inline-block! gl-ml-2 gl-font-sm" @@ -298,6 +323,9 @@ export default { </span> </template> <template #author> + <span v-if="externalAuthor" data-testid="external-author" + >{{ externalAuthor }} {{ __('via') }}</span + > <slot v-if="hasSlotContents('author')" name="author"></slot> <gl-link v-else @@ -344,7 +372,7 @@ export default { </div> <div class="issuable-meta"> <ul v-if="showIssuableMeta" class="controls"> - <li v-if="hasSlotContents('status')" class="issuable-status"> + <li v-if="hasSlotContents('status')"> <slot name="status"></slot> </li> <li v-if="assignees.length"> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index 7a9404e06c7..0db7417cebc 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -5,6 +5,7 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -51,7 +52,8 @@ export default { }, searchInputPlaceholder: { type: String, - required: true, + required: false, + default: __('Search or filter results…'), }, searchTokens: { type: Array, @@ -344,7 +346,7 @@ export default { :show-friendly-text="showFilteredSearchFriendlyText" terms-as-tokens class="gl-flex-grow-1 gl-border-t-none row-content-block" - data-qa-selector="issuable_search_container" + data-testid="issuable-search-container" @checked-input="handleAllIssuablesCheckedInput" @onFilter="$emit('filter', $event)" @onSort="$emit('sort', $event)" @@ -377,7 +379,7 @@ export default { v-for="issuable in issuables" :key="issuableId(issuable)" :class="{ 'gl-cursor-grab': isManualOrdering }" - data-qa-selector="issuable_container" + data-testid="issuable-container" :data-qa-issuable-title="issuable.title" :has-scoped-labels-feature="hasScopedLabelsFeature" :issuable-symbol="issuableSymbol" diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue index 0691bc02b5c..ab71842ae13 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_tabs.vue @@ -56,7 +56,7 @@ export default { @click="$emit('click', tab.name)" > <template #title> - <span :title="tab.titleTooltip" :data-qa-selector="`${tab.name}_issuables_tab`"> + <span :title="tab.titleTooltip" :data-testid="`${tab.name}-issuables-tab`"> {{ tab.title }} </span> <gl-badge diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue index ce1851ab873..01389cd90a9 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue @@ -34,7 +34,7 @@ export default { <div class="description" :class="{ 'js-task-list-container': canEdit && enableTaskList }" - data-qa-selector="description_content" + data-testid="description-content" > <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div> <textarea diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index 29aef89a991..c4b92454ac0 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -162,8 +162,8 @@ export default { <template> <div class="detail-page-header gl-flex-direction-column gl-sm-flex-direction-row"> - <div class="detail-page-header-body gl-flex-wrap"> - <gl-badge class="gl-mr-2" :variant="badgeVariant"> + <div class="detail-page-header-body gl-flex-wrap gl-gap-2"> + <gl-badge :variant="badgeVariant" data-testid="issue-state-badge"> <gl-icon v-if="statusIcon" :name="statusIcon" :class="statusIconClass" /> <span class="gl-display-none gl-sm-display-block" :class="{ 'gl-ml-2': statusIcon }"> <slot name="status-badge">{{ badgeText }}</slot> @@ -193,18 +193,18 @@ export default { <work-item-type-icon v-if="shouldShowWorkItemTypeIcon" show-text - :work-item-type="issuableType.toUpperCase()" + :work-item-type="issuableType" /> <gl-sprintf :message="createdMessage"> <template #timeAgo> - <time-ago-tooltip class="gl-mx-2" :time="createdAt" /> + <time-ago-tooltip :time="createdAt" /> </template> <template #email> {{ serviceDeskReplyTo }} </template> <template #author> <gl-link - class="gl-font-weight-bold gl-mx-2 js-user-link" + class="gl-font-weight-bold js-user-link" :href="author.webUrl" :data-user-id="authorId" > @@ -225,7 +225,6 @@ export default { <gl-icon v-if="isFirstContribution" v-gl-tooltip - class="gl-mr-2" name="first-contribution" :title="__('1st contribution!')" :aria-label="__('1st contribution!')" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue index 2bc57ecba55..3878c16c8d0 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -92,6 +92,11 @@ export default { required: false, default: false, }, + workspaceType: { + type: String, + required: false, + default: '', + }, }, methods: { handleKeydownTitle(e, issuableMeta) { @@ -105,7 +110,7 @@ export default { </script> <template> - <div class="issuable-show-container" data-qa-selector="issuable_show_container"> + <div class="issuable-show-container" data-testid="issuable-show-container"> <issuable-header :issuable-state="issuable.state" :status-icon="statusIcon" @@ -116,6 +121,7 @@ export default { :author="issuable.author" :task-completion-status="taskCompletionStatus" :issuable-type="issuable.type" + :workspace-type="workspaceType" :show-work-item-type-icon="showWorkItemTypeIcon" > <template #status-badge> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index 841d92fd63d..da71adc8abd 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -60,8 +60,7 @@ export default { v-safe-html="issuable.titleHtml || issuable.title" class="title gl-font-size-h-display" dir="auto" - data-qa-selector="title_content" - data-testid="title" + data-testid="issuable-title" ></h1> <gl-button v-if="enableEdit" diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 4503ba6e561..f54c4c52743 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -88,6 +88,10 @@ export default { showSuperSidebarToggle() { return gon.use_new_navigation && sidebarState.isCollapsed; }, + + topBarClasses() { + return gon.use_new_navigation ? 'top-bar-fixed container-fluid' : ''; + }, }, created() { @@ -120,15 +124,17 @@ export default { <template> <div> - <div - class="top-bar-container gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid" - > - <super-sidebar-toggle - v-if="showSuperSidebarToggle" - class="gl-mr-2" - :class="$options.JS_TOGGLE_EXPAND_CLASS" - /> - <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" /> + <div :class="topBarClasses" data-testid="top-bar"> + <div + class="top-bar-container gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid" + > + <super-sidebar-toggle + v-if="showSuperSidebarToggle" + class="gl-mr-2" + :class="$options.JS_TOGGLE_EXPAND_CLASS" + /> + <gl-breadcrumb :items="breadcrumbs" data-testid="breadcrumb-links" /> + </div> </div> <template v-if="activePanel"> diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue index 28618cb96a3..61bca18b050 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue @@ -1,15 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; export default { name: 'SecurityReportDownloadDropdown', components: { - GlDropdown, - GlDropdownItem, - }, - directives: { - GlTooltip, + GlDisclosureDropdown, }, props: { artifacts: { @@ -26,19 +22,23 @@ export default { required: false, default: '', }, - title: { - type: String, - required: false, - default: '', - }, }, computed: { showDropdown() { return this.loading || this.artifacts.length > 0; }, + items() { + return this.artifacts.map(({ name, path }) => ({ + text: this.artifactText(name), + href: path, + extraAttrs: { + download: '', + }, + })); + }, }, methods: { - artifactText({ name }) { + artifactText(name) { return sprintf(s__('SecurityReports|Download %{artifactName}'), { artifactName: name, }); @@ -48,23 +48,13 @@ export default { </script> <template> - <gl-dropdown + <gl-disclosure-dropdown v-if="showDropdown" - v-gl-tooltip - :text="text" - :title="title" + :items="items" + :toggle-text="text" :loading="loading" icon="download" size="small" - right - > - <gl-dropdown-item - v-for="artifact in artifacts" - :key="artifact.path" - :href="artifact.path" - download - > - {{ artifactText(artifact) }} - </gl-dropdown-item> - </gl-dropdown> + placement="right" + /> </template> |