summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/vue_shared
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared')
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue46
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue56
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue7
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql1
-rw-r--r--app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue45
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue54
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js48
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js40
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue167
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue84
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue110
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue58
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/keep_alive_slots.vue51
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/list_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/remove_member_modal.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue41
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql18
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql16
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql14
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql6
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/user_select/user_select.vue302
-rw-r--r--app/assets/javascripts/vue_shared/components/vuex_module_provider.vue21
-rw-r--r--app/assets/javascripts/vue_shared/directives/validation.js66
-rw-r--r--app/assets/javascripts/vue_shared/mixins/timeago.js20
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue31
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue71
-rw-r--r--app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue135
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue83
-rw-r--r--app/assets/javascripts/vue_shared/security_configuration/provider.js9
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql (renamed from app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql)0
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql18
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue8
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/utils.js14
57 files changed, 1644 insertions, 179 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
index 554c7a573fe..ca42cb0b1b5 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
@@ -64,6 +64,9 @@ export default {
<sidebar-status
:project-path="projectPath"
:alert="alert"
+ :sidebar-collapsed="sidebarStatus"
+ text-class="gl-text-gray-500"
+ class="gl-w-70p"
@toggle-sidebar="$emit('toggle-sidebar')"
@alert-error="$emit('alert-error', $event)"
/>
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 2a999b908f9..ef31106b709 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
@@ -192,21 +192,33 @@ export default {
</script>
<template>
- <div class="block alert-assignees">
- <div ref="assignees" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
- <gl-icon name="user" :size="14" />
- <gl-loading-icon v-if="isUpdating" />
- </div>
- <gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left">
- <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK">
- <template #assignees>
- {{ userName }}
- </template>
- </gl-sprintf>
- </gl-tooltip>
+ <div
+ class="alert-assignees gl-py-5 gl-w-70p"
+ :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': !sidebarCollapsed }"
+ >
+ <template v-if="sidebarCollapsed">
+ <div
+ ref="assignees"
+ class="gl-mb-6 gl-ml-6"
+ data-testid="assignees-icon"
+ @click="$emit('toggle-sidebar')"
+ >
+ <gl-icon name="user" />
+ <gl-loading-icon v-if="isUpdating" />
+ </div>
+ <gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left">
+ <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK">
+ <template #assignees>
+ {{ userName }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+ </template>
- <div class="hide-collapsed">
- <p class="title gl-display-flex gl-justify-content-space-between">
+ <div v-else>
+ <p
+ class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between"
+ >
{{ __('Assignee') }}
<a
v-if="isEditable"
@@ -264,7 +276,11 @@ export default {
</div>
<gl-loading-icon v-if="isUpdating" :inline="true" />
- <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }">
+ <div
+ v-else-if="!isDropdownShowing"
+ class="hide-collapsed value gl-m-0"
+ :class="{ 'no-value': !userName }"
+ >
<div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users">
<span class="gl-relative gl-mr-4">
<img
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue
index fd40b5d9f65..832b154b312 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue
@@ -25,7 +25,7 @@ export default {
</script>
<template>
- <div class="block gl-display-flex gl-justify-content-space-between">
+ <div class="block gl-display-flex gl-justify-content-space-between gl-border-b-gray-100!">
<span class="issuable-header-text hide-collapsed">
{{ __('To Do') }}
</span>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
index 3822b9153a4..8715eb99518 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
@@ -30,6 +30,15 @@ export default {
required: false,
default: true,
},
+ sidebarCollapsed: {
+ type: Boolean,
+ required: false,
+ },
+ textClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -48,34 +57,44 @@ export default {
},
toggleFormDropdown() {
this.isDropdownShowing = !this.isDropdownShowing;
- const { dropdown } = this.$children[2].$refs.dropdown.$refs;
+ const { dropdown } = this.$refs.status.$refs.dropdown.$refs;
if (dropdown && this.isDropdownShowing) {
dropdown.show();
}
},
- handleUpdating(updating) {
- this.isUpdating = updating;
+ handleUpdating(isMutationInProgress) {
+ if (!isMutationInProgress) {
+ this.$emit('alert-update');
+ }
+ this.isUpdating = isMutationInProgress;
},
},
};
</script>
<template>
- <div class="block alert-status">
- <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
- <gl-icon name="status" :size="14" />
- <gl-loading-icon v-if="isUpdating" />
- </div>
- <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
- <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')">
- <template #status>
- {{ alert.status.toLowerCase() }}
- </template>
- </gl-sprintf>
- </gl-tooltip>
+ <div
+ class="alert-status gl-py-5"
+ :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': !sidebarCollapsed }"
+ >
+ <template v-if="sidebarCollapsed">
+ <div ref="status" class="gl-ml-6" data-testid="status-icon" @click="$emit('toggle-sidebar')">
+ <gl-icon name="status" />
+ <gl-loading-icon v-if="isUpdating" />
+ </div>
+ <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
+ <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')">
+ <template #status>
+ {{ alert.status.toLowerCase() }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+ </template>
- <div class="hide-collapsed">
- <p class="title gl-display-flex justify-content-between">
+ <div v-else>
+ <p
+ class="gl-text-gray-900 gl-mb-2 gl-line-height-20 gl-display-flex gl-justify-content-space-between"
+ >
{{ s__('AlertManagement|Status') }}
<a
v-if="isEditable"
@@ -90,6 +109,7 @@ export default {
</p>
<alert-status
+ ref="status"
:alert="alert"
:project-path="projectPath"
:is-dropdown-showing="isDropdownShowing"
@@ -106,7 +126,7 @@ export default {
class="value gl-m-0"
:class="{ 'no-value': !statuses[alert.status] }"
>
- <span v-if="statuses[alert.status]" class="gl-text-gray-500" data-testid="status">
+ <span v-if="statuses[alert.status]" :class="textClass" data-testid="status">
{{ statuses[alert.status] }}
</span>
<span v-else>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
index 271f0b4e4bb..a2a4046ab81 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
@@ -134,7 +134,12 @@ export default {
</script>
<template>
- <div :class="{ 'block todo': sidebarCollapsed, 'gl-ml-auto': !sidebarCollapsed }">
+ <div
+ :class="{
+ 'block todo': sidebarCollapsed,
+ 'gl-ml-auto': !sidebarCollapsed,
+ }"
+ >
<todo
data-testid="alert-todo-button"
:collapsed="sidebarCollapsed"
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
index bc4d91a51d1..f0095abfca1 100644
--- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
@@ -3,6 +3,7 @@ mutation createAlertIssue($projectPath: ID!, $iid: String!) {
errors
issue {
iid
+ webUrl
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue
new file mode 100644
index 00000000000..1f293b2150f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/alerts_deprecation_warning.vue
@@ -0,0 +1,41 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ inject: ['hasManagedPrometheus'],
+ i18n: {
+ alertsDeprecationText: s__(
+ 'Metrics|GitLab-managed Prometheus is deprecated and %{linkStart}scheduled for removal%{linkEnd}. Following this removal, your existing alerts will continue to function as part of the new cluster integration. However, you will no longer be able to add new alerts or edit existing alerts from the metrics dashboard.',
+ ),
+ },
+ methods: {
+ helpPagePath,
+ },
+};
+</script>
+
+<template>
+ <gl-alert v-if="hasManagedPrometheus" variant="warning" class="my-2">
+ <gl-sprintf :message="$options.i18n.alertsDeprecationText">
+ <template #link="{ content }">
+ <gl-link
+ :href="
+ helpPagePath('operations/metrics/alerts.html', {
+ anchor: 'managed-prometheus-instances',
+ })
+ "
+ target="_blank"
+ >
+ <span>{{ content }}</span>
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index f477610ff1d..f6ab3cac536 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -6,6 +6,7 @@ import { HIGHLIGHT_CLASS_NAME } from './constants';
import ViewerMixin from './mixins';
export default {
+ name: 'SimpleViewer',
components: {
GlIcon,
EditorLite: () =>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
new file mode 100644
index 00000000000..2552236a073
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_area_chart.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
+import { CHART_CONTAINER_HEIGHT } from './constants';
+
+export default {
+ name: 'CiCdAnalyticsAreaChart',
+ components: {
+ GlAreaChart,
+ ResizableChartContainer,
+ },
+ props: {
+ chartData: {
+ type: Array,
+ required: true,
+ },
+ areaChartOptions: {
+ type: Object,
+ required: true,
+ },
+ },
+ chartContainerHeight: CHART_CONTAINER_HEIGHT,
+};
+</script>
+<template>
+ <div class="gl-mt-3">
+ <p>
+ <slot></slot>
+ </p>
+ <resizable-chart-container>
+ <gl-area-chart
+ slot-scope="{ width }"
+ v-bind="$attrs"
+ :width="width"
+ :height="$options.chartContainerHeight"
+ :data="chartData"
+ :include-legend-avg-max="false"
+ :option="areaChartOptions"
+ >
+ <slot slot="tooltip-title" name="tooltip-title"></slot>
+ <slot slot="tooltip-content" name="tooltip-content"></slot>
+ </gl-area-chart>
+ </resizable-chart-container>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
new file mode 100644
index 00000000000..f4fd57e4cdc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/ci_cd_analytics_charts.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlSegmentedControl } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue';
+
+export default {
+ components: {
+ GlSegmentedControl,
+ CiCdAnalyticsAreaChart,
+ },
+ props: {
+ charts: {
+ required: true,
+ type: Array,
+ },
+ chartOptions: {
+ required: true,
+ type: Object,
+ },
+ },
+ data() {
+ return {
+ selectedChart: 0,
+ };
+ },
+ computed: {
+ chartRanges() {
+ return this.charts.map(({ title }, index) => ({ text: title, value: index }));
+ },
+ chart() {
+ return this.charts[this.selectedChart];
+ },
+ dateRange() {
+ return sprintf(s__('CiCdAnalytics|Date range: %{range}'), { range: this.chart.range });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-segmented-control v-model="selectedChart" :options="chartRanges" class="gl-mb-4" />
+ <ci-cd-analytics-area-chart
+ v-if="chart"
+ v-bind="$attrs"
+ :chart-data="chart.data"
+ :area-chart-options="chartOptions"
+ >
+ {{ dateRange }}
+
+ <slot slot="tooltip-title" name="tooltip-title"></slot>
+ <slot slot="tooltip-content" name="tooltip-content"></slot>
+ </ci-cd-analytics-area-chart>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js
new file mode 100644
index 00000000000..1561674c0ad
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/ci_cd_analytics/constants.js
@@ -0,0 +1 @@
+export const CHART_CONTAINER_HEIGHT = 300;
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
index eb8400e81c7..a1c7c4dd142 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue
@@ -28,6 +28,7 @@ export default {
</script>
<template>
+ <!-- eslint-disable @gitlab/vue-no-data-toggle -->
<button
:disabled="isDisabled || isLoading"
class="dropdown-menu-toggle dropdown-menu-full-width"
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index e622b505570..e1e71639115 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -93,6 +93,7 @@ const fileExtensionIcons = {
pdf: 'pdf',
xlsx: 'table',
xls: 'table',
+ ods: 'table',
csv: 'table',
tsv: 'table',
vscodeignore: 'vscode',
@@ -154,6 +155,7 @@ const fileExtensionIcons = {
gradle: 'gradle',
doc: 'word',
docx: 'word',
+ odt: 'word',
rtf: 'word',
cer: 'certificate',
cert: 'certificate',
@@ -204,6 +206,7 @@ const fileExtensionIcons = {
pps: 'powerpoint',
ppam: 'powerpoint',
ppa: 'powerpoint',
+ odp: 'powerpoint',
webm: 'movie',
mkv: 'movie',
flv: 'movie',
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 3d8afd162cb..2cb1b6a195f 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -1,24 +1,46 @@
-/* eslint-disable @gitlab/require-i18n-strings */
import { __ } from '~/locale';
-const DEFAULT_LABEL_NO_LABEL = { value: 'No label', text: __('No label') };
-export const DEFAULT_LABEL_NONE = { value: 'None', text: __('None') };
-export const DEFAULT_LABEL_ANY = { value: 'Any', text: __('Any') };
+export const DEBOUNCE_DELAY = 200;
+export const MAX_RECENT_TOKENS_SIZE = 3;
-export const DEFAULT_LABELS = [DEFAULT_LABEL_NO_LABEL];
+export const FILTER_NONE = 'None';
+export const FILTER_ANY = 'Any';
+export const FILTER_CURRENT = 'Current';
-export const DEBOUNCE_DELAY = 200;
+export const OPERATOR_IS = '=';
+export const OPERATOR_IS_TEXT = __('is');
+export const OPERATOR_IS_NOT = '!=';
+
+export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }];
+
+export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) };
+export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) };
+export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+
+export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([
+ { value: FILTER_CURRENT, text: __(FILTER_CURRENT) },
+]);
+
+export const DEFAULT_LABELS = [{ value: 'No label', text: __('No label') }]; // eslint-disable-line @gitlab/require-i18n-strings
+
+export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([
+ { value: 'Upcoming', text: __('Upcoming') }, // eslint-disable-line @gitlab/require-i18n-strings
+ { value: 'Started', text: __('Started') }, // eslint-disable-line @gitlab/require-i18n-strings
+]);
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
};
-export const DEFAULT_MILESTONES = [
- DEFAULT_LABEL_NONE,
- DEFAULT_LABEL_ANY,
- { value: 'Upcoming', text: __('Upcoming') },
- { value: 'Started', text: __('Started') },
-];
+export const FILTERED_SEARCH_TERM = 'filtered-search-term';
-/* eslint-enable @gitlab/require-i18n-strings */
+export const TOKEN_TITLE_AUTHOR = __('Author');
+export const TOKEN_TITLE_ASSIGNEE = __('Assignee');
+export const TOKEN_TITLE_MILESTONE = __('Milestone');
+export const TOKEN_TITLE_LABEL = __('Label');
+export const TOKEN_TITLE_MY_REACTION = __('My-Reaction');
+export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential');
+export const TOKEN_TITLE_ITERATION = __('Iteration');
+export const TOKEN_TITLE_EPIC = __('Epic');
+export const TOKEN_TITLE_WEIGHT = __('Weight');
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue
index 107ced550c1..3e7feb91b27 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
@@ -93,9 +93,9 @@ export default {
sortBy.sortDirection.descending === this.initialSortBy,
)
.pop();
- selectedSortDirection = this.initialSortBy.endsWith('_desc')
- ? SortDirection.descending
- : SortDirection.ascending;
+ selectedSortDirection = Object.keys(selectedSortOption.sortDirection).find(
+ (key) => selectedSortOption.sortDirection[key] === this.initialSortBy,
+ );
}
return {
@@ -324,7 +324,9 @@ export default {
class="gl-align-self-center"
:checked="checkboxChecked"
@input="$emit('checked-input', $event)"
- />
+ >
+ <span class="gl-sr-only">{{ __('Select all') }}</span>
+ </gl-form-checkbox>
<gl-filtered-search
ref="filteredSearchInput"
v-model="filterValue"
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
index a15cf220ee5..e5c8d29e09b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
@@ -1,6 +1,9 @@
-import { isEmpty } from 'lodash';
+import { isEmpty, uniqWith, isEqual } from 'lodash';
+import AccessorUtilities from '~/lib/utils/accessor';
import { queryToObject } from '~/lib/utils/url_utility';
+import { MAX_RECENT_TOKENS_SIZE } from './constants';
+
/**
* Strips enclosing quotations from a string if it has one.
*
@@ -162,3 +165,38 @@ export function urlQueryToFilter(query = '') {
return { ...memo, [filterName]: { value, operator } };
}, {});
}
+
+/**
+ * Returns array of token values from localStorage
+ * based on provided recentTokenValuesStorageKey
+ *
+ * @param {String} recentTokenValuesStorageKey
+ * @returns
+ */
+export function getRecentlyUsedTokenValues(recentTokenValuesStorageKey) {
+ let recentlyUsedTokenValues = [];
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ recentlyUsedTokenValues = JSON.parse(localStorage.getItem(recentTokenValuesStorageKey)) || [];
+ }
+ return recentlyUsedTokenValues;
+}
+
+/**
+ * Sets provided token value to recently used array
+ * within localStorage for provided recentTokenValuesStorageKey
+ *
+ * @param {String} recentTokenValuesStorageKey
+ * @param {Object} tokenValue
+ */
+export function setTokenValueToRecentlyUsed(recentTokenValuesStorageKey, tokenValue) {
+ const recentlyUsedTokenValues = getRecentlyUsedTokenValues(recentTokenValuesStorageKey);
+
+ recentlyUsedTokenValues.splice(0, 0, { ...tokenValue });
+
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ localStorage.setItem(
+ recentTokenValuesStorageKey,
+ JSON.stringify(uniqWith(recentlyUsedTokenValues, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)),
+ );
+ }
+}
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
new file mode 100644
index 00000000000..6ebc5431012
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue
@@ -0,0 +1,167 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+
+import { DEBOUNCE_DELAY } from '../constants';
+import { getRecentlyUsedTokenValues, setTokenValueToRecentlyUsed } from '../filtered_search_utils';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ },
+ props: {
+ tokenConfig: {
+ type: Object,
+ required: true,
+ },
+ tokenValue: {
+ type: Object,
+ required: true,
+ },
+ tokenActive: {
+ type: Boolean,
+ required: true,
+ },
+ tokensListLoading: {
+ type: Boolean,
+ required: true,
+ },
+ tokenValues: {
+ type: Array,
+ required: true,
+ },
+ fnActiveTokenValue: {
+ type: Function,
+ required: true,
+ },
+ defaultTokenValues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ recentTokenValuesStorageKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ valueIdentifier: {
+ type: String,
+ required: false,
+ default: 'id',
+ },
+ fnCurrentTokenValue: {
+ type: Function,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ searchKey: '',
+ recentTokenValues: this.recentTokenValuesStorageKey
+ ? getRecentlyUsedTokenValues(this.recentTokenValuesStorageKey)
+ : [],
+ loading: false,
+ };
+ },
+ computed: {
+ isRecentTokenValuesEnabled() {
+ return Boolean(this.recentTokenValuesStorageKey);
+ },
+ recentTokenIds() {
+ return this.recentTokenValues.map((tokenValue) => tokenValue.id || tokenValue.name);
+ },
+ currentTokenValue() {
+ if (this.fnCurrentTokenValue) {
+ return this.fnCurrentTokenValue(this.tokenValue.data);
+ }
+ return this.tokenValue.data.toLowerCase();
+ },
+ activeTokenValue() {
+ return this.fnActiveTokenValue(this.tokenValues, this.currentTokenValue);
+ },
+ /**
+ * Return all the tokenValues when searchKey is present
+ * otherwise return only the tokenValues which aren't
+ * present in "Recently used"
+ */
+ availableTokenValues() {
+ return this.searchKey
+ ? this.tokenValues
+ : this.tokenValues.filter(
+ (tokenValue) => !this.recentTokenIds.includes(tokenValue[this.valueIdentifier]),
+ );
+ },
+ },
+ watch: {
+ tokenActive: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.tokenValues.length) {
+ this.$emit('fetch-token-values', this.tokenValue.data);
+ }
+ },
+ },
+ },
+ methods: {
+ handleInput({ data }) {
+ this.searchKey = data;
+ setTimeout(() => {
+ if (!this.tokensListLoading) this.$emit('fetch-token-values', data);
+ }, DEBOUNCE_DELAY);
+ },
+ handleTokenValueSelected(activeTokenValue) {
+ if (this.isRecentTokenValuesEnabled) {
+ setTokenValueToRecentlyUsed(this.recentTokenValuesStorageKey, activeTokenValue);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="tokenConfig"
+ v-bind="{ ...this.$parent.$props, ...this.$parent.$attrs }"
+ v-on="this.$parent.$listeners"
+ @input="handleInput"
+ @select="handleTokenValueSelected(activeTokenValue)"
+ >
+ <template #view-token="viewTokenProps">
+ <slot name="view-token" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
+ </template>
+ <template #view="viewTokenProps">
+ <slot name="view" :view-token-props="{ ...viewTokenProps, activeTokenValue }"></slot>
+ </template>
+ <template #suggestions>
+ <template v-if="defaultTokenValues.length">
+ <gl-filtered-search-suggestion
+ v-for="token in defaultTokenValues"
+ :key="token.value"
+ :value="token.value"
+ >
+ {{ token.text }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider />
+ </template>
+ <template v-if="isRecentTokenValuesEnabled && recentTokenValues.length && !searchKey">
+ <gl-dropdown-section-header>{{ __('Recently used') }}</gl-dropdown-section-header>
+ <slot name="token-values-list" :token-values="recentTokenValues"></slot>
+ <gl-dropdown-divider />
+ </template>
+ <gl-loading-icon v-if="tokensListLoading" />
+ <template v-else>
+ <slot name="token-values-list" :token-values="availableTokenValues"></slot>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
index 98190d716c9..f2f4787d80b 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue
@@ -10,7 +10,7 @@ import { debounce } from 'lodash';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __ } from '~/locale';
-import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants';
+import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants';
import { stripQuotes } from '../filtered_search_utils';
export default {
@@ -33,7 +33,7 @@ export default {
data() {
return {
emojis: this.config.initialEmojis || [],
- defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
+ defaultEmojis: this.config.defaultEmojis || DEFAULT_NONE_ANY,
loading: true,
};
},
@@ -47,6 +47,16 @@ export default {
);
},
},
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.emojis.length) {
+ this.fetchEmojiBySearchTerm(this.value.data);
+ }
+ },
+ },
+ },
methods: {
fetchEmojiBySearchTerm(searchTerm) {
this.loading = true;
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
index 101c7150c55..1450807b11d 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue
@@ -1,15 +1,18 @@
<script>
-import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import {
+ GlDropdownDivider,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchToken,
+ GlLoadingIcon,
+} from '@gitlab/ui';
import { debounce } from 'lodash';
-
import createFlash from '~/flash';
-import { isNumeric } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
-import { DEBOUNCE_DELAY } from '../constants';
-import { stripQuotes } from '../filtered_search_utils';
+import { DEBOUNCE_DELAY, DEFAULT_NONE_ANY } from '../constants';
export default {
components: {
+ GlDropdownDivider,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlLoadingIcon,
@@ -32,29 +35,16 @@ export default {
},
computed: {
currentValue() {
- /*
- * When the URL contains the epic_iid, we'd get: '123'
- */
- if (isNumeric(this.value.data)) {
- return parseInt(this.value.data, 10);
- }
-
- /*
- * When the token is added in current session it'd be: 'Foo::&123'
- */
- const id = this.value.data.split('::&')[1];
-
- if (id) {
- return parseInt(id, 10);
- }
-
- return this.value.data;
+ return Number(this.value.data);
+ },
+ defaultEpics() {
+ return this.config.defaultEpics || DEFAULT_NONE_ANY;
+ },
+ idProperty() {
+ return this.config.idProperty || 'id';
},
activeEpic() {
- const currentValueIsString = typeof this.currentValue === 'string';
- return this.epics.find(
- (epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue,
- );
+ return this.epics.find((epic) => epic[this.idProperty] === this.currentValue);
},
},
watch: {
@@ -72,20 +62,8 @@ export default {
this.loading = true;
this.config
.fetchEpics(searchTerm)
- .then(({ data }) => {
- this.epics = data;
- })
- .catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
- .finally(() => {
- this.loading = false;
- });
- },
- fetchSingleEpic(iid) {
- this.loading = true;
- this.config
- .fetchSingleEpic(iid)
- .then(({ data }) => {
- this.epics = [data];
+ .then((response) => {
+ this.epics = Array.isArray(response) ? response : response.data;
})
.catch(() => createFlash({ message: __('There was a problem fetching epics.') }))
.finally(() => {
@@ -93,17 +71,13 @@ export default {
});
},
searchEpics: debounce(function debouncedSearch({ data }) {
- if (isNumeric(data)) {
- return this.fetchSingleEpic(data);
- }
- return this.fetchEpicsBySearchTerm(data);
+ this.fetchEpicsBySearchTerm(data);
}, DEBOUNCE_DELAY),
- getEpicValue(epic) {
- return `${epic.title}::&${epic.iid}`;
+ getEpicDisplayText(epic) {
+ return `${epic.title}::&${epic[this.idProperty]}`;
},
},
- stripQuotes,
};
</script>
@@ -115,17 +89,25 @@ export default {
@input="searchEpics"
>
<template #view="{ inputValue }">
- <span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span>
+ {{ activeEpic ? getEpicDisplayText(activeEpic) : inputValue }}
</template>
<template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="epic in defaultEpics"
+ :key="epic.value"
+ :value="epic.value"
+ >
+ {{ epic.text }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider v-if="defaultEpics.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="epic in epics"
- :key="epic.id"
- :value="getEpicValue(epic)"
+ :key="epic[idProperty]"
+ :value="String(epic[idProperty])"
>
- <div>{{ epic.title }}</div>
+ {{ epic.title }}
</gl-filtered-search-suggestion>
</template>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
new file mode 100644
index 00000000000..7b6a590279a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue
@@ -0,0 +1,110 @@
+<script>
+import {
+ GlDropdownDivider,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchToken,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import { DEBOUNCE_DELAY, DEFAULT_ITERATIONS } from '../constants';
+
+export default {
+ components: {
+ GlDropdownDivider,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchToken,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ iterations: this.config.initialIterations || [],
+ defaultIterations: this.config.defaultIterations || DEFAULT_ITERATIONS,
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data;
+ },
+ activeIteration() {
+ return this.iterations.find((iteration) => iteration.title === this.currentValue);
+ },
+ },
+ watch: {
+ active: {
+ immediate: true,
+ handler(newValue) {
+ if (!newValue && !this.iterations.length) {
+ this.fetchIterationBySearchTerm(this.currentValue);
+ }
+ },
+ },
+ },
+ methods: {
+ fetchIterationBySearchTerm(searchTerm) {
+ const fetchPromise = this.config.fetchPath
+ ? this.config.fetchIterations(this.config.fetchPath, searchTerm)
+ : this.config.fetchIterations(searchTerm);
+
+ this.loading = true;
+
+ fetchPromise
+ .then((response) => {
+ this.iterations = Array.isArray(response) ? response : response.data;
+ })
+ .catch(() => createFlash({ message: __('There was a problem fetching iterations.') }))
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ searchIterations: debounce(function debouncedSearch({ data }) {
+ this.fetchIterationBySearchTerm(data);
+ }, DEBOUNCE_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchIterations"
+ >
+ <template #view="{ inputValue }">
+ {{ activeIteration ? activeIteration.title : inputValue }}
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="iteration in defaultIterations"
+ :key="iteration.value"
+ :value="iteration.value"
+ >
+ {{ iteration.text }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider v-if="defaultIterations.length" />
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="iteration in iterations"
+ :key="iteration.title"
+ :value="iteration.title"
+ >
+ {{ iteration.title }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
new file mode 100644
index 00000000000..72116f0e991
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/weight_token.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlDropdownDivider, GlFilteredSearchSuggestion, GlFilteredSearchToken } from '@gitlab/ui';
+import { DEFAULT_NONE_ANY } from '../constants';
+
+export default {
+ baseWeights: ['0', '1', '2', '3', '4', '5'],
+ components: {
+ GlDropdownDivider,
+ GlFilteredSearchSuggestion,
+ GlFilteredSearchToken,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ weights: this.$options.baseWeights,
+ defaultWeights: this.config.defaultWeights || DEFAULT_NONE_ANY,
+ };
+ },
+ methods: {
+ updateWeights({ data }) {
+ const weight = parseInt(data, 10);
+ this.weights = Number.isNaN(weight) ? this.$options.baseWeights : [String(weight)];
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="updateWeights"
+ >
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="weight in defaultWeights"
+ :key="weight.value"
+ :value="weight.value"
+ >
+ {{ weight.text }}
+ </gl-filtered-search-suggestion>
+ <gl-dropdown-divider v-if="defaultWeights.length" />
+ <gl-filtered-search-suggestion v-for="weight of weights" :key="weight" :value="weight">
+ {{ weight }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index be0c843ef00..ccdb47e3144 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -146,6 +146,7 @@ export default {
<span v-if="dueDate" class="order-md-1">
<issue-due-date
:date="dueDate"
+ :closed="Boolean(closedAt)"
tooltip-placement="top"
css-class="item-due-date gl-display-flex gl-align-items-center"
/>
diff --git a/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue b/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue
new file mode 100644
index 00000000000..d68c4399275
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/keep_alive_slots.vue
@@ -0,0 +1,51 @@
+<script>
+export default {
+ props: {
+ slotKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ aliveSlotsLookup: {},
+ };
+ },
+ computed: {
+ aliveSlots() {
+ return Object.keys(this.aliveSlotsLookup);
+ },
+ },
+ watch: {
+ slotKey: {
+ handler(val) {
+ if (!val) {
+ return;
+ }
+
+ this.$set(this.aliveSlotsLookup, val, true);
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ isCurrentSlot(key) {
+ return key === this.slotKey;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ v-for="slot in aliveSlots"
+ v-show="isCurrentSlot(slot)"
+ :key="slot"
+ class="gl-h-full gl-w-full"
+ >
+ <slot :name="slot"></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
index 90ac20fe748..d6a20984ad1 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -34,7 +34,7 @@ export default {
boundary="window"
right
menu-class="gl-w-full!"
- data-qa-selector="apply_suggestion_button"
+ data-qa-selector="apply_suggestion_dropdown"
@shown="$refs.commitMessage.$el.focus()"
>
<gl-dropdown-form class="gl-px-4! gl-m-0!">
@@ -45,7 +45,7 @@ export default {
v-model="message"
:placeholder="defaultCommitMessage"
submit-on-enter
- data-qa-selector="commit_message_textbox"
+ data-qa-selector="commit_message_field"
@submit="onApply"
/>
<gl-button
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 01cf0beea3a..d343ba700ab 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -63,6 +63,9 @@ export default {
'\n',
);
},
+ mdCollapsibleSection() {
+ return ['<details><summary>Click to expand</summary>', `{text}`, '</details>'].join('\n');
+ },
isMac() {
// Accessing properties using ?. to allow tests to use
// this component without setting up window.gl.client.
@@ -245,6 +248,13 @@ export default {
icon="list-task"
/>
<toolbar-button
+ :tag="mdCollapsibleSection"
+ :prepend="true"
+ tag-select="Click to expand"
+ :button-title="__('Add a collapsible section')"
+ icon="details-block"
+ />
+ <toolbar-button
:tag="mdTable"
:prepend="true"
:button-title="__('Add a table')"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index bcd8c02e968..9c954fce322 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -70,7 +70,7 @@ export default {
<template>
<div class="md-suggestion">
<suggestion-diff-header
- class="qa-suggestion-diff-header js-suggestion-diff-header"
+ class="js-suggestion-diff-header"
:suggestions-count="suggestionsCount"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
index e2591362611..d05e45e90b3 100644
--- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -4,6 +4,7 @@ import Api from '~/api';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import Tracking from '~/tracking';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import { initialPaginationState, defaultI18n, defaultPageSize } from './constants';
@@ -105,7 +106,7 @@ export default {
unique: true,
symbol: '@',
token: AuthorToken,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
@@ -116,7 +117,7 @@ export default {
unique: true,
symbol: '@',
token: AuthorToken,
- operators: [{ value: '=', description: __('is'), default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
index 4ade75e705e..b9e916bc199 100644
--- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue
@@ -32,7 +32,7 @@ export default {
return {
'gl-border-t-transparent': !this.first && !this.selected,
'gl-border-t-gray-100': this.first && !this.selected,
- 'disabled-content': this.disabled,
+ 'gl-opacity-5': this.disabled,
'gl-border-b-gray-100': !this.selected,
'gl-bg-blue-50 gl-border-blue-200': this.selected,
};
diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
index dff3a6a8c3f..07272a5b8d6 100644
--- a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue
@@ -55,13 +55,12 @@ export default {
return !this.isAccessRequest && this.oncallSchedules.schedules?.length;
},
oncallSchedules() {
- let schedules = {};
try {
- schedules = JSON.parse(this.modalData.oncallSchedules);
+ return JSON.parse(this.modalData.oncallSchedules);
} catch (e) {
Sentry.captureException(e);
}
- return schedules;
+ return {};
},
},
mounted() {
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
index 795b4f58ac5..1f70644eb2c 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue
@@ -9,7 +9,9 @@ import {
GlIcon,
GlLoadingIcon,
GlSkeletonLoader,
+ GlResizeObserverDirective,
} from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { isEmpty } from 'lodash';
import { __, s__ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
@@ -33,6 +35,9 @@ export default {
GlSkeletonLoader,
ModalCopyButton,
},
+ directives: {
+ GlResizeObserver: GlResizeObserverDirective,
+ },
props: {
modalId: {
type: String,
@@ -87,6 +92,7 @@ export default {
selectedArchitecture: null,
showAlert: false,
instructions: {},
+ platformsButtonGroupVertical: false,
};
},
computed: {
@@ -127,6 +133,13 @@ export default {
toggleAlert(state) {
this.showAlert = state;
},
+ onPlatformsButtonResize() {
+ if (bp.getBreakpointSize() === 'xs') {
+ this.platformsButtonGroupVertical = true;
+ } else {
+ this.platformsButtonGroupVertical = false;
+ }
+ },
},
i18n: {
installARunner: s__('Runners|Install a runner'),
@@ -159,17 +172,23 @@ export default {
<h5>
{{ __('Environment') }}
</h5>
- <gl-button-group class="gl-mb-3">
- <gl-button
- v-for="platform in platforms"
- :key="platform.name"
- :selected="selectedPlatform && selectedPlatform.name === platform.name"
- data-testid="platform-button"
- @click="selectPlatform(platform)"
+ <div v-gl-resize-observer="onPlatformsButtonResize">
+ <gl-button-group
+ :vertical="platformsButtonGroupVertical"
+ :class="{ 'gl-w-full': platformsButtonGroupVertical }"
+ class="gl-mb-3"
+ data-testid="platform-buttons"
>
- {{ platform.humanReadableName }}
- </gl-button>
- </gl-button-group>
+ <gl-button
+ v-for="platform in platforms"
+ :key="platform.name"
+ :selected="selectedPlatform && selectedPlatform.name === platform.name"
+ @click="selectPlatform(platform)"
+ >
+ {{ platform.humanReadableName }}
+ </gl-button>
+ </gl-button-group>
+ </div>
</template>
<template v-if="hasArchitecureList">
<template v-if="selectedPlatform">
@@ -190,7 +209,7 @@ export default {
{{ architecture.name }}
</gl-dropdown-item>
</gl-dropdown>
- <div class="gl-display-flex gl-align-items-center gl-mb-3">
+ <div class="gl-sm-display-flex gl-align-items-center gl-mb-3">
<h5>{{ $options.i18n.downloadInstallBinary }}</h5>
<gl-button
class="gl-ml-auto"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
index 9b28ce0d881..94cf1f84ec3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -61,6 +61,7 @@ export default {
</script>
<template>
+ <!-- eslint-disable @gitlab/vue-no-data-toggle -->
<button
ref="dropdownButton"
:class="{ 'js-extra-options': showExtraOptions }"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
index e3704198ad0..d80b66fd9be 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState } from 'vuex';
+import { mapGetters, mapState } from 'vuex';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
@@ -18,6 +18,7 @@ export default {
},
computed: {
...mapState(['showDropdownContentsCreateView']),
+ ...mapGetters(['isDropdownVariantSidebar']),
dropdownContentsView() {
if (this.showDropdownContentsCreateView) {
return 'dropdown-contents-create-view';
@@ -25,11 +26,8 @@ export default {
return 'dropdown-contents-labels-view';
},
directionStyle() {
- if (this.renderOnTop) {
- return { bottom: '100%' };
- }
-
- return {};
+ const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem';
+ return this.renderOnTop ? { bottom } : {};
},
},
};
@@ -37,7 +35,7 @@ export default {
<template>
<div
- class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute"
+ class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute"
data-qa-selector="labels_dropdown_content"
:style="directionStyle"
>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index 6065b6c160c..86788a84260 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -83,12 +83,13 @@ export default {
const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused');
if (highlightedLabel) {
- const rect = highlightedLabel.getBoundingClientRect();
- if (rect.bottom > this.$refs.labelsListContainer.clientHeight) {
- highlightedLabel.scrollIntoView(false);
- }
- if (rect.top < 0) {
- highlightedLabel.scrollIntoView();
+ const container = this.$refs.labelsListContainer.getBoundingClientRect();
+ const label = highlightedLabel.getBoundingClientRect();
+
+ if (label.bottom > container.bottom) {
+ this.$refs.labelsListContainer.scrollTop += label.bottom - container.bottom;
+ } else if (label.top < container.top) {
+ this.$refs.labelsListContainer.scrollTop -= container.top - label.top;
}
}
},
@@ -177,7 +178,7 @@ export default {
class="labels-fetch-loading gl-align-items-center w-100 h-100"
size="md"
/>
- <ul v-else class="list-unstyled mb-0">
+ <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word">
<label-item
v-for="(label, index) in visibleLabels"
:key="label.id"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
index e431fd000a6..e8fdf4bb0c2 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
@@ -22,7 +22,7 @@ export default {
const { label, highlight, isLabelSet } = props;
const labelColorBox = h('span', {
- class: 'dropdown-label-box',
+ class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3',
style: {
backgroundColor: label.color,
},
@@ -33,7 +33,7 @@ export default {
const checkedIcon = h(GlIcon, {
class: {
- 'mr-2 align-self-center': true,
+ 'gl-mr-3 gl-flex-shrink-0': true,
hidden: !isLabelSet,
},
props: {
@@ -43,7 +43,7 @@ export default {
const noIcon = h('span', {
class: {
- 'mr-3 pr-2': true,
+ 'gl-mr-5 gl-pr-3': true,
hidden: isLabelSet,
},
attrs: {
@@ -56,7 +56,7 @@ export default {
const labelLink = h(
GlLink,
{
- class: 'd-flex align-items-baseline text-break-word label-item',
+ class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal',
on: {
click: () => {
listeners.clickLabel(label);
@@ -70,8 +70,8 @@ export default {
'li',
{
class: {
- 'd-block': true,
- 'text-left': true,
+ 'gl-display-block': true,
+ 'gl-text-left': true,
'is-focused': highlight,
},
},
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index f547433f322..a4462895f6a 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -268,7 +268,7 @@ export default {
this.$emit('toggleCollapse');
},
setContentIsOnViewport(showDropdownContents) {
- if (!this.isDropdownVariantEmbedded || !showDropdownContents) {
+ if (!showDropdownContents) {
this.contentIsOnViewport = true;
return;
@@ -276,8 +276,7 @@ export default {
this.$nextTick(() => {
if (this.$refs.dropdownContents) {
- const offset = { top: 100 };
- this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el, offset);
+ this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el);
}
});
},
@@ -313,6 +312,7 @@ export default {
<dropdown-contents
v-show="dropdownButtonVisible && showDropdownContents"
ref="dropdownContents"
+ :render-on-top="!contentIsOnViewport"
/>
</template>
<template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
new file mode 100644
index 00000000000..93b9833bb7d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql
@@ -0,0 +1,18 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query issueAssignees($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ __typename
+ issuable: issue(iid: $iid) {
+ __typename
+ id
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
index 3885127fa8e..48787305459 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql
@@ -13,12 +13,6 @@ query issueParticipants($fullPath: ID!, $iid: String!) {
...UserAvailability
}
}
- assignees {
- nodes {
- ...User
- ...UserAvailability
- }
- }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
new file mode 100644
index 00000000000..a2990d7171b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql
@@ -0,0 +1,14 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+query timeTrackingReport($id: IssueID!) {
+ issuable: issue(id: $id) {
+ __typename
+ id
+ title
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
new file mode 100644
index 00000000000..53f7381760e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql
@@ -0,0 +1,16 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query getMrAssignees($fullPath: ID!, $iid: String!) {
+ workspace: project(fullPath: $fullPath) {
+ issuable: mergeRequest(iid: $iid) {
+ id
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
index 63482873b69..6adbd4098f2 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql
@@ -11,12 +11,6 @@ query getMrParticipants($fullPath: ID!, $iid: String!) {
...UserAvailability
}
}
- assignees {
- nodes {
- ...User
- ...UserAvailability
- }
- }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
new file mode 100644
index 00000000000..753f1b345e3
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql
@@ -0,0 +1,14 @@
+#import "~/graphql_shared/fragments/issuable_timelogs.fragment.graphql"
+
+query timeTrackingReport($id: MergeRequestID!) {
+ issuable: mergeRequest(id: $id) {
+ __typename
+ id
+ title
+ timelogs {
+ nodes {
+ ...TimelogFragment
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
index 3f40c0368d7..24de5ea4fe3 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql
@@ -13,12 +13,6 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP
...UserAvailability
}
}
- participants {
- nodes {
- ...User
- ...UserAvailability
- }
- }
}
}
}
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 4447a87777a..66088b33c99 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -15,7 +15,7 @@ export default {
mixins: [timeagoMixin],
props: {
time: {
- type: String,
+ type: [String, Number],
required: true,
},
tooltipPlacement: {
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 11f484b2cdf..deac24d2270 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -20,7 +20,7 @@ export default {
},
props: {
target: {
- type: HTMLAnchorElement,
+ type: HTMLElement,
required: true,
},
user: {
@@ -79,7 +79,7 @@ export default {
<div class="gl-text-gray-500">
<div v-if="user.bio" class="gl-display-flex gl-mb-2">
<gl-icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" />
- <span ref="bio" class="gl-ml-2" v-html="user.bioHtml"></span>
+ <span ref="bio" class="gl-ml-2 gl-overflow-hidden" v-html="user.bioHtml"></span>
</div>
<div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
<gl-icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" />
diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
new file mode 100644
index 00000000000..3116d2fbf32
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue
@@ -0,0 +1,302 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
+import { __ } from '~/locale';
+import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue';
+import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants';
+
+export default {
+ i18n: {
+ unassigned: __('Unassigned'),
+ },
+ components: {
+ GlDropdownForm,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ SidebarParticipant,
+ GlLoadingIcon,
+ },
+ props: {
+ headerText: {
+ type: String,
+ required: true,
+ },
+ text: {
+ type: String,
+ required: true,
+ },
+ fullPath: {
+ type: String,
+ required: true,
+ },
+ iid: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: Array,
+ required: true,
+ },
+ allowMultipleAssignees: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ currentUser: {
+ type: Object,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+ data() {
+ return {
+ search: '',
+ participants: [],
+ searchUsers: [],
+ isSearching: false,
+ };
+ },
+ apollo: {
+ participants: {
+ query() {
+ return participantsQueries[this.issuableType].query;
+ },
+ variables() {
+ return {
+ iid: this.iid,
+ fullPath: this.fullPath,
+ };
+ },
+ update(data) {
+ return data.workspace?.issuable?.participants.nodes;
+ },
+ error() {
+ this.$emit('error');
+ },
+ },
+ searchUsers: {
+ // TODO Remove error policy
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
+ errorPolicy: 'all',
+ query: searchUsers,
+ variables() {
+ return {
+ fullPath: this.fullPath,
+ search: this.search,
+ first: 20,
+ };
+ },
+ update(data) {
+ // TODO Remove null filter (BE fix required)
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
+ return data.workspace?.users?.nodes.filter((x) => x).map(({ user }) => user) || [];
+ },
+ debounce: ASSIGNEES_DEBOUNCE_DELAY,
+ error({ graphQLErrors }) {
+ // TODO This error suppression is temporary (BE fix required)
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
+ const isNullError = ({ message }) => {
+ return message === 'Cannot return null for non-nullable field GroupMember.user';
+ };
+
+ if (graphQLErrors?.length > 0 && graphQLErrors.every(isNullError)) {
+ // only null-related errors exist, suppress them.
+ // eslint-disable-next-line no-console
+ console.error(
+ "Suppressing the error 'Cannot return null for non-nullable field GroupMember.user'. Please see https://gitlab.com/gitlab-org/gitlab/-/issues/329750",
+ );
+ this.isSearching = false;
+ return;
+ }
+
+ this.$emit('error');
+ this.isSearching = false;
+ },
+ result() {
+ this.isSearching = false;
+ },
+ },
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading;
+ },
+ users() {
+ if (!this.participants) {
+ return [];
+ }
+
+ const filteredParticipants = this.participants.filter(
+ (user) => user.name.includes(this.search) || user.username.includes(this.search),
+ );
+
+ // TODO this de-duplication is temporary (BE fix required)
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/327822
+ const mergedSearchResults = filteredParticipants
+ .concat(this.searchUsers)
+ .reduce(
+ (acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]),
+ [],
+ );
+
+ return this.moveCurrentUserToStart(mergedSearchResults);
+ },
+ isSearchEmpty() {
+ return this.search === '';
+ },
+ shouldShowParticipants() {
+ return this.isSearchEmpty || this.isSearching;
+ },
+ isCurrentUserInList() {
+ const isCurrentUser = (user) => user.username === this.currentUser.username;
+ return this.users.some(isCurrentUser);
+ },
+ noUsersFound() {
+ return !this.isSearchEmpty && this.users.length === 0;
+ },
+ showCurrentUser() {
+ return this.currentUser.username && !this.isCurrentUserInList && this.isSearchEmpty;
+ },
+ selectedFiltered() {
+ if (this.shouldShowParticipants) {
+ return this.moveCurrentUserToStart(this.value);
+ }
+
+ const foundUsernames = this.users.map(({ username }) => username);
+ const filtered = this.value.filter(({ username }) => foundUsernames.includes(username));
+ return this.moveCurrentUserToStart(filtered);
+ },
+ selectedUserNames() {
+ return this.value.map(({ username }) => username);
+ },
+ unselectedFiltered() {
+ return this.users?.filter(({ username }) => !this.selectedUserNames.includes(username)) || [];
+ },
+ selectedIsEmpty() {
+ return this.selectedFiltered.length === 0;
+ },
+ },
+ watch: {
+ // We need to add this watcher to track the moment when user is alredy typing
+ // but query is still not started due to debounce
+ search(newVal) {
+ if (newVal) {
+ this.isSearching = true;
+ }
+ },
+ },
+ methods: {
+ selectAssignee(user) {
+ let selected = [...this.value];
+ if (!this.allowMultipleAssignees) {
+ selected = [user];
+ } else {
+ selected.push(user);
+ }
+ this.$emit('input', selected);
+ },
+ unselect(name) {
+ const selected = this.value.filter((user) => user.username !== name);
+ this.$emit('input', selected);
+ },
+ focusSearch() {
+ this.$refs.search.focusInput();
+ },
+ showDivider(list) {
+ return list.length > 0 && this.isSearchEmpty;
+ },
+ moveCurrentUserToStart(users) {
+ if (!users) {
+ return [];
+ }
+ const usersCopy = [...users];
+ const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
+
+ if (currentUser) {
+ const index = usersCopy.indexOf(currentUser);
+ usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
+ }
+
+ return usersCopy;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')">
+ <template #header>
+ <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p>
+ <gl-dropdown-divider />
+ <gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" />
+ </template>
+ <gl-dropdown-form class="gl-relative gl-min-h-7">
+ <gl-loading-icon
+ v-if="isLoading"
+ data-testid="loading-participants"
+ size="md"
+ class="gl-absolute gl-left-0 gl-top-0 gl-right-0"
+ />
+ <template v-else>
+ <template v-if="shouldShowParticipants">
+ <gl-dropdown-item
+ v-if="isSearchEmpty"
+ :is-checked="selectedIsEmpty"
+ :is-check-centered="true"
+ data-testid="unassign"
+ @click="$emit('input', [])"
+ >
+ <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{
+ $options.i18n.unassigned
+ }}</span></gl-dropdown-item
+ >
+ </template>
+ <gl-dropdown-divider v-if="showDivider(selectedFiltered)" />
+ <gl-dropdown-item
+ v-for="item in selectedFiltered"
+ :key="item.id"
+ is-checked
+ is-check-centered
+ data-testid="selected-participant"
+ @click.stop="unselect(item.username)"
+ >
+ <sidebar-participant :user="item" />
+ </gl-dropdown-item>
+ <template v-if="showCurrentUser">
+ <gl-dropdown-divider />
+ <gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)">
+ <sidebar-participant :user="currentUser" class="gl-pl-6!" />
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" />
+ <gl-dropdown-item
+ v-for="unselectedUser in unselectedFiltered"
+ :key="unselectedUser.id"
+ data-testid="unselected-participant"
+ @click="selectAssignee(unselectedUser)"
+ >
+ <sidebar-participant :user="unselectedUser" class="gl-pl-6!" />
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!">
+ {{ __('No matching results') }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown-form>
+ <template #footer>
+ <slot name="footer"></slot>
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
new file mode 100644
index 00000000000..eff39e2fb89
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/vuex_module_provider.vue
@@ -0,0 +1,21 @@
+<script>
+export default {
+ provide() {
+ return {
+ // We can't use this.vuexModule due to bug in vue-apollo when
+ // provide is called in beforeCreate
+ // See https://github.com/vuejs/vue-apollo/pull/1153 for details
+ vuexModule: this.$options.propsData.vuexModule,
+ };
+ },
+ props: {
+ vuexModule: {
+ type: String,
+ required: true,
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js
index 176954891e9..692f2769b88 100644
--- a/app/assets/javascripts/vue_shared/directives/validation.js
+++ b/app/assets/javascripts/vue_shared/directives/validation.js
@@ -33,6 +33,10 @@ const focusFirstInvalidInput = (e) => {
}
};
+const getInputElement = (el) => {
+ return el.querySelector('input') || el;
+};
+
const isEveryFieldValid = (form) => Object.values(form.fields).every(({ state }) => state === true);
const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => {
@@ -91,8 +95,9 @@ export default function initValidation(customFeedbackMap = {}) {
const elDataMap = new WeakMap();
return {
- inserted(el, binding, { context }) {
+ inserted(element, binding, { context }) {
const { arg: showGlobalValidation } = binding;
+ const el = getInputElement(element);
const { form: formEl } = el;
const validate = createValidator(context, feedbackMap);
@@ -121,7 +126,8 @@ export default function initValidation(customFeedbackMap = {}) {
validate({ el, reportInvalidInput: showGlobalValidation });
},
- update(el, binding) {
+ update(element, binding) {
+ const el = getInputElement(element);
const { arg: showGlobalValidation } = binding;
const { validate, isTouched, isBlurred } = elDataMap.get(el);
const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred);
@@ -130,3 +136,59 @@ export default function initValidation(customFeedbackMap = {}) {
},
};
}
+
+/**
+ * This is a helper that initialize the form fields structure to be used in initForm
+ * @param {*} fieldValues
+ * @returns formObject
+ */
+const initFormField = ({ value, required = true, skipValidation = false }) => ({
+ value,
+ required,
+ state: skipValidation ? true : null,
+ feedback: null,
+});
+
+/**
+ * This is a helper that initialize the form structure that is compliant to be used with the validation directive
+ *
+ * @example
+ * const form initForm = initForm({
+ * fields: {
+ * name: {
+ * value: 'lorem'
+ * },
+ * description: {
+ * value: 'ipsum',
+ * required: false,
+ * skipValidation: true
+ * }
+ * }
+ * })
+ *
+ * @example
+ * const form initForm = initForm({
+ * state: true, // to override
+ * foo: { // something custom
+ * bar: 'lorem'
+ * },
+ * fields: {...}
+ * })
+ *
+ * @param {*} formObject
+ * @returns form
+ */
+export const initForm = ({ fields = {}, ...rest } = {}) => {
+ const initFields = Object.fromEntries(
+ Object.entries(fields).map(([fieldName, fieldValues]) => {
+ return [fieldName, initFormField(fieldValues)];
+ }),
+ );
+
+ return {
+ state: false,
+ showValidation: false,
+ ...rest,
+ fields: initFields,
+ };
+};
diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js
index af14c6d9486..45452f2ea35 100644
--- a/app/assets/javascripts/vue_shared/mixins/timeago.js
+++ b/app/assets/javascripts/vue_shared/mixins/timeago.js
@@ -14,5 +14,25 @@ export default {
tooltipTitle(time) {
return formatDate(time);
},
+
+ durationTimeFormatted(duration) {
+ const date = new Date(duration * 1000);
+
+ let hh = date.getUTCHours();
+ let mm = date.getUTCMinutes();
+ let ss = date.getSeconds();
+
+ if (hh < 10) {
+ hh = `0${hh}`;
+ }
+ if (mm < 10) {
+ mm = `0${mm}`;
+ }
+ if (ss < 10) {
+ ss = `0${ss}`;
+ }
+
+ return `${hh}:${mm}:${ss}`;
+ },
},
};
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
new file mode 100644
index 00000000000..d2fc2c66924
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue
@@ -0,0 +1,31 @@
+<script>
+export default {
+ inheritAttrs: false,
+ props: {
+ selector: {
+ type: String,
+ required: true,
+ },
+ },
+ mounted() {
+ const legacyEntry = document.querySelector(this.selector);
+ if (legacyEntry.tagName === 'TEMPLATE') {
+ this.$el.innerHTML = legacyEntry.innerHTML;
+ } else {
+ this.source = legacyEntry.parentNode;
+ this.$el.appendChild(legacyEntry);
+ legacyEntry.classList.add('active');
+ }
+ },
+
+ beforeDestroy() {
+ if (this.source) {
+ this.$el.firstChild.classList.remove('active');
+ this.source.appendChild(this.$el.firstChild);
+ }
+ },
+};
+</script>
+<template>
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
new file mode 100644
index 00000000000..e9983af5401
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import Vue from 'vue';
+import Tracking from '~/tracking';
+
+export default {
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ panels: {
+ type: Array,
+ required: true,
+ },
+ experiment: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ created() {
+ const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: this.experiment });
+ const trackingInstance = new Vue({
+ ...trackingMixin,
+ render() {
+ return null;
+ },
+ });
+ this.track = trackingInstance.track;
+ },
+};
+</script>
+<template>
+ <div class="container">
+ <h2 class="gl-my-7 gl-font-size-h1 gl-text-center">
+ {{ title }}
+ </h2>
+ <div>
+ <div
+ v-for="panel in panels"
+ :key="panel.name"
+ class="new-namespace-panel-wrapper gl-display-inline-block gl-px-3 gl-mb-5"
+ >
+ <a
+ :href="`#${panel.name}`"
+ :data-qa-selector="`${panel.name}_link`"
+ class="new-namespace-panel gl-display-flex gl-flex-shrink-0 gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-center gl-rounded-base gl-border-gray-100 gl-border-solid gl-border-1 gl-w-full gl-py-6 gl-px-8 gl-hover-text-decoration-none!"
+ @click="track('click_tab', { label: panel.name })"
+ >
+ <div
+ v-safe-html="panel.illustration"
+ class="new-namespace-panel-illustration gl-text-white gl-display-flex gl-flex-shrink-0 gl-justify-content-center"
+ ></div>
+ <div class="gl-pl-4">
+ <h3 class="gl-font-size-h2 gl-reset-color">
+ {{ panel.title }}
+ </h3>
+ <p class="gl-text-gray-900">
+ {{ panel.description }}
+ </p>
+ </div>
+ </a>
+ </div>
+ </div>
+ <slot name="footer"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
new file mode 100644
index 00000000000..54313297b14
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue
@@ -0,0 +1,135 @@
+<script>
+import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+
+import LegacyContainer from './components/legacy_container.vue';
+import WelcomePage from './components/welcome.vue';
+
+export default {
+ components: {
+ GlBreadcrumb,
+ GlIcon,
+ WelcomePage,
+ LegacyContainer,
+ },
+ directives: {
+ SafeHtml,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ initialBreadcrumb: {
+ type: String,
+ required: true,
+ },
+ panels: {
+ type: Array,
+ required: true,
+ },
+ jumpToLastPersistedPanel: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ persistenceKey: {
+ type: String,
+ required: true,
+ },
+ experiment: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+
+ data() {
+ return {
+ activePanelName: null,
+ };
+ },
+
+ computed: {
+ activePanel() {
+ return this.panels.find((p) => p.name === this.activePanelName);
+ },
+
+ details() {
+ return this.activePanel.details || this.activePanel.description;
+ },
+
+ hasTextDetails() {
+ return typeof this.details === 'string';
+ },
+
+ breadcrumbs() {
+ if (!this.activePanel) {
+ return null;
+ }
+
+ return [
+ { text: this.initialBreadcrumb, href: '#' },
+ { text: this.activePanel.title, href: `#${this.activePanel.name}` },
+ ];
+ },
+ },
+
+ created() {
+ this.handleLocationHashChange();
+
+ if (this.jumpToLastPersistedPanel) {
+ this.activePanelName = localStorage.getItem(this.persistenceKey) || this.panels[0].name;
+ }
+
+ window.addEventListener('hashchange', () => {
+ this.handleLocationHashChange();
+ this.$emit('panel-change');
+ });
+
+ this.$root.$on('clicked::link', (e) => {
+ window.location = e.target.href;
+ });
+ },
+
+ methods: {
+ handleLocationHashChange() {
+ this.activePanelName = window.location.hash.substring(1) || null;
+ if (this.activePanelName) {
+ localStorage.setItem(this.persistenceKey, this.activePanelName);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <welcome-page
+ v-if="activePanelName === null"
+ :panels="panels"
+ :title="title"
+ :experiment="experiment"
+ >
+ <template #footer>
+ <slot name="welcome-footer"> </slot>
+ </template>
+ </welcome-page>
+ <div v-else class="row">
+ <div class="col-lg-3">
+ <div v-safe-html="activePanel.illustration" class="gl-text-white"></div>
+ <h4>{{ activePanel.title }}</h4>
+
+ <p v-if="hasTextDetails">{{ details }}</p>
+ <component :is="details" v-else />
+
+ <slot name="extra-description"></slot>
+ </div>
+ <div class="col-lg-9">
+ <gl-breadcrumb v-if="breadcrumbs" :items="breadcrumbs">
+ <template #separator>
+ <gl-icon name="chevron-right" :size="8" />
+ </template>
+ </gl-breadcrumb>
+ <legacy-container :key="activePanel.name" class="gl-mt-3" :selector="activePanel.selector" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
new file mode 100644
index 00000000000..12e5f634a08
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants';
+import { redirectTo } from '~/lib/utils/url_utility';
+import { sprintf, s__ } from '~/locale';
+import apolloProvider from '../provider';
+
+export default {
+ apolloProvider,
+ components: {
+ GlButton,
+ },
+ inject: ['projectPath'],
+ props: {
+ feature: {
+ type: Object,
+ required: true,
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: 'success',
+ },
+ category: {
+ type: String,
+ required: false,
+ default: 'secondary',
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ };
+ },
+ computed: {
+ featureSettings() {
+ return featureToMutationMap[this.feature.type];
+ },
+ },
+ methods: {
+ async mutate() {
+ this.isLoading = true;
+ try {
+ const mutation = this.featureSettings;
+ const { data } = await this.$apollo.mutate(mutation.getMutationPayload(this.projectPath));
+ const { errors, successPath } = data[mutation.mutationId];
+
+ if (errors.length > 0) {
+ throw new Error(errors[0]);
+ }
+
+ if (!successPath) {
+ throw new Error(
+ sprintf(this.$options.i18n.noSuccessPathError, { featureName: this.feature.name }),
+ );
+ }
+
+ redirectTo(successPath);
+ } catch (e) {
+ this.$emit('error', e.message);
+ this.isLoading = false;
+ }
+ },
+ },
+ i18n: {
+ buttonLabel: s__('SecurityConfiguration|Configure via Merge Request'),
+ noSuccessPathError: s__(
+ 'SecurityConfiguration|%{featureName} merge request creation mutation failed',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ v-if="!feature.configured"
+ :loading="isLoading"
+ :variant="variant"
+ :category="category"
+ @click="mutate"
+ >{{ $options.i18n.buttonLabel }}</gl-button
+ >
+</template>
diff --git a/app/assets/javascripts/vue_shared/security_configuration/provider.js b/app/assets/javascripts/vue_shared/security_configuration/provider.js
new file mode 100644
index 00000000000..ef96b443da8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_configuration/provider.js
@@ -0,0 +1,9 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export default new VueApollo({
+ defaultClient: createDefaultClient(),
+});
diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql
index 4ce13827da2..4ce13827da2 100644
--- a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql
+++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql
diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql
new file mode 100644
index 00000000000..c7e9fa16418
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_pipeline_download_paths.query.graphql
@@ -0,0 +1,18 @@
+query getCorpuses($projectPath: ID!, $iid: ID, $reportTypes: [SecurityReportTypeEnum!]) {
+ project(fullPath: $projectPath) {
+ pipeline(iid: $iid) {
+ id
+ jobs(securityReportTypes: $reportTypes) {
+ nodes {
+ name
+ artifacts {
+ nodes {
+ downloadPath
+ fileType
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
index 1151cffa76f..b7f283b8fd9 100644
--- a/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
+++ b/app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue
@@ -13,10 +13,10 @@ import {
REPORT_TYPE_SECRET_DETECTION,
reportTypeToSecurityReportTypeEnum,
} from './constants';
-import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql';
+import securityReportMergeRequestDownloadPathsQuery from './queries/security_report_merge_request_download_paths.query.graphql';
import store from './store';
import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants';
-import { extractSecurityReportArtifacts } from './utils';
+import { extractSecurityReportArtifactsFromMergeRequest } from './utils';
export default {
store,
@@ -86,7 +86,7 @@ export default {
},
apollo: {
reportArtifacts: {
- query: securityReportDownloadPathsQuery,
+ query: securityReportMergeRequestDownloadPathsQuery,
variables() {
return {
projectPath: this.targetProjectFullPath,
@@ -97,7 +97,7 @@ export default {
};
},
update(data) {
- return extractSecurityReportArtifacts(this.$options.reportTypes, data);
+ return extractSecurityReportArtifactsFromMergeRequest(this.$options.reportTypes, data);
},
error(error) {
this.showError(error);
diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js
index ad819bf7081..c3f24a7e52f 100644
--- a/app/assets/javascripts/vue_shared/security_reports/utils.js
+++ b/app/assets/javascripts/vue_shared/security_reports/utils.js
@@ -14,9 +14,7 @@ const addReportTypeIfExists = (acc, reportTypes, reportType, getName, downloadPa
}
};
-export const extractSecurityReportArtifacts = (reportTypes, data) => {
- const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? [];
-
+const extractSecurityReportArtifacts = (reportTypes, jobs) => {
return jobs.reduce((acc, job) => {
const artifacts = job.artifacts?.nodes ?? [];
@@ -41,3 +39,13 @@ export const extractSecurityReportArtifacts = (reportTypes, data) => {
return acc;
}, []);
};
+
+export const extractSecurityReportArtifactsFromPipeline = (reportTypes, data) => {
+ const jobs = data.project?.pipeline?.jobs?.nodes ?? [];
+ return extractSecurityReportArtifacts(reportTypes, jobs);
+};
+
+export const extractSecurityReportArtifactsFromMergeRequest = (reportTypes, data) => {
+ const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? [];
+ return extractSecurityReportArtifacts(reportTypes, jobs);
+};