Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-11-14 11:41:52 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-11-14 11:41:52 +0300
commit585826cb22ecea5998a2c2a4675735c94bdeedac (patch)
tree5b05f0b30d33cef48963609e8a18a4dff260eab3 /app/assets/javascripts/vue_shared
parentdf221d036e5d0c6c0ee4d55b9c97f481ee05dee8 (diff)
Add latest changes from gitlab-org/gitlab@16-6-stable-eev16.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared')
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue4
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/chronic_duration_input.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue157
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue128
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue122
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/constants.js10
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/group_select.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue150
-rw-r--r--app/assets/javascripts/vue_shared/components/entity_select/project_select.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue36
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/form/errors_alert.stories.js21
-rw-r--r--app/assets/javascripts/vue_shared/components/form/errors_alert.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/list_selector/constants.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/list_selector/group_item.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/list_selector/index.vue193
-rw-r--r--app/assets/javascripts/vue_shared/components/list_selector/user_item.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue66
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql36
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue122
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/utils.js19
-rw-r--r--app/assets/javascripts/vue_shared/components/toggle_labels.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/users_table/constants.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue76
-rw-r--r--app/assets/javascripts/vue_shared/components/users_table/users_table.vue110
-rw-r--r--app/assets/javascripts/vue_shared/components/web_ide_link.vue4
-rw-r--r--app/assets/javascripts/vue_shared/directives/safe_html.js2
-rw-r--r--app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js11
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue6
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue1
-rw-r--r--app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue17
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"