diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-14 11:41:52 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-11-14 11:41:52 +0300 |
commit | 585826cb22ecea5998a2c2a4675735c94bdeedac (patch) | |
tree | 5b05f0b30d33cef48963609e8a18a4dff260eab3 /app/assets/javascripts/vue_shared | |
parent | df221d036e5d0c6c0ee4d55b9c97f481ee05dee8 (diff) |
Add latest changes from gitlab-org/gitlab@16-6-stable-eev16.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared')
42 files changed, 1250 insertions, 417 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index 6803d609dbc..e84b3f53b53 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -10,7 +10,7 @@ import { GlTab, GlButton, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import { fetchPolicies } from '~/lib/graphql'; @@ -30,7 +30,7 @@ import AlertSidebar from './alert_sidebar.vue'; import AlertSummaryRow from './alert_summary_row.vue'; import SystemNote from './system_notes/system_note.vue'; -const containerEl = document.querySelector('.page-with-contextual-sidebar'); +const containerEl = document.querySelector('.layout-page'); export default { i18n: { diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index 2d3815439a6..056388f690d 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -284,13 +284,7 @@ export default { > <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users"> <span class="gl-relative gl-mr-4"> - <img - :alt="userName" - :src="userImg" - :width="32" - class="avatar avatar-inline gl-m-0 s32" - data-qa-selector="avatar_image" - /> + <img :alt="userName" :src="userImg" :width="32" class="avatar avatar-inline gl-m-0 s32" /> </span> <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden"> <strong class="dropdown-menu-user-full-name"> diff --git a/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue index ffbcdefc924..93e1fc4a0c2 100644 --- a/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue +++ b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue @@ -1,6 +1,6 @@ <script> -import * as Sentry from '@sentry/browser'; import { GlFormInput } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { DurationParseError, outputChronicDuration, diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue deleted file mode 100644 index abbeac0e098..00000000000 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ /dev/null @@ -1,157 +0,0 @@ -<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. - * - * Receives status object containing: - * status: { - * details_path or detailsPath: "/gitlab-org/gitlab-foss/pipelines/8150156" // url - * group:"running" // used for CSS class - * icon: "icon_status_running" // used to render the icon - * label:"running" // used for potential tooltip - * text:"running" // text rendered - * } - * - * Used in: - * - Pipelines table - first column - * - Jobs table - first column - * - Pipeline show view - header - * - Job show view - header - * - MR widget - * - Terraform table - * - On-demand scans list - */ - -const badgeSizeOptions = { - sm: 'sm', - md: 'md', - lg: 'lg', -}; - -export default { - components: { - CiIcon, - GlBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - status: { - type: Object, - required: true, - }, - showText: { - type: Boolean, - required: false, - default: true, - }, - size: { - type: String, - required: false, - default: badgeSizeOptions.md, - validator(value) { - return badgeSizeOptions[value] !== undefined; - }, - }, - showTooltip: { - type: Boolean, - required: false, - default: true, - }, - useLink: { - type: Boolean, - default: true, - required: false, - }, - }, - computed: { - isNotLargeBadgeSize() { - return this.size !== badgeSizeOptions.lg; - }, - title() { - return this.showTooltip && !this.showText ? this.status?.text : ''; - }, - detailsPath() { - // For now, this can either come from graphQL with camelCase or REST API in snake_case - if (!this.useLink) { - return null; - } - return this.status.detailsPath || this.status.details_path; - }, - badgeStyles() { - switch (this.status.icon) { - case 'status_success': - return { - textColor: 'gl-text-green-700', - variant: 'success', - }; - case 'status_warning': - return { - textColor: 'gl-text-orange-700', - variant: 'warning', - }; - case 'status_failed': - return { - textColor: 'gl-text-red-700', - variant: 'danger', - }; - case 'status_running': - return { - textColor: 'gl-text-blue-700', - variant: 'info', - }; - case 'status_pending': - return { - textColor: 'gl-text-orange-700', - variant: 'warning', - }; - case 'status_canceled': - return { - textColor: 'gl-text-gray-700', - variant: 'neutral', - }; - case 'status_manual': - return { - textColor: 'gl-text-gray-700', - variant: 'neutral', - }; - // default covers the styles for the remainder of CI - // statuses that are not explicitly stated here - default: - return { - textColor: 'gl-text-gray-600', - variant: 'muted', - }; - } - }, - }, -}; -</script> -<template> - <gl-badge - v-gl-tooltip - :class="{ 'gl-px-2': !showText && isNotLargeBadgeSize }" - :title="title" - :href="detailsPath" - :size="size" - :variant="badgeStyles.variant" - data-testid="ci-badge-link" - @click="$emit('ciStatusBadgeClick')" - > - <ci-icon :status="status" /> - - <template v-if="showText"> - <span - class="gl-ml-2 gl-white-space-nowrap" - :class="badgeStyles.textColor" - data-testid="ci-badge-text" - > - {{ status.text }} - </span> - </template> - </gl-badge> -</template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 6670b931416..a2b6b4642c9 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -1,99 +1,115 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlBadge, GlTooltipDirective, GlIcon } from '@gitlab/ui'; /** * Renders CI icon based on API response shared between all places where it is used. * * Receives status object containing: * status: { - * group:"running" // used for CSS class - * icon: "icon_status_running" // used to render the icon + * icon: "status_running" // used to render the icon and CSS class + * text: "Running", + * detailsPath: '/project1/jobs/1' // can also be details_path * } * - * Used in: - * - Extended MR Popover - * - Jobs show view header - * - Jobs show view sidebar - * - Jobs table - * - Linked pipelines - * - Pipeline graph - * - Pipeline mini graph - * - Pipeline show view badge - * - Pipelines table Badge */ -/* - * These sizes are defined in gitlab-ui/src/scss/variables.scss - * under '$gl-icon-sizes' - */ -const validSizes = [8, 12, 14, 16, 24, 32, 48, 72]; - export default { components: { + GlBadge, GlIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { status: { type: Object, required: true, validator(status) { - const { group, icon } = status; - return ( - typeof group === 'string' && - group.length && - typeof icon === 'string' && - icon.startsWith('status_') - ); + const { icon } = status; + return typeof icon === 'string' && icon.startsWith('status_'); }, }, - size: { - type: Number, - required: false, - default: 16, - validator(value) { - return validSizes.includes(value); - }, - }, - isActive: { + showStatusText: { type: Boolean, required: false, default: false, }, - isBorderless: { + showTooltip: { type: Boolean, required: false, - default: false, + default: true, }, - isInteractive: { + useLink: { type: Boolean, + default: true, required: false, - default: false, - }, - cssClasses: { - type: String, - required: false, - default: '', }, }, computed: { - wrapperStyleClasses() { - const status = this.status.group; - return `ci-status-icon ci-status-icon-${status} gl-rounded-full gl-justify-content-center gl-line-height-0`; + title() { + if (this.showTooltip) { + // show tooltip only when not showing text already + return !this.showStatusText ? this.status?.text : null; + } + return null; + }, + ariaLabel() { + // show aria-label only when text is not rendered + if (!this.showStatusText) { + return this.status?.text; + } + return null; + }, + href() { + // href can come from GraphQL (camelCase) or REST API (snake_case) + if (this.useLink) { + return this.status.detailsPath || this.status.details_path; + } + return null; }, icon() { - return this.isBorderless ? `${this.status.icon}_borderless` : this.status.icon; + if (this.status.icon) { + return `${this.status.icon}_borderless`; + } + return null; + }, + variant() { + switch (this.status.icon) { + case 'status_success': + return 'success'; + case 'status_warning': + case 'status_pending': + return 'warning'; + case 'status_failed': + return 'danger'; + case 'status_running': + return 'info'; + // default covers the styles for the remainder of CI + // statuses that are not explicitly stated here + default: + return 'neutral'; + } }, }, }; </script> <template> - <span - :class="[ - wrapperStyleClasses, - { interactive: isInteractive, active: isActive, borderless: isBorderless }, - ]" - :style="{ height: `${size}px`, width: `${size}px` }" + <gl-badge + v-gl-tooltip + class="ci-icon gl-p-2" + :class="`ci-icon-variant-${variant}`" + :variant="variant" + :title="title" + :aria-label="ariaLabel" + :href="href" + size="md" + data-testid="ci-icon" + @click="$emit('ciStatusBadgeClick')" > - <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" /> - </span> + <span class="ci-icon-gl-icon-wrapper"><gl-icon :name="icon" /></span + ><span v-if="showStatusText" class="gl-mx-2 gl-white-space-nowrap" data-testid="ci-icon-text">{{ + status.text + }}</span> + </gl-badge> </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 index f62bfb551df..55767c5f4bc 100644 --- a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue @@ -1,11 +1,5 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlSprintf, -} from '@gitlab/ui'; +import { GlDisclosureDropdown, GlIcon, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { __, n__, s__, sprintf } from '~/locale'; @@ -16,12 +10,16 @@ export const i18n = { searchFiles: __('Search files'), }; +const variantCssColorMap = { + success: 'gl-text-green-500', + danger: 'gl-text-red-500', +}; + export default { i18n, components: { - GlDropdown, - GlDropdownItem, - GlDropdownText, + GlDisclosureDropdown, + GlIcon, GlSearchBoxByType, GlSprintf, }, @@ -54,6 +52,15 @@ export default { ? fuzzaldrinPlus.filter(this.files, this.search, { key: 'name' }) : this.files; }, + dropdownItems() { + return this.filteredFiles.map((file) => { + return { + ...file, + text: file.name || this.$options.i18n.noFileNameAvailable, + iconColor: variantCssColorMap[file.iconColor], + }; + }); + }, messageChanged() { return sprintf( n__( @@ -64,21 +71,21 @@ export default { { 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(); }, + focusFirstItem() { + if (!this.filteredFiles.length) return; + this.$el.querySelector('.gl-new-dropdown-item:first-child').focus(); + }, + additionsText(numberOfChanges = this.added) { + return n__('Diffs|%d addition', 'Diffs|%d additions', numberOfChanges); + }, + deletionsText(numberOfChanges = this.deleted) { + return n__('Diffs|%d deletion', 'Diffs|%d deletions', numberOfChanges); + }, }, }; </script> @@ -87,15 +94,15 @@ export default { <div> <gl-sprintf :message="messageChanged"> <template #dropdown="{ content: dropdownText }"> - <gl-dropdown + <gl-disclosure-dropdown + :toggle-text="dropdownText" + :items="dropdownItems" 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 + fluid-width @shown="focusInput" > <template #header> @@ -103,35 +110,38 @@ export default { ref="search" v-model.trim="search" :placeholder="$options.i18n.searchFiles" + class="gl-mx-3 gl-my-4" + @keydown.down="focusFirstItem" /> + <span v-if="!filteredFiles.length" class="gl-mx-3"> + {{ $options.i18n.noFilesFound }} + </span> </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> + <template #list-item="{ item }"> + <div class="gl-display-flex gl-gap-3 gl-align-items-center gl-overflow-hidden"> + <gl-icon :name="item.icon" :class="item.iconColor" class="gl-flex-shrink-0" /> + <div class="gl-flex-grow-1 gl-overflow-hidden"> + <div class="gl-display-flex"> + <span + class="gl-font-weight-bold gl-mr-3 gl-flex-grow-1" + :class="item.name ? 'gl-text-truncate' : 'gl-font-style-italic gl-gray-400'" + >{{ item.text }}</span + > + <span class="gl-ml-auto gl-white-space-nowrap" aria-hidden="true"> + <span class="gl-text-green-600">+{{ item.added }}</span> + <span class="gl-text-red-500">-{{ item.removed }}</span> + </span> + <span class="gl-sr-only" + >{{ additionsText(item.added) }}, {{ deletionsText(item.removed) }}</span + > + </div> + <div class="gl-text-gray-700 gl-overflow-hidden gl-text-overflow-ellipsis"> + {{ item.path }} + </div> + </div> </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-disclosure-dropdown> </template> </gl-sprintf> <span @@ -140,12 +150,20 @@ export default { > <gl-sprintf :message="$options.i18n.messageAdditionsDeletions"> <template #additions> - <span class="gl-text-green-600 gl-font-weight-bold">{{ additionsText }}</span> + <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> + <span class="gl-text-red-500 gl-font-weight-bold">{{ deletionsText() }}</span> </template> </gl-sprintf> </span> </div> </template> + +<style scoped> +/* TODO: Use max-height prop when gitlab-ui got updated. +See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2374 */ +::v-deep .gl-new-dropdown-inner { + max-height: 310px; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/constants.js b/app/assets/javascripts/vue_shared/components/entity_select/constants.js index 0fb5a2d5534..5bad907c9f9 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/constants.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/constants.js @@ -14,3 +14,13 @@ export const PROJECT_TOGGLE_TEXT = s__('ProjectSelect|Search for project'); export const PROJECT_HEADER_TEXT = s__('ProjectSelect|Select a project'); export const FETCH_PROJECTS_ERROR = __('Unable to fetch projects. Reload the page to try again.'); export const FETCH_PROJECT_ERROR = __('Unable to fetch project. Reload the page to try again.'); + +// Organizations +export const ORGANIZATION_TOGGLE_TEXT = s__('Organization|Search for an organization'); +export const ORGANIZATION_HEADER_TEXT = s__('Organization|Select an organization'); +export const FETCH_ORGANIZATIONS_ERROR = s__( + 'Organization|Unable to fetch organizations. Reload the page to try again.', +); +export const FETCH_ORGANIZATION_ERROR = s__( + 'Organization|Unable to fetch organizations. Reload the page to try again.', +); diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue index 970c24c6e87..1a215454ab6 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue @@ -22,6 +22,11 @@ export default { type: String, required: true, }, + description: { + type: String, + required: false, + default: '', + }, inputName: { type: String, required: true, @@ -31,7 +36,7 @@ export default { required: true, }, initialSelection: { - type: String, + type: [String, Number], required: false, default: null, }, @@ -57,6 +62,11 @@ export default { required: false, default: null, }, + toggleClass: { + type: [String, Array, Object], + required: false, + default: '', + }, }, data() { return { @@ -152,6 +162,7 @@ export default { this.searching = true; const name = await this.fetchInitialSelectionText(this.initialSelection); + this.selectedValue = this.initialSelection; this.selectedText = name; this.pristine = false; @@ -178,7 +189,7 @@ export default { </script> <template> - <gl-form-group :label="label"> + <gl-form-group :label="label" :description="description"> <slot name="error"></slot> <template v-if="Boolean($scopedSlots.label)" #label> <slot name="label"></slot> @@ -196,6 +207,7 @@ export default { :no-results-text="noResultsText" :infinite-scroll="hasMoreItems" :infinite-scroll-loading="infiniteScrollLoading" + :toggle-class="toggleClass" searchable @shown="onShown" @search="search" 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 eb7b20fa4c1..8a338551fbe 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 @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import Api, { DEFAULT_PER_PAGE } from '~/api'; diff --git a/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue new file mode 100644 index 00000000000..d068d86d95b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue @@ -0,0 +1,150 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import getCurrentUserOrganizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; +import getOrganizationQuery from '~/organizations/shared/graphql/queries/organization.query.graphql'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_ORGANIZATION } from '~/graphql_shared/constants'; +import { + ORGANIZATION_TOGGLE_TEXT, + ORGANIZATION_HEADER_TEXT, + FETCH_ORGANIZATIONS_ERROR, + FETCH_ORGANIZATION_ERROR, +} from './constants'; +import EntitySelect from './entity_select.vue'; + +export default { + name: 'OrganizationSelect', + components: { + GlAlert, + EntitySelect, + }, + props: { + block: { + type: Boolean, + required: false, + default: false, + }, + label: { + type: String, + required: false, + default: '', + }, + description: { + type: String, + required: false, + default: '', + }, + inputName: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + initialSelection: { + type: [String, Number], + required: false, + default: null, + }, + clearable: { + type: Boolean, + required: false, + default: false, + }, + toggleClass: { + type: [String, Array, Object], + required: false, + default: '', + }, + }, + data() { + return { + errorMessage: '', + }; + }, + methods: { + async fetchOrganizations() { + try { + const { + data: { + currentUser: { + organizations: { nodes }, + }, + }, + } = await this.$apollo.query({ + query: getCurrentUserOrganizationsQuery, + // TODO: implement search support - https://gitlab.com/gitlab-org/gitlab/-/issues/429999. + }); + + return { + items: nodes.map((organization) => ({ + text: organization.name, + value: getIdFromGraphQLId(organization.id), + })), + // TODO: implement pagination - https://gitlab.com/gitlab-org/gitlab/-/issues/429999. + totalPages: 1, + }; + } catch (error) { + this.handleError({ message: FETCH_ORGANIZATIONS_ERROR, error }); + + return { items: [], totalPages: 0 }; + } + }, + async fetchOrganizationName(id) { + try { + const { + data: { + organization: { name }, + }, + } = await this.$apollo.query({ + query: getOrganizationQuery, + variables: { id: convertToGraphQLId(TYPENAME_ORGANIZATION, id) }, + }); + + return name; + } catch (error) { + this.handleError({ message: FETCH_ORGANIZATION_ERROR, error }); + + return ''; + } + }, + handleError({ message, error }) { + Sentry.captureException(error); + this.errorMessage = message; + }, + dismissError() { + this.errorMessage = ''; + }, + }, + i18n: { + toggleText: ORGANIZATION_TOGGLE_TEXT, + selectGroup: ORGANIZATION_HEADER_TEXT, + }, +}; +</script> + +<template> + <entity-select + :block="block" + :label="label" + :description="description" + :input-name="inputName" + :input-id="inputId" + :initial-selection="initialSelection" + :clearable="clearable" + :header-text="$options.i18n.selectGroup" + :default-toggle-text="$options.i18n.toggleText" + :fetch-items="fetchOrganizations" + :fetch-initial-selection-text="fetchOrganizationName" + :toggle-class="toggleClass" + v-on="$listeners" + > + <template #error> + <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ + errorMessage + }}</gl-alert> + </template> + </entity-select> +</template> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue index 13a825a68f6..8c371e3d4ce 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Api from '~/api'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { 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 346384e3023..d39e4d2ee42 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 @@ -292,7 +292,9 @@ export default { this.recentSearchesService.save(resultantSearches); this.recentSearches = []; }, - handleFilterSubmit() { + async handleFilterSubmit() { + this.blurSearchInput(); + await this.$nextTick(); const filterTokens = uniqueTokens(this.filterValue); this.filterValue = filterTokens; @@ -309,7 +311,6 @@ export default { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); } - this.blurSearchInput(); this.$emit('onFilter', this.removeQuotesEnclosure(filterTokens)); }, historyTokenOptionTitle(historyToken) { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index 23de8dd5596..3857dd9c55d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -7,9 +7,10 @@ import { GlDropdownText, GlLoadingIcon, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, last } from 'lodash'; import { stripQuotes } from '~/lib/utils/text_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; @@ -22,6 +23,7 @@ export default { GlDropdownText, GlLoadingIcon, }, + mixins: [glFeatureFlagMixin()], props: { config: { type: Object, @@ -70,6 +72,11 @@ export default { required: false, default: undefined, }, + multiSelectValues: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -94,7 +101,11 @@ export default { return this.preloadedSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); }, activeTokenValue() { - return this.getActiveTokenValue(this.suggestions, this.value.data); + const data = + this.glFeatures.groupMultiSelectTokens && Array.isArray(this.value.data) + ? last(this.value.data) + : this.value.data; + return this.getActiveTokenValue(this.suggestions, data); }, availableDefaultSuggestions() { if ([OPERATOR_NOT, OPERATOR_OR].includes(this.value.operator)) { @@ -146,10 +157,14 @@ export default { watch: { active: { immediate: true, - handler(newValue) { - if (!newValue && !this.suggestions.length) { - const search = this.searchTerm ? this.searchTerm : this.value.data; - this.$emit('fetch-suggestions', search); + handler(active) { + if (!active && !this.suggestions.length) { + // data could be a string or an array of strings + const selectedItems = [this.value.data].flat(); + selectedItems.forEach((item) => { + const search = this.searchTerm ? this.searchTerm : item; + this.$emit('fetch-suggestions', search); + }); } }, }, @@ -163,6 +178,9 @@ export default { }, methods: { handleInput: debounce(function debouncedSearch({ data, operator }) { + // in multiSelect mode, data could be an array + if (Array.isArray(data)) return; + // Prevent fetching suggestions when data or operator is not present if (data || operator) { this.searchKey = data; @@ -181,8 +199,11 @@ export default { } }, DEBOUNCE_DELAY), handleTokenValueSelected(selectedValue) { - const activeTokenValue = this.getActiveTokenValue(this.suggestions, selectedValue); + if (this.glFeatures.groupMultiSelectTokens) { + this.$emit('token-selected', selectedValue); + } + const activeTokenValue = this.getActiveTokenValue(this.suggestions, selectedValue); // Make sure that; // 1. Recently used values feature is enabled // 2. User has actually selected a value @@ -210,6 +231,7 @@ export default { :config="config" :value="value" :active="active" + :multi-select-values="multiSelectValues" v-bind="$attrs" v-on="$listeners" @input="handleInput" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue index 4601287b417..c5326ead60d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue @@ -1,11 +1,12 @@ <script> -import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { GlAvatar, GlIcon, GlIntersperse, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { compact } from 'lodash'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -14,8 +15,11 @@ export default { components: { BaseToken, GlAvatar, + GlIcon, + GlIntersperse, GlFilteredSearchSuggestion, }, + mixins: [glFeatureFlagMixin()], props: { config: { type: Object, @@ -32,8 +36,11 @@ export default { }, data() { return { + // current users visible in list users: this.config.initialUsers || [], + allUsers: this.config.initialUsers || [], loading: false, + selectedUsernames: [], }; }, computed: { @@ -49,13 +56,69 @@ export default { fetchUsersQuery() { return this.config.fetchUsers ? this.config.fetchUsers : this.fetchUsersBySearchTerm; }, + multiSelectEnabled() { + return this.config.multiSelect && this.glFeatures.groupMultiSelectTokens; + }, + }, + watch: { + value: { + deep: true, + immediate: true, + handler(newValue) { + const { data } = newValue; + + if (!this.multiSelectEnabled) { + return; + } + + // don't add empty values to selectedUsernames + if (!data) { + return; + } + + if (Array.isArray(data)) { + this.selectedUsernames = data; + // !active so we don't add strings while searching, e.g. r, ro, roo + // !includes so we don't add the same usernames (if @input is emitted twice) + } else if (!this.active && !this.selectedUsernames.includes(data)) { + this.selectedUsernames = this.selectedUsernames.concat(data); + } + }, + }, }, methods: { getActiveUser(users, data) { return users.find((user) => user.username.toLowerCase() === data.toLowerCase()); }, getAvatarUrl(user) { - return user.avatarUrl || user.avatar_url; + return user?.avatarUrl || user?.avatar_url; + }, + displayNameFor(username) { + return this.getActiveUser(this.allUsers, username)?.name || `@${username}`; + }, + avatarFor(username) { + const user = this.getActiveUser(this.allUsers, username); + return this.getAvatarUrl(user); + }, + addCheckIcon(username) { + return this.multiSelectEnabled && this.selectedUsernames.includes(username); + }, + addPadding(username) { + return this.multiSelectEnabled && !this.selectedUsernames.includes(username); + }, + handleSelected(username) { + if (!this.multiSelectEnabled) { + return; + } + + const index = this.selectedUsernames.indexOf(username); + if (index > -1) { + this.selectedUsernames.splice(index, 1); + } else { + this.selectedUsernames.push(username); + } + + this.$emit('input', { ...this.value, data: '' }); }, fetchUsersBySearchTerm(search) { return this.$apollo @@ -79,6 +142,7 @@ export default { // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756 this.users = Array.isArray(res) ? compact(res) : compact(res.data); + this.allUsers = this.allUsers.concat(this.users); }) .catch(() => createAlert({ @@ -103,18 +167,32 @@ export default { :get-active-token-value="getActiveUser" :default-suggestions="defaultUsers" :preloaded-suggestions="preloadedUsers" + :multi-select-values="selectedUsernames" v-bind="$attrs" @fetch-suggestions="fetchUsers" + @token-selected="handleSelected" v-on="$listeners" > <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> - <gl-avatar - v-if="activeTokenValue" - :size="16" - :src="getAvatarUrl(activeTokenValue)" - class="gl-mr-2" - /> - {{ activeTokenValue ? activeTokenValue.name : inputValue }} + <gl-intersperse v-if="multiSelectEnabled" separator=","> + <span + v-for="(username, index) in selectedUsernames" + :key="username" + :class="{ 'gl-ml-2': index > 0 }" + ><gl-avatar :size="16" :src="avatarFor(username)" class="gl-mr-1" />{{ + displayNameFor(username) + }}</span + > + </gl-intersperse> + <template v-else> + <gl-avatar + v-if="activeTokenValue" + :size="16" + :src="getAvatarUrl(activeTokenValue)" + class="gl-mr-2" + /> + {{ activeTokenValue ? activeTokenValue.name : inputValue }} + </template> </template> <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion @@ -122,7 +200,15 @@ export default { :key="user.username" :value="user.username" > - <div class="gl-display-flex"> + <div + class="gl-display-flex gl-align-items-center" + :class="{ 'gl-pl-6': addPadding(user.username) }" + > + <gl-icon + v-if="addCheckIcon(user.username)" + name="check" + class="gl-mr-3 gl-text-secondary gl-flex-shrink-0" + /> <gl-avatar :size="32" :src="getAvatarUrl(user)" /> <div> <div>{{ user.name }}</div> diff --git a/app/assets/javascripts/vue_shared/components/form/errors_alert.stories.js b/app/assets/javascripts/vue_shared/components/form/errors_alert.stories.js new file mode 100644 index 00000000000..7c32e38a299 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/errors_alert.stories.js @@ -0,0 +1,21 @@ +import ErrorsAlert from './errors_alert.vue'; + +export default { + component: ErrorsAlert, + title: 'vue_shared/form/errors_alert', +}; + +const defaultProps = { + errors: ['Name must be at least 5 characters.', 'Name cannot contain special characters.'], +}; + +const Template = (args) => ({ + components: { ErrorsAlert }, + data() { + return { errors: args.errors }; + }, + template: `<errors-alert v-model="errors" />`, +}); + +export const Default = Template.bind({}); +Default.args = defaultProps; diff --git a/app/assets/javascripts/vue_shared/components/form/errors_alert.vue b/app/assets/javascripts/vue_shared/components/form/errors_alert.vue new file mode 100644 index 00000000000..3e33168781b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/errors_alert.vue @@ -0,0 +1,42 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +export default { + components: { GlAlert }, + model: { + prop: 'errors', + }, + props: { + errors: { + type: Array, + required: true, + }, + }, + computed: { + title() { + return n__( + 'The form contains the following error:', + 'The form contains the following errors:', + this.errors.length, + ); + }, + }, +}; +</script> + +<template> + <gl-alert + v-if="errors.length" + class="gl-mb-5" + :title="title" + variant="danger" + @dismiss="$emit('input', [])" + > + <ul class="gl-pl-5 gl-mb-0"> + <li v-for="error in errors" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> +</template> diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue index d97f1ae6135..0455685627d 100644 --- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -94,8 +94,12 @@ export default { computedValueIsVisible() { return !this.showToggleVisibilityButton || this.valueIsVisible; }, - inputType() { - return this.computedValueIsVisible ? 'text' : 'password'; + formInputClass() { + return [ + 'gl-font-monospace! gl-cursor-default!', + { 'input-copy-show-disc': !this.computedValueIsVisible }, + this.formInputGroupProps.class, + ]; }, }, mounted() { @@ -157,10 +161,9 @@ export default { ref="input" :readonly="readonly" :width="size" - class="gl-font-monospace! gl-cursor-default!" + :class="formInputClass" v-bind="formInputGroupProps" :value="value" - :type="inputType" @input="handleInput" @click="handleClick" /> @@ -194,3 +197,8 @@ export default { </template> </gl-form-group> </template> +<style> +.input-copy-show-disc { + -webkit-text-security: disc; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/list_selector/constants.js b/app/assets/javascripts/vue_shared/components/list_selector/constants.js new file mode 100644 index 00000000000..cff9c56a1c0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/constants.js @@ -0,0 +1,6 @@ +import { __ } from '~/locale'; + +export const CONFIG = { + users: { title: __('Users'), icon: 'user', filterKey: 'username', showNamespaceDropdown: true }, + groups: { title: __('Groups'), icon: 'group', filterKey: 'name' }, +}; diff --git a/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue b/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue new file mode 100644 index 00000000000..2d24cc5553b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue @@ -0,0 +1,55 @@ +<script> +import { GlAvatar, GlButton } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; + +export default { + name: 'GroupItem', + components: { + GlAvatar, + GlButton, + }, + props: { + data: { + type: Object, + required: true, + }, + canDelete: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + deleteButtonLabel() { + return sprintf(__('Delete %{name}'), { name: this.name }); + }, + fullName() { + return this.data.fullName; + }, + name() { + return this.data.name; + }, + avatarUrl() { + return this.data.avatarUrl; + }, + }, +}; +</script> + +<template> + <span class="gl-display-flex gl-align-items-center gl-gap-3" @click="$emit('select', name)"> + <gl-avatar :alt="fullName" :size="32" :src="avatarUrl" /> + <span class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"> + <span class="gl-font-weight-bold">{{ fullName }}</span> + <span class="gl-text-gray-600">@{{ name }}</span> + </span> + + <gl-button + v-if="canDelete" + icon="remove" + :aria-label="deleteButtonLabel" + category="tertiary" + @click="$emit('delete', name)" + /> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/list_selector/index.vue b/app/assets/javascripts/vue_shared/components/list_selector/index.vue new file mode 100644 index 00000000000..b8480a0c496 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/index.vue @@ -0,0 +1,193 @@ +<script> +import { GlCard, GlIcon, GlCollapsibleListbox, GlSearchBoxByType } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql'; +import Api from '~/api'; +import UserItem from './user_item.vue'; +import GroupItem from './group_item.vue'; +import { CONFIG } from './constants'; + +const I18N = { + allGroups: __('All groups'), + projectGroups: __('Project groups'), + apiErrorMessage: __('An error occurred while fetching. Please try again.'), +}; + +export default { + name: 'ListSelector', + i18n: I18N, + components: { + GlCard, + GlIcon, + GlSearchBoxByType, + GlCollapsibleListbox, + }, + props: { + title: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + }, + selectedItems: { + type: Array, + required: false, + default: () => [], + }, + projectPath: { + type: String, + required: false, + default: null, + }, + groupPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + searchValue: '', + isProjectNamespace: 'true', + selected: [], + items: [], + }; + }, + computed: { + config() { + return CONFIG[this.type]; + }, + isUserVariant() { + return this.type === 'users'; + }, + component() { + return this.isUserVariant ? UserItem : GroupItem; + }, + namespaceDropdownText() { + return parseBoolean(this.isProjectNamespace) + ? this.$options.i18n.projectGroups + : this.$options.i18n.allGroups; + }, + }, + methods: { + async handleSearchInput(search) { + this.$refs.results.open(); + + try { + if (this.isUserVariant) { + this.items = await this.fetchUsersBySearchTerm(search); + } else { + this.items = await this.fetchGroupsBySearchTerm(search); + } + } catch (e) { + createAlert({ + message: this.$options.i18n.apiErrorMessage, + }); + } + }, + async fetchUsersBySearchTerm(search) { + let users = []; + if (parseBoolean(this.isProjectNamespace)) { + users = await Api.projectUsers(this.projectPath, search); + } else { + const groupMembers = await Api.groupMembers(this.groupPath, { query: search }); + users = groupMembers?.data || []; + } + + return users?.map((user) => ({ text: user.name, value: user.username, ...user })); + }, + fetchGroupsBySearchTerm(search) { + return this.$apollo + .query({ + query: groupsAutocompleteQuery, + variables: { search }, + }) + .then(({ data }) => + data?.groups.nodes.map((group) => ({ + text: group.fullName, + value: group.name, + ...group, + })), + ); + }, + getItemByKey(key) { + return this.items.find((item) => item[this.config.filterKey] === key); + }, + handleSelectItem(key) { + this.$emit('select', this.getItemByKey(key)); + }, + handleDeleteItem(key) { + this.$emit('delete', key); + }, + handleSelectNamespace() { + this.items = []; + this.searchValue = ''; + }, + }, + namespaceOptions: [ + { text: I18N.projectGroups, value: 'true' }, + { text: I18N.allGroups, value: 'false' }, + ], +}; +</script> + +<template> + <gl-card header-class="gl-new-card-header gl-border-none" body-class="gl-card-footer"> + <template #header + ><strong data-testid="list-selector-title" + >{{ title }} + <span class="gl-text-gray-700 gl-ml-3" + ><gl-icon :name="config.icon" /> {{ selectedItems.length }}</span + ></strong + ></template + > + + <div class="gl-display-flex gl-gap-3" :class="{ 'gl-mb-4': selectedItems.length }"> + <gl-collapsible-listbox + ref="results" + v-model="selected" + class="list-selector gl-display-block gl-flex-grow-1" + :items="items" + multiple + @shown="$refs.search.focusInput()" + > + <template #toggle> + <gl-search-box-by-type + ref="search" + v-model="searchValue" + autofocus + debounce="500" + @input="handleSearchInput" + /> + </template> + + <template #list-item="{ item }"> + <component :is="component" :data="item" @select="handleSelectItem" /> + </template> + </gl-collapsible-listbox> + + <gl-collapsible-listbox + v-if="config.showNamespaceDropdown" + v-model="isProjectNamespace" + :toggle-text="namespaceDropdownText" + :items="$options.namespaceOptions" + @select="handleSelectNamespace" + /> + </div> + + <component + :is="component" + v-for="(item, index) of selectedItems" + :key="index" + :class="{ 'gl-border-t': index > 0 }" + class="gl-p-3" + :data="item" + can-delete + @delete="handleDeleteItem" + /> + </gl-card> +</template> diff --git a/app/assets/javascripts/vue_shared/components/list_selector/user_item.vue b/app/assets/javascripts/vue_shared/components/list_selector/user_item.vue new file mode 100644 index 00000000000..fdbc767db81 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/user_item.vue @@ -0,0 +1,55 @@ +<script> +import { GlAvatar, GlButton } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; + +export default { + name: 'UserItem', + components: { + GlAvatar, + GlButton, + }, + props: { + data: { + type: Object, + required: true, + }, + canDelete: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + deleteButtonLabel() { + return sprintf(__('Delete %{name}'), { name: this.name }); + }, + name() { + return this.data.name; + }, + username() { + return this.data.username; + }, + avatarUrl() { + return this.data.avatarUrl; + }, + }, +}; +</script> + +<template> + <span class="gl-display-flex gl-align-items-center gl-gap-3" @click="$emit('select', username)"> + <gl-avatar :alt="name" :size="32" :src="avatarUrl" /> + <span class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"> + <span class="gl-font-weight-bold">{{ name }}</span> + <span class="gl-text-gray-600">@{{ username }}</span> + </span> + + <gl-button + v-if="canDelete" + icon="remove" + :aria-label="deleteButtonLabel" + category="tertiary" + @click="$emit('delete', username)" + /> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 741bdfd211b..cc3c95a047b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -492,7 +492,7 @@ export default { tracking-property="quickAction" /> <comment-templates-dropdown - v-if="!previewMarkdown && newCommentTemplatePath && glFeatures.savedReplies" + v-if="!previewMarkdown && newCommentTemplatePath" :new-comment-template-path="newCommentTemplatePath" @select="insertSavedReply" /> 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 4a3c3cf0053..73c030b23dc 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -190,7 +190,7 @@ export default { renderMarkdown(markdown) { const url = setUrlParams( { render_quick_actions: this.supportsQuickActions }, - joinPaths(gon.relative_url_root || window.location.origin, this.renderMarkdownPath), + joinPaths(window.location.origin, gon.relative_url_root, this.renderMarkdownPath), ); return axios.post(url, { text: markdown }).then(({ data }) => data.body); }, diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js index 27237f2f16b..6d74c1d083a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { helpPagePath } from '~/helpers/help_page_helper'; import axios from '~/lib/utils/axios_utils'; 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 0ec8b6e2a0a..3bee539688b 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -64,7 +64,7 @@ export default { }); }, lockedContextText() { - return sprintf(__('This %{noteableTypeText} is locked.'), { + return sprintf(__('The discussion in this %{noteableTypeText} is locked.'), { noteableTypeText: this.noteableTypeText, }); }, @@ -80,7 +80,7 @@ export default { <gl-sprintf :message=" __( - 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{confidentialLinkEnd} and %{lockedLinkStart}locked%{lockedLinkEnd}.', + 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{confidentialLinkEnd} and its %{lockedLinkStart}discussion is locked%{lockedLinkEnd}.', ) " > 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 81cbbf951ad..6a5884e4857 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -30,12 +30,10 @@ import { renderGFM } from '~/behaviors/markdown/render_gfm'; import TimelineEntryItem from './timeline_entry_item.vue'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; -const MR_ICON_COLORS = { +const ICON_COLORS = { check: 'gl-bg-green-100 gl-text-green-700', 'merge-request-close': 'gl-bg-red-100 gl-text-red-700', merge: 'gl-bg-blue-100 gl-text-blue-700', -}; -const ICON_COLORS = { 'issue-close': 'gl-bg-blue-100 gl-text-blue-700', }; @@ -76,6 +74,9 @@ export default { noteAnchorId() { return `note_${this.note.id}`; }, + isAllowedIcon() { + return Object.keys(ICON_COLORS).includes(this.note.system_note_icon_name); + }, isTargetNote() { return this.targetNoteHash === this.noteAnchorId; }, @@ -95,15 +96,8 @@ export default { isMergeRequest() { return this.getNoteableData.noteableType === 'MergeRequest'; }, - hasIconColors() { - if (!this.isMergeRequest) return true; - - return this.isMergeRequest && MR_ICON_COLORS[this.note.system_note_icon_name]; - }, iconBgClass() { - const colors = this.isMergeRequest ? MR_ICON_COLORS : ICON_COLORS; - - return colors[this.note.system_note_icon_name] || 'gl-bg-gray-50 gl-text-gray-600'; + return ICON_COLORS[this.note.system_note_icon_name] || 'gl-bg-gray-50 gl-text-gray-600'; }, }, mounted() { @@ -140,17 +134,16 @@ export default { :class="[ iconBgClass, { - 'mr-system-note-empty gl-bg-gray-900!': !hasIconColors, - 'gl-w-6 gl-h-6 gl-mt-n1 gl-ml-2': !isMergeRequest, - 'mr-system-note-icon': isMergeRequest, + 'system-note-icon': isAllowedIcon, + 'system-note-tiny-dot gl-bg-gray-900!': !isAllowedIcon, }, ]" class="gl-float-left gl--flex-center gl-rounded-full gl-relative timeline-icon" > <gl-icon - v-if="note.system_note_icon_name && hasIconColors" + v-if="isAllowedIcon" :name="note.system_note_icon_name" - :size="isMergeRequest ? 12 : 16" + :size="12" data-testid="timeline-icon" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue index 9bce9402afa..e2fd4477f0a 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue @@ -2,7 +2,6 @@ import { GlTooltipDirective } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import CommitInfo from '~/repository/components/commit_info.vue'; -import { calculateBlameOffset, toggleBlameClasses } from '../utils'; export default { name: 'BlameInfo', @@ -14,25 +13,11 @@ export default { SafeHtml, }, props: { - blameData: { + blameInfo: { type: Array, required: true, }, }, - computed: { - blameInfo() { - return this.blameData.map((blame, index) => ({ - ...blame, - blameOffset: calculateBlameOffset(blame.lineno, index), - })); - }, - }, - mounted() { - toggleBlameClasses(this.blameData, true); - }, - destroyed() { - toggleBlameClasses(this.blameData, false); - }, }; </script> <template> @@ -41,10 +26,11 @@ export default { <commit-info v-for="(blame, index) in blameInfo" :key="index" - :class="{ 'gl-border-t': index !== 0 }" + :class="{ 'gl-border-t': blame.blameOffset !== '0px' }" class="gl-display-flex gl-absolute gl-px-3" :style="{ top: blame.blameOffset }" :commit="blame.commit" + :prev-blame-link="blame.commitData && blame.commitData.projectBlameLink" /> </div> </div> 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 8dac6327a99..3b6dcace8fe 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 @@ -56,7 +56,6 @@ export default { data() { return { hasAppeared: false, - isLoading: true, }; }, computed: { @@ -68,17 +67,6 @@ export default { return getPageSearchString(this.blamePath, page); }, }, - created() { - if (this.chunkIndex === 0) { - // Display first chunk ASAP in order to improve perceived performance - this.isLoading = false; - return; - } - - window.requestIdleCallback(() => { - this.isLoading = false; - }); - }, methods: { handleChunkAppear() { this.hasAppeared = true; @@ -91,37 +79,37 @@ export default { }; </script> <template> - <gl-intersection-observer @appear="handleChunkAppear"> - <div class="gl-display-flex"> - <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> - <div - v-for="(n, index) in totalLines" - :key="index" - data-testid="line-numbers" - class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + <div class="gl-display-flex"> + <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> + <div + v-for="(n, index) in totalLines" + :key="index" + data-testid="line-numbers" + class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + > + <a + class="gl-user-select-none gl-shadow-none! file-line-blame" + :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" + ></a> + <a + :id="`L${calculateLineNumber(index)}`" + class="gl-user-select-none gl-shadow-none! file-line-num" + :href="`#L${calculateLineNumber(index)}`" + :data-line-number="calculateLineNumber(index)" > - <a - class="gl-user-select-none gl-shadow-none! file-line-blame" - :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" - ></a> - <a - :id="`L${calculateLineNumber(index)}`" - class="gl-user-select-none gl-shadow-none! file-line-num" - :href="`#L${calculateLineNumber(index)}`" - :data-line-number="calculateLineNumber(index)" - > - {{ calculateLineNumber(index) }} - </a> - </div> + {{ calculateLineNumber(index) }} + </a> </div> + </div> - <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> - <!-- Placeholder for line numbers while content is not highlighted --> - </div> + <div v-else class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> + <!-- Placeholder for line numbers while content is not highlighted --> + </div> + <gl-intersection-observer class="gl-w-full" @appear="handleChunkAppear"> <pre class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" - ><code v-if="shouldHighlight" v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> - </div> - </gl-intersection-observer> + ><code v-if="shouldHighlight" v-safe-html="highlightedContent" data-testid="content"></code><code v-else v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> + </gl-intersection-observer> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql new file mode 100644 index 00000000000..a5f3f348cfc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql @@ -0,0 +1,36 @@ +#import "~/graphql_shared/fragments/author.fragment.graphql" + +query getBlameData($fullPath: ID!, $filePath: String!, $fromLine: Int, $toLine: Int) { + project(fullPath: $fullPath) { + id + repository { + blobs(paths: [$filePath]) { + nodes { + id + blame(fromLine: $fromLine, toLine: $toLine) { + firstLine + groups { + lineno + span + commit { + id + titleHtml + message + authoredDate + authorGravatar + webPath + author { + ...Author + } + sha + } + commitData { + projectBlameLink + } + } + } + } + } + } + } +} 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 c7353ed6785..dcefa66c403 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 @@ -1,10 +1,15 @@ <script> +import { debounce } from 'lodash'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import LineHighlighter from '~/blob/line_highlighter'; import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; import Chunk from './components/chunk_new.vue'; +import Blame from './components/blame_info.vue'; +import { calculateBlameOffset, shouldRender, toggleBlameClasses } from './utils'; +import blameDataQuery from './queries/blame_data.query.graphql'; /* * Note, this is a new experimental version of the SourceViewer, it is not ready for production use. @@ -15,6 +20,7 @@ export default { name: 'SourceViewerNew', components: { Chunk, + Blame, }, directives: { SafeHtml, @@ -30,13 +36,55 @@ export default { required: false, default: () => [], }, + showBlame: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, }, data() { return { lineHighlighter: new LineHighlighter(), + blameData: [], + renderedChunks: [], }; }, + computed: { + blameInfo() { + return this.blameData.reduce((result, blame, index) => { + if (shouldRender(this.blameData, index)) { + result.push({ + ...blame, + blameOffset: calculateBlameOffset(blame.lineno, index), + }); + } + + return result; + }, []); + }, + }, + watch: { + showBlame: { + handler(shouldShow) { + toggleBlameClasses(this.blameData, shouldShow); + this.requestBlameInfo(this.renderedChunks[0]); + }, + immediate: true, + }, + blameData: { + handler(blameData) { + if (!this.showBlame) return; + toggleBlameClasses(blameData, true); + }, + immediate: true, + }, + }, created() { + this.handleAppear = debounce(this.handleChunkAppear, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); addBlobLinksTracking(); }, @@ -44,10 +92,39 @@ export default { this.selectLine(); }, methods: { + async handleChunkAppear(chunkIndex, handleOverlappingChunk = true) { + if (!this.renderedChunks.includes(chunkIndex)) { + this.renderedChunks.push(chunkIndex); + await this.requestBlameInfo(chunkIndex); + + if (chunkIndex > 0 && handleOverlappingChunk) { + // request the blame information for overlapping chunk incase it is visible in the DOM + this.handleChunkAppear(chunkIndex - 1, false); + } + } + }, + async requestBlameInfo(chunkIndex) { + const chunk = this.chunks[chunkIndex]; + if (!this.showBlame || !chunk) return; + + const { data } = await this.$apollo.query({ + query: blameDataQuery, + variables: { + fullPath: this.projectPath, + filePath: this.blob.path, + fromLine: chunk.startingFrom + 1, + toLine: chunk.startingFrom + chunk.totalLines, + }, + }); + + const blob = data?.project?.repository?.blobs?.nodes[0]; + const blameGroups = blob?.blame?.groups; + const isDuplicate = this.blameData.includes(blameGroups[0]); + if (blameGroups && !isDuplicate) this.blameData.push(...blameGroups); + }, async selectLine() { await this.$nextTick(); - const scrollEnabled = false; - this.lineHighlighter.highlightHash(this.$route.hash, scrollEnabled); + this.lineHighlighter.highlightHash(this.$route.hash); }, }, userColorScheme: window.gon.user_color_scheme, @@ -55,24 +132,27 @@ export default { </script> <template> - <div - class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" - :class="$options.userColorScheme" - data-type="simple" - :data-path="blob.path" - data-qa-selector="blob_viewer_file_content" - > - <chunk - v-for="(chunk, _, index) in chunks" - :key="index" - :chunk-index="index" - :is-highlighted="Boolean(chunk.isHighlighted)" - :raw-content="chunk.rawContent" - :highlighted-content="chunk.highlightedContent" - :total-lines="chunk.totalLines" - :starting-from="chunk.startingFrom" - :blame-path="blob.blamePath" - @appear="selectLine" - /> + <div class="gl-display-flex"> + <blame v-if="showBlame && blameInfo.length" :blame-info="blameInfo" /> + + <div + class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto gl-w-full" + :class="$options.userColorScheme" + data-type="simple" + :data-path="blob.path" + > + <chunk + v-for="(chunk, index) in chunks" + :key="index" + :chunk-index="index" + :is-highlighted="Boolean(chunk.isHighlighted)" + :raw-content="chunk.rawContent" + :highlighted-content="chunk.highlightedContent" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :blame-path="blob.blamePath" + @appear="() => handleAppear(index)" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js index af01653fc0d..596829b51a4 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js @@ -1,6 +1,7 @@ const BLAME_INFO_CLASSLIST = ['gl-border-t', 'gl-border-gray-500', 'gl-pt-3!']; const PADDING_BOTTOM_LARGE = 'gl-pb-6!'; const PADDING_BOTTOM_SMALL = 'gl-pb-3!'; +const VIEWER_SELECTOR = '.file-holder .blob-viewer'; const findLineNumberElement = (lineNumber) => document.getElementById(`L${lineNumber}`); @@ -8,8 +9,18 @@ const findLineContentElement = (lineNumber) => document.getElementById(`LC${line export const calculateBlameOffset = (lineNumber) => { if (lineNumber === 1) return '0px'; - const lineContentOffset = findLineContentElement(lineNumber)?.offsetTop; - return `${lineContentOffset}px`; + const blobViewerOffset = document.querySelector(VIEWER_SELECTOR)?.getBoundingClientRect().top; + const lineContentOffset = findLineContentElement(lineNumber)?.getBoundingClientRect().top; + return `${lineContentOffset - blobViewerOffset}px`; +}; + +export const shouldRender = (data, index) => { + const prevBlame = data[index - 1]; + const currBlame = data[index]; + const identicalSha = currBlame.commit.sha === prevBlame?.commit?.sha; + const lineNumberSmaller = currBlame.lineno < prevBlame?.lineno; + + return !identicalSha || lineNumberSmaller; }; export const toggleBlameClasses = (blameData, isVisible) => { @@ -17,7 +28,9 @@ export const toggleBlameClasses = (blameData, isVisible) => { * Adds/removes classes to line number/content elements to match the line with the blame info * */ const method = isVisible ? 'add' : 'remove'; - blameData.forEach(({ lineno, span }) => { + blameData.forEach(({ lineno, span }, index) => { + if (!shouldRender(blameData, index)) return; + const lineNumberEl = findLineNumberElement(lineno)?.parentElement; const lineContentEl = findLineContentElement(lineno); const lineNumberSpanEl = findLineNumberElement(lineno + span - 1)?.parentElement; diff --git a/app/assets/javascripts/vue_shared/components/toggle_labels.vue b/app/assets/javascripts/vue_shared/components/toggle_labels.vue index 05c837e32f0..db20e1288aa 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_labels.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_labels.vue @@ -54,7 +54,6 @@ export default { label-position="left" aria-describedby="board-labels-toggle-text" data-testid="show-labels-toggle" - data-qa-selector="show_labels_toggle" class="gl-flex-direction-row" @change="setShowLabels" /> diff --git a/app/assets/javascripts/vue_shared/components/users_table/constants.js b/app/assets/javascripts/vue_shared/components/users_table/constants.js new file mode 100644 index 00000000000..2a063a1be33 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/users_table/constants.js @@ -0,0 +1,3 @@ +export const USER_AVATAR_SIZE = 32; + +export const LENGTH_OF_USER_NOTE_TOOLTIP = 100; diff --git a/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue new file mode 100644 index 00000000000..5d86f90880d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue @@ -0,0 +1,76 @@ +<script> +import { GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { truncate } from '~/lib/utils/text_utility'; +import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from './constants'; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlAvatarLabeled, + GlBadge, + GlIcon, + }, + props: { + user: { + type: Object, + required: true, + }, + adminUserPath: { + type: String, + required: true, + }, + }, + computed: { + subLabel() { + if (this.user.email) { + return { + label: this.user.email, + link: `mailto:${this.user.email}`, + }; + } + + return { + label: `@${this.user.username}`, + }; + }, + adminUserHref() { + return this.adminUserPath.replace('id', this.user.username); + }, + userNoteShort() { + return truncate(this.user.note, LENGTH_OF_USER_NOTE_TOOLTIP); + }, + }, + USER_AVATAR_SIZE, +}; +</script> + +<template> + <div + v-if="user" + class="js-user-link gl-display-inline-block" + :data-user-id="user.id" + :data-username="user.username" + > + <gl-avatar-labeled + :size="$options.USER_AVATAR_SIZE" + :src="user.avatarUrl" + :label="user.name" + :sub-label="subLabel.label" + :label-link="adminUserHref" + :sub-label-link="subLabel.link" + > + <template #meta> + <div v-if="user.note" class="gl-text-gray-500 gl-p-1"> + <gl-icon v-gl-tooltip="userNoteShort" name="document" /> + </div> + <div v-for="(badge, idx) in user.badges" :key="idx" class="gl-p-1"> + <gl-badge class="gl-display-flex!" size="sm" :variant="badge.variant">{{ + badge.text + }}</gl-badge> + </div> + </template> + </gl-avatar-labeled> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/users_table/users_table.vue b/app/assets/javascripts/vue_shared/components/users_table/users_table.vue new file mode 100644 index 00000000000..be164bb07a3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/users_table/users_table.vue @@ -0,0 +1,110 @@ +<script> +import { GlSkeletonLoader, GlTable } from '@gitlab/ui'; +import { thWidthPercent } from '~/lib/utils/table_utility'; +import { __ } from '~/locale'; +import UserDate from '~/vue_shared/components/user_date.vue'; +import UserAvatar from './user_avatar.vue'; + +export default { + components: { + GlSkeletonLoader, + GlTable, + UserAvatar, + UserDate, + }, + props: { + users: { + type: Array, + required: true, + }, + adminUserPath: { + type: String, + required: true, + }, + groupCounts: { + type: Object, + required: false, + default: () => ({}), + }, + groupCountsLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + fields: [ + { + key: 'name', + label: __('Name'), + thClass: thWidthPercent(40), + }, + { + key: 'projectsCount', + label: __('Projects'), + thClass: thWidthPercent(10), + }, + { + key: 'groupCount', + label: __('Groups'), + thClass: thWidthPercent(10), + }, + { + key: 'createdAt', + label: __('Created on'), + thClass: thWidthPercent(15), + }, + { + key: 'lastActivityOn', + label: __('Last activity'), + thClass: thWidthPercent(15), + }, + { + key: 'settings', + label: '', + thClass: thWidthPercent(10), + }, + ], +}; +</script> + +<template> + <div> + <gl-table + :items="users" + :fields="$options.fields" + :empty-text="s__('AdminUsers|No users found')" + show-empty + stacked="md" + :tbody-tr-attr="{ 'data-testid': 'user-row-content' }" + > + <template #cell(name)="{ item: user }"> + <user-avatar :user="user" :admin-user-path="adminUserPath" /> + </template> + + <template #cell(createdAt)="{ item: { createdAt } }"> + <user-date :date="createdAt" /> + </template> + + <template #cell(lastActivityOn)="{ item: { lastActivityOn } }"> + <user-date :date="lastActivityOn" show-never /> + </template> + + <template #cell(groupCount)="{ item: { id } }"> + <div :data-testid="`user-group-count-${id}`"> + <gl-skeleton-loader v-if="groupCountsLoading" :width="40" :lines="1" /> + <span v-else>{{ groupCounts[id] || 0 }}</span> + </div> + </template> + + <template #cell(projectsCount)="{ item: { id, projectsCount } }"> + <div :data-testid="`user-project-count-${id}`"> + {{ projectsCount || 0 }} + </div> + </template> + + <template #cell(settings)="{ item: user }"> + <slot name="user-actions" :user="user"></slot> + </template> + </gl-table> + </div> +</template> 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 9fb0add5522..441b4c31b3a 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -335,7 +335,7 @@ export default { :variant="isBlob ? 'confirm' : 'default'" :category="isBlob ? 'primary' : 'secondary'" :toggle-text="$options.i18n.toggleText" - data-qa-selector="action_dropdown" + data-testid="action-dropdown" fluid-width block @shown="$emit('shown')" @@ -347,7 +347,7 @@ export default { v-for="action in actions" :key="action.key" :item="action" - :data-qa-selector="`${action.key}_menu_item`" + :data-testid="`${action.key}-menu-item`" @action="executeAction(action)" > <template #list-item> diff --git a/app/assets/javascripts/vue_shared/directives/safe_html.js b/app/assets/javascripts/vue_shared/directives/safe_html.js index 450c7fc1bc5..c731f742771 100644 --- a/app/assets/javascripts/vue_shared/directives/safe_html.js +++ b/app/assets/javascripts/vue_shared/directives/safe_html.js @@ -11,7 +11,7 @@ const DEFAULT_CONFIG = { const transform = (el, binding) => { if (binding.oldValue !== binding.value) { - const config = { ...DEFAULT_CONFIG, ...(binding.arg ?? {}) }; + const config = { ...DEFAULT_CONFIG, ...binding.arg }; el.textContent = ''; diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js index 79946ebaecd..a1abb079cc2 100644 --- a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js +++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js @@ -2,12 +2,11 @@ export default (Vue) => { Vue.mixin({ provide() { return { - glFeatures: - { - ...window.gon?.features, - // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460 - ...window.gon?.licensed_features, - } || {}, + glFeatures: { + ...window.gon?.features, + // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460 + ...window.gon?.licensed_features, + }, }; }, }); diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index 45fde45f516..dae3ddfe016 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -74,6 +74,11 @@ export default { required: false, default: 0, }, + workspaceType: { + type: String, + required: false, + default: '', + }, }, computed: { isUpdated() { @@ -161,6 +166,7 @@ export default { :issuable="issuable" :status-icon="statusIcon" :enable-edit="enableEdit" + :workspace-type="workspaceType" @edit-issuable="$emit('edit-issuable', $event)" > <template #status-badge> 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 a9b5e3a66a8..62a2b44e660 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 @@ -221,7 +221,7 @@ export default { @click="handleRightSidebarToggleClick" /> </div> - <div class="detail-page-header-actions gl-display-flex"> + <div class="detail-page-header-actions gl-align-self-center gl-display-flex"> <slot name="header-actions"></slot> </div> </div> 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 3878c16c8d0..040f49c7c25 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 @@ -147,6 +147,7 @@ export default { :description-help-path="descriptionHelpPath" :task-list-update-path="taskListUpdatePath" :task-list-lock-version="taskListLockVersion" + :workspace-type="workspaceType" @edit-issuable="$emit('edit-issuable', $event)" @task-list-update-success="$emit('task-list-update-success', $event)" @task-list-update-failure="$emit('task-list-update-failure')" 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 da71adc8abd..5387e39e3eb 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 @@ -1,5 +1,6 @@ <script> import { GlIcon, GlBadge, GlButton, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { STATUS_OPEN } from '~/issues/constants'; import { __ } from '~/locale'; @@ -13,6 +14,7 @@ export default { GlBadge, GlButton, GlIntersectionObserver, + ConfidentialityBadge, }, directives: { GlTooltip: GlTooltipDirective, @@ -31,6 +33,11 @@ export default { type: Boolean, required: true, }, + workspaceType: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -79,9 +86,7 @@ export default { class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" data-testid="header" > - <div - class="issue-sticky-header-text gl-display-flex gl-align-items-baseline gl-mx-auto gl-px-5" - > + <div class="issue-sticky-header-text gl-display-flex gl-align-items-baseline gl-mx-auto"> <gl-badge class="gl-white-space-nowrap gl-mr-3 gl-align-self-center" :variant="badgeVariant" @@ -91,6 +96,12 @@ export default { <slot name="status-badge"></slot> </span> </gl-badge> + <confidentiality-badge + v-if="issuable.confidential" + class="gl-white-space-nowrap gl-mr-3 gl-align-self-center" + :issuable-type="issuable.type" + :workspace-type="workspaceType" + /> <p class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" :title="issuable.title" |