diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared')
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); +}; |