diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 17:22:11 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 17:22:11 +0300 |
commit | 0c872e02b2c822e3397515ec324051ff540f0cd5 (patch) | |
tree | ce2fb6ce7030e4dad0f4118d21ab6453e5938cdd /app/assets/javascripts | |
parent | f7e05a6853b12f02911494c4b3fe53d9540d74fc (diff) |
Add latest changes from gitlab-org/gitlab@15-7-stable-eev15.7.0-rc42
Diffstat (limited to 'app/assets/javascripts')
843 files changed, 11665 insertions, 5552 deletions
diff --git a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue index 80c216024a0..8e814cd55ef 100644 --- a/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue +++ b/app/assets/javascripts/admin/background_migrations/components/database_listbox.vue @@ -1,5 +1,5 @@ <script> -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { s__ } from '~/locale'; import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; @@ -9,7 +9,7 @@ export default { database: s__('BackgroundMigrations|Database'), }, components: { - GlListbox, + GlCollapsibleListbox, }, props: { databases: { @@ -39,7 +39,7 @@ export default { <label id="label" class="gl-font-weight-bold gl-mr-4 gl-mb-0">{{ $options.i18n.database }}</label> - <gl-listbox + <gl-collapsible-listbox v-model="selected" :items="databases" right diff --git a/app/assets/javascripts/admin/broadcast_messages/components/base.vue b/app/assets/javascripts/admin/broadcast_messages/components/base.vue index b7bafe46327..f869d21d55f 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/base.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/base.vue @@ -5,14 +5,18 @@ import { buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; import { createAlert, VARIANT_DANGER } from '~/flash'; import { s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import { NEW_BROADCAST_MESSAGE } from '../constants'; +import MessageForm from './message_form.vue'; import MessagesTable from './messages_table.vue'; const PER_PAGE = 20; export default { name: 'BroadcastMessagesBase', + NEW_BROADCAST_MESSAGE, components: { GlPagination, + MessageForm, MessagesTable, }, @@ -97,6 +101,7 @@ export default { <template> <div> + <message-form :broadcast-message="$options.NEW_BROADCAST_MESSAGE" /> <messages-table v-if="hasVisibleMessages" :messages="visibleMessages" diff --git a/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue b/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue new file mode 100644 index 00000000000..07814ef2511 --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/components/datetime_picker.vue @@ -0,0 +1,47 @@ +<script> +import { GlDatepicker, GlFormInput } from '@gitlab/ui'; +import { dateToTimeInputValue, timeToHoursMinutes } from '~/lib/utils/datetime/date_format_utility'; + +export default { + name: 'DatetimePicker', + components: { + GlDatepicker, + GlFormInput, + }, + props: { + value: { + type: Date, + required: true, + }, + }, + computed: { + date: { + get() { + return this.value; + }, + set(val) { + const dup = new Date(this.value.getTime()); + dup.setFullYear(val.getFullYear(), val.getMonth(), val.getDate()); + this.$emit('input', dup); + }, + }, + time: { + get() { + return dateToTimeInputValue(this.value); + }, + set(val) { + const dup = new Date(this.value.getTime()); + const { hours, minutes } = timeToHoursMinutes(val); + dup.setHours(hours, minutes); + this.$emit('input', dup); + }, + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-gap-3 gl-align-items-center"> + <gl-datepicker v-model="date" /> + <gl-form-input v-model="time" size="sm" type="time" data-testid="time-picker" /> + </div> +</template> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue new file mode 100644 index 00000000000..36796708e78 --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue @@ -0,0 +1,225 @@ +<script> +import { + GlButton, + GlBroadcastMessage, + GlForm, + GlFormCheckbox, + GlFormCheckboxGroup, + GlFormInput, + GlFormSelect, + GlFormText, + GlFormTextarea, +} from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; +import { createAlert, VARIANT_DANGER } from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { BROADCAST_MESSAGES_PATH, THEMES, TYPES, TYPE_BANNER } from '../constants'; +import MessageFormGroup from './message_form_group.vue'; +import DatetimePicker from './datetime_picker.vue'; + +const FORM_HEADERS = { headers: { 'Content-Type': 'application/json; charset=utf-8' } }; + +export default { + name: 'MessageForm', + components: { + DatetimePicker, + GlButton, + GlBroadcastMessage, + GlForm, + GlFormCheckbox, + GlFormCheckboxGroup, + GlFormInput, + GlFormSelect, + GlFormText, + GlFormTextarea, + MessageFormGroup, + }, + mixins: [glFeatureFlagsMixin()], + inject: ['targetAccessLevelOptions'], + i18n: { + message: s__('BroadcastMessages|Message'), + messagePlaceholder: s__('BroadcastMessages|Your message here'), + type: s__('BroadcastMessages|Type'), + theme: s__('BroadcastMessages|Theme'), + dismissable: s__('BroadcastMessages|Dismissable'), + dismissableDescription: s__('BroadcastMessages|Allow users to dismiss the broadcast message'), + targetRoles: s__('BroadcastMessages|Target roles'), + targetRolesDescription: s__( + 'BroadcastMessages|The broadcast message displays only to users in projects and groups who have these roles.', + ), + targetPath: s__('BroadcastMessages|Target Path'), + targetPathDescription: s__('BroadcastMessages|Paths can contain wildcards, like */welcome'), + startsAt: s__('BroadcastMessages|Starts at'), + endsAt: s__('BroadcastMessages|Ends at'), + add: s__('BroadcastMessages|Add broadcast message'), + addError: s__('BroadcastMessages|There was an error adding broadcast message.'), + update: s__('BroadcastMessages|Update broadcast message'), + updateError: s__('BroadcastMessages|There was an error updating broadcast message.'), + }, + messageThemes: THEMES, + messageTypes: TYPES, + props: { + broadcastMessage: { + type: Object, + required: true, + }, + }, + data() { + return { + loading: false, + message: this.broadcastMessage.message, + type: this.broadcastMessage.broadcastType, + theme: this.broadcastMessage.theme, + dismissable: this.broadcastMessage.dismissable || false, + targetPath: this.broadcastMessage.targetPath, + targetAccessLevels: this.broadcastMessage.targetAccessLevels, + targetAccessLevelOptions: this.targetAccessLevelOptions.map(([text, value]) => ({ + text, + value, + })), + startsAt: new Date(this.broadcastMessage.startsAt.getTime()), + endsAt: new Date(this.broadcastMessage.endsAt.getTime()), + }; + }, + computed: { + isBanner() { + return this.type === TYPE_BANNER; + }, + messageBlank() { + return this.message.trim() === ''; + }, + messagePreview() { + return this.messageBlank ? this.$options.i18n.messagePlaceholder : this.message; + }, + isAddForm() { + return !this.broadcastMessage.id; + }, + formPath() { + return this.isAddForm + ? BROADCAST_MESSAGES_PATH + : `${BROADCAST_MESSAGES_PATH}/${this.broadcastMessage.id}`; + }, + formPayload() { + return JSON.stringify({ + message: this.message, + broadcast_type: this.type, + theme: this.theme, + dismissable: this.dismissable, + target_path: this.targetPath, + target_access_levels: this.targetAccessLevels, + starts_at: this.startsAt.toISOString(), + ends_at: this.endsAt.toISOString(), + }); + }, + }, + methods: { + async onSubmit() { + this.loading = true; + + const success = await this.submitForm(); + if (success) { + redirectTo(BROADCAST_MESSAGES_PATH); + } else { + this.loading = false; + } + }, + + async submitForm() { + const requestMethod = this.isAddForm ? 'post' : 'patch'; + + try { + await axios[requestMethod](this.formPath, this.formPayload, FORM_HEADERS); + } catch (e) { + const message = this.isAddForm + ? this.$options.i18n.addError + : this.$options.i18n.updateError; + createAlert({ message, variant: VARIANT_DANGER }); + return false; + } + return true; + }, + }, +}; +</script> +<template> + <gl-form @submit.prevent="onSubmit"> + <gl-broadcast-message class="gl-my-6" :type="type" :theme="theme" :dismissible="dismissable"> + {{ messagePreview }} + </gl-broadcast-message> + + <message-form-group :label="$options.i18n.message" label-for="message-textarea"> + <gl-form-textarea + id="message-textarea" + v-model="message" + size="sm" + :placeholder="$options.i18n.messagePlaceholder" + /> + </message-form-group> + + <message-form-group :label="$options.i18n.type" label-for="type-select"> + <gl-form-select id="type-select" v-model="type" :options="$options.messageTypes" /> + </message-form-group> + + <template v-if="isBanner"> + <message-form-group :label="$options.i18n.theme" label-for="theme-select"> + <gl-form-select + id="theme-select" + v-model="theme" + :options="$options.messageThemes" + data-testid="theme-select" + /> + </message-form-group> + + <message-form-group :label="$options.i18n.dismissable" label-for="dismissable-checkbox"> + <gl-form-checkbox + id="dismissable-checkbox" + v-model="dismissable" + class="gl-mt-3" + data-testid="dismissable-checkbox" + > + <span>{{ $options.i18n.dismissableDescription }}</span> + </gl-form-checkbox> + </message-form-group> + </template> + + <message-form-group + v-if="glFeatures.roleTargetedBroadcastMessages" + :label="$options.i18n.targetRoles" + data-testid="target-roles-checkboxes" + > + <gl-form-checkbox-group v-model="targetAccessLevels" :options="targetAccessLevelOptions" /> + <gl-form-text> + {{ $options.i18n.targetRolesDescription }} + </gl-form-text> + </message-form-group> + + <message-form-group :label="$options.i18n.targetPath" label-for="target-path-input"> + <gl-form-input id="target-path-input" v-model="targetPath" /> + <gl-form-text> + {{ $options.i18n.targetPathDescription }} + </gl-form-text> + </message-form-group> + + <message-form-group :label="$options.i18n.startsAt"> + <datetime-picker v-model="startsAt" /> + </message-form-group> + + <message-form-group :label="$options.i18n.endsAt"> + <datetime-picker v-model="endsAt" /> + </message-form-group> + + <div class="form-actions gl-mb-3"> + <gl-button + type="submit" + variant="confirm" + :loading="loading" + :disabled="messageBlank" + data-testid="submit-button" + > + {{ isAddForm ? $options.i18n.add : $options.i18n.update }} + </gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue new file mode 100644 index 00000000000..eec51c0c28b --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form_group.vue @@ -0,0 +1,34 @@ +<script> +import { GlFormGroup } from '@gitlab/ui'; + +export default { + name: 'MessageFormGroup', + components: { + GlFormGroup, + }, + props: { + label: { + type: String, + required: true, + }, + labelFor: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> +<template> + <div> + <gl-form-group + :label="label" + :label-for="labelFor" + label-cols-sm="2" + label-class="gl-mt-3" + label-align-sm="right" + > + <slot></slot> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue index 1408312d3e4..a523dd3b391 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/messages_table.vue @@ -1,6 +1,8 @@ <script> -import { GlButton, GlTableLite, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton, GlTableLite } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; +import { formatDate } from '~/lib/utils/datetime/date_format_utility'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const DEFAULT_TD_CLASSES = 'gl-vertical-align-middle!'; @@ -12,7 +14,7 @@ export default { GlTableLite, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], i18n: { @@ -77,6 +79,11 @@ export default { safeHtmlConfig: { ADD_TAGS: ['use'], }, + methods: { + formatDate(dateString) { + return formatDate(new Date(dateString)); + }, + }, }; </script> <template> @@ -90,6 +97,14 @@ export default { <div v-safe-html:[$options.safeHtmlConfig]="preview"></div> </template> + <template #cell(starts_at)="{ item: { starts_at } }"> + {{ formatDate(starts_at) }} + </template> + + <template #cell(ends_at)="{ item: { ends_at } }"> + {{ formatDate(ends_at) }} + </template> + <template #cell(buttons)="{ item: { id, edit_path, disable_delete } }"> <gl-button icon="pencil" diff --git a/app/assets/javascripts/admin/broadcast_messages/constants.js b/app/assets/javascripts/admin/broadcast_messages/constants.js new file mode 100644 index 00000000000..6250d5a943d --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/constants.js @@ -0,0 +1,35 @@ +import { s__ } from '~/locale'; + +export const BROADCAST_MESSAGES_PATH = '/admin/broadcast_messages'; + +export const TYPE_BANNER = 'banner'; +export const TYPE_NOTIFICATION = 'notification'; + +export const TYPES = [ + { value: TYPE_BANNER, text: s__('BroadcastMessages|Banner') }, + { value: TYPE_NOTIFICATION, text: s__('BroadcastMessages|Notification') }, +]; + +export const THEMES = [ + { value: 'indigo', text: s__('BroadcastMessages|Indigo') }, + { value: 'light-indigo', text: s__('BroadcastMessages|Light Indigo') }, + { value: 'blue', text: s__('BroadcastMessages|Blue') }, + { value: 'light-blue', text: s__('BroadcastMessages|Light Blue') }, + { value: 'green', text: s__('BroadcastMessages|Green') }, + { value: 'light-green', text: s__('BroadcastMessages|Light Green') }, + { value: 'red', text: s__('BroadcastMessages|Red') }, + { value: 'light-red', text: s__('BroadcastMessages|Light Red') }, + { value: 'dark', text: s__('BroadcastMessages|Dark') }, + { value: 'light', text: s__('BroadcastMessages|Light') }, +]; + +export const NEW_BROADCAST_MESSAGE = { + message: '', + broadcastType: TYPES[0].value, + theme: THEMES[0].value, + dismissable: false, + targetPath: '', + targetAccessLevels: [], + startsAt: new Date(), + endsAt: new Date(), +}; diff --git a/app/assets/javascripts/admin/broadcast_messages/edit.js b/app/assets/javascripts/admin/broadcast_messages/edit.js new file mode 100644 index 00000000000..70a270f7a56 --- /dev/null +++ b/app/assets/javascripts/admin/broadcast_messages/edit.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import MessageForm from './components/message_form.vue'; + +export default () => { + const el = document.querySelector('#js-broadcast-message'); + const { + id, + message, + broadcastType, + theme, + dismissable, + targetAccessLevels, + targetAccessLevelOptions, + targetPath, + startsAt, + endsAt, + } = el.dataset; + + return new Vue({ + el, + name: 'EditBroadcastMessage', + provide: { + targetAccessLevelOptions: JSON.parse(targetAccessLevelOptions), + }, + render(createElement) { + return createElement(MessageForm, { + props: { + broadcastMessage: { + id: parseInt(id, 10), + message, + broadcastType, + theme, + dismissable: dismissable === 'true', + targetAccessLevels: JSON.parse(targetAccessLevels), + targetPath, + startsAt: new Date(startsAt), + endsAt: new Date(endsAt), + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/admin/broadcast_messages/index.js b/app/assets/javascripts/admin/broadcast_messages/index.js index 81952d2033e..fd8b2aad4ec 100644 --- a/app/assets/javascripts/admin/broadcast_messages/index.js +++ b/app/assets/javascripts/admin/broadcast_messages/index.js @@ -3,11 +3,14 @@ import BroadcastMessagesBase from './components/base.vue'; export default () => { const el = document.querySelector('#js-broadcast-messages'); - const { page, messagesCount, messages } = el.dataset; + const { page, targetAccessLevelOptions, messagesCount, messages } = el.dataset; return new Vue({ el, name: 'BroadcastMessages', + provide: { + targetAccessLevelOptions: JSON.parse(targetAccessLevelOptions), + }, render(createElement) { return createElement(BroadcastMessagesBase, { props: { diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index c0cac958a42..5229d4c9ae2 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -17,6 +17,7 @@ import { fetchPolicies } from '~/lib/graphql'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; +import { TOKEN_TYPE_ASSIGNEE } from '~/vue_shared/components/filtered_search_bar/constants'; import { tdClass, thClass, @@ -96,6 +97,7 @@ export default { sortable: true, }, ], + filterSearchTokens: [TOKEN_TYPE_ASSIGNEE], severityLabels: SEVERITY_LEVELS, statusTabs: ALERTS_STATUS_TABS, components: { @@ -294,9 +296,7 @@ export default { :status-tabs="$options.statusTabs" :track-views-options="$options.trackAlertListViewsOptions" :server-error-message="serverErrorMessage" - :filter-search-tokens="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ - 'assignee_username', - ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :filter-search-tokens="$options.filterSearchTokens" filter-search-key="alerts" @page-changed="pageChanged" @tabs-changed="statusChanged" @@ -312,6 +312,7 @@ export default { <template #table> <gl-table class="alert-management-table" + data-qa-selector="alert_table_container" :items=" alerts ? alerts.list diff --git a/app/assets/javascripts/alerts_settings/components/alerts_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_form.vue index 388d925196b..a0d5cb7f4c3 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_form.vue @@ -83,7 +83,7 @@ export default { </p> <form ref="settingsForm" @submit.prevent="updateAlertsIntegrationSettings"> <gl-form-group class="gl-pl-0"> - <gl-form-checkbox v-model="createIssueEnabled" data-qa-selector="create_issue_checkbox"> + <gl-form-checkbox v-model="createIssueEnabled" data-qa-selector="create_incident_checkbox"> <span>{{ $options.i18n.createIncident.label }}</span> </gl-form-checkbox> </gl-form-group> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 03bc4b825ae..65c3bc732ed 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -430,6 +430,7 @@ export default { v-model="integrationForm.type" :disabled="isSelectDisabled" class="gl-max-w-full" + data-qa-selector="integration_type_dropdown" :options="integrationTypesOptions" /> @@ -461,6 +462,7 @@ export default { v-model="integrationForm.name" type="text" :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder" + data-qa-selector="integration_name_field" @input="validateName" /> </gl-form-group> @@ -483,6 +485,7 @@ export default { v-model="integrationForm.active" :is-loading="loading" :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" + data-qa-selector="active_toggle_container" class="gl-mt-4 gl-font-weight-normal" /> </gl-form-group> @@ -594,6 +597,7 @@ export default { category="secondary" class="gl-ml-3 js-no-auto-disable" data-testid="integration-form-test-and-submit" + data-qa-selector="save_and_create_alert_button" @click="submit(true)" > {{ $options.i18n.saveAndTestIntegration }} @@ -695,6 +699,7 @@ export default { :debounce="$options.JSON_VALIDATE_DELAY" rows="6" max-rows="10" + data-qa-selector="test_payload_field" @input="validateJson(false)" /> </gl-form-group> @@ -706,6 +711,7 @@ export default { data-testid="send-test-alert" variant="confirm" class="js-no-auto-disable" + data-qa-selector="send_test_alert_button" @click="isFormDirty ? null : sendTestAlert()" > {{ $options.i18n.send }} diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index bf456b6adaa..010cb5721a1 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -375,6 +375,7 @@ export default { category="secondary" variant="confirm" data-testid="add-integration-btn" + data-qa-selector="add_integration_button" class="gl-mt-3" @click="setFormVisibility(true)" > diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue index f06544f50c6..a688e2f497b 100644 --- a/app/assets/javascripts/cycle_analytics/components/base.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/base.vue @@ -5,9 +5,9 @@ import { getCookie, setCookie } from '~/lib/utils/common_utils'; import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue'; import { VSA_METRICS_GROUPS } from '~/analytics/shared/constants'; import { toYmd } from '~/analytics/shared/utils'; -import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; -import StageTable from '~/cycle_analytics/components/stage_table.vue'; -import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; +import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue'; +import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue'; +import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; import { __ } from '~/locale'; import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants'; diff --git a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue index 0ad325a8523..54b632968e2 100644 --- a/app/assets/javascripts/cycle_analytics/components/filter_bar.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/filter_bar.vue @@ -1,12 +1,16 @@ <script> import { mapActions, mapState } from 'vuex'; import { - OPERATOR_IS_ONLY, - DEFAULT_NONE_ANY, + OPERATORS_IS, + OPTIONS_NONE_ANY, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_LABEL, TOKEN_TITLE_MILESTONE, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { @@ -14,7 +18,7 @@ import { processFilters, filterToQueryObject, } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; @@ -47,45 +51,45 @@ export default { { icon: 'clock', title: TOKEN_TITLE_MILESTONE, - type: 'milestone', + type: TOKEN_TYPE_MILESTONE, token: MilestoneToken, initialMilestones: this.milestonesData, unique: true, symbol: '%', - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, fetchMilestones: this.fetchMilestones, }, { icon: 'labels', title: TOKEN_TITLE_LABEL, - type: 'labels', + type: TOKEN_TYPE_LABEL, token: LabelToken, - defaultLabels: DEFAULT_NONE_ANY, + defaultLabels: OPTIONS_NONE_ANY, initialLabels: this.labelsData, unique: false, symbol: '~', - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, fetchLabels: this.fetchLabels, }, { icon: 'pencil', title: TOKEN_TITLE_AUTHOR, - type: 'author', - token: AuthorToken, - initialAuthors: this.authorsData, + type: TOKEN_TYPE_AUTHOR, + token: UserToken, + initialUsers: this.authorsData, unique: true, - operators: OPERATOR_IS_ONLY, - fetchAuthors: this.fetchAuthors, + operators: OPERATORS_IS, + fetchUsers: this.fetchAuthors, }, { icon: 'user', title: TOKEN_TITLE_ASSIGNEE, - type: 'assignees', - token: AuthorToken, - initialAuthors: this.assigneesData, + type: TOKEN_TYPE_ASSIGNEE, + token: UserToken, + initialUsers: this.assigneesData, unique: false, - operators: OPERATOR_IS_ONLY, - fetchAuthors: this.fetchAssignees, + operators: OPERATORS_IS, + fetchUsers: this.fetchAssignees, }, ]; }, @@ -108,14 +112,19 @@ export default { ]), initialFilterValue() { return prepareTokens({ - milestone: this.selectedMilestone, - author: this.selectedAuthor, - assignees: this.selectedAssigneeList, - labels: this.selectedLabelList, + [TOKEN_TYPE_MILESTONE]: this.selectedMilestone, + [TOKEN_TYPE_AUTHOR]: this.selectedAuthor, + [TOKEN_TYPE_ASSIGNEE]: this.selectedAssigneeList, + [TOKEN_TYPE_LABEL]: this.selectedLabelList, }); }, handleFilter(filters) { - const { labels, milestone, author, assignees } = processFilters(filters); + const { + [TOKEN_TYPE_LABEL]: labels, + [TOKEN_TYPE_MILESTONE]: milestone, + [TOKEN_TYPE_AUTHOR]: author, + [TOKEN_TYPE_ASSIGNEE]: assignees, + } = processFilters(filters); this.setFilters({ selectedAuthor: author ? author[0] : null, diff --git a/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue b/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue index b622b0441e2..b622b0441e2 100644 --- a/app/assets/javascripts/cycle_analytics/components/formatted_stage_count.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/formatted_stage_count.vue diff --git a/app/assets/javascripts/cycle_analytics/components/metric_tile.vue b/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue index a5c20b237b3..a5c20b237b3 100644 --- a/app/assets/javascripts/cycle_analytics/components/metric_tile.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/metric_tile.vue diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue index 72a7659aac0..ac41bc4917c 100644 --- a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/path_navigation.vue @@ -1,5 +1,6 @@ <script> -import { GlPath, GlPopover, GlSkeletonLoader, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlPath, GlPopover, GlSkeletonLoader } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; import { OVERVIEW_STAGE_ID } from '../constants'; import FormattedStageCount from './formatted_stage_count.vue'; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue index f1fdffd4b72..78ac29426d9 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/stage_table.vue @@ -8,7 +8,7 @@ import { GlTable, GlBadge, } from '@gitlab/ui'; -import FormattedStageCount from '~/cycle_analytics/components/formatted_stage_count.vue'; +import FormattedStageCount from '~/analytics/cycle_analytics/components/formatted_stage_count.vue'; import { __ } from '~/locale'; import Tracking from '~/tracking'; import { diff --git a/app/assets/javascripts/cycle_analytics/components/total_time.vue b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue index 725952c3518..725952c3518 100644 --- a/app/assets/javascripts/cycle_analytics/components/total_time.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/total_time.vue diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue index 17decb6b448..17decb6b448 100644 --- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue +++ b/app/assets/javascripts/analytics/cycle_analytics/components/value_stream_filters.vue diff --git a/app/assets/javascripts/cycle_analytics/constants.js b/app/assets/javascripts/analytics/cycle_analytics/constants.js index 2758d686fb1..2758d686fb1 100644 --- a/app/assets/javascripts/cycle_analytics/constants.js +++ b/app/assets/javascripts/analytics/cycle_analytics/constants.js diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/analytics/cycle_analytics/index.js index 3da8696edeb..df161f7e563 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/analytics/cycle_analytics/index.js @@ -3,7 +3,7 @@ import { extractFilterQueryParameters, extractPaginationQueryParameters, } from '~/analytics/shared/utils'; -import Translate from '../vue_shared/translate'; +import Translate from '~/vue_shared/translate'; import CycleAnalytics from './components/base.vue'; import createStore from './store'; import { buildCycleAnalyticsInitialData } from './utils'; diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js index 4a201e00582..4a201e00582 100644 --- a/app/assets/javascripts/cycle_analytics/store/actions.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/actions.js diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js index 83068cabf0f..83068cabf0f 100644 --- a/app/assets/javascripts/cycle_analytics/store/getters.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/getters.js diff --git a/app/assets/javascripts/cycle_analytics/store/index.js b/app/assets/javascripts/analytics/cycle_analytics/store/index.js index 76e3e835016..76e3e835016 100644 --- a/app/assets/javascripts/cycle_analytics/store/index.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/index.js diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js index 9376d81f317..9376d81f317 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutation_types.js diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js index 8567529caf2..8567529caf2 100644 --- a/app/assets/javascripts/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/analytics/cycle_analytics/store/state.js index 8d662333afa..00dd2e53883 100644 --- a/app/assets/javascripts/cycle_analytics/store/state.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/state.js @@ -1,7 +1,7 @@ import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC, -} from '~/cycle_analytics/constants'; +} from '~/analytics/cycle_analytics/constants'; export default () => ({ id: null, diff --git a/app/assets/javascripts/cycle_analytics/utils.js b/app/assets/javascripts/analytics/cycle_analytics/utils.js index 428bb11b950..428bb11b950 100644 --- a/app/assets/javascripts/cycle_analytics/utils.js +++ b/app/assets/javascripts/analytics/cycle_analytics/utils.js diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js index 15457f28eff..66ed30130bb 100644 --- a/app/assets/javascripts/api/analytics_api.js +++ b/app/assets/javascripts/api/analytics_api.js @@ -7,6 +7,11 @@ const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics const PROJECT_VSA_STAGES_PATH = `${PROJECT_VSA_PATH_BASE}/:value_stream_id/stages`; const PROJECT_VSA_STAGE_DATA_PATH = `${PROJECT_VSA_STAGES_PATH}/:stage_id`; +export const LEAD_TIME_METRIC_TYPE = 'lead_time'; +export const CYCLE_TIME_METRIC_TYPE = 'cycle_time'; +export const ISSUES_METRIC_TYPE = 'issues'; +export const DEPLOYS_METRIC_TYPE = 'deploys'; + export const METRIC_TYPE_SUMMARY = 'summary'; export const METRIC_TYPE_TIME_SUMMARY = 'time_summary'; diff --git a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql index 89a24d7891e..9777153999e 100644 --- a/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql +++ b/app/assets/javascripts/artifacts/graphql/queries/get_job_artifacts.query.graphql @@ -10,6 +10,7 @@ query getJobArtifacts( project(fullPath: $projectPath) { id jobs( + withArtifacts: true statuses: [SUCCESS, FAILED] first: $firstPageSize last: $lastPageSize diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 9ab1d6bfd80..1855fb9ed8c 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -2,7 +2,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import { uniq } from 'lodash'; +import { uniq, escape } from 'lodash'; import { getEmojiScoreWithIntent } from '~/emoji/utils'; import { getCookie, setCookie, scrollToElement } from '~/lib/utils/common_utils'; import * as Emoji from '~/emoji'; @@ -149,7 +149,7 @@ export class AwardsHandler { let frequentlyUsedCatgegory = ''; if (frequentlyUsedEmojis.length > 0) { frequentlyUsedCatgegory = this.renderCategory('Frequently used', frequentlyUsedEmojis, { - menuListClass: 'frequent-emojis', + frequentEmojis: true, }); } @@ -228,9 +228,9 @@ export class AwardsHandler { renderCategory(name, emojiList, opts = {}) { return ` <h5 class="emoji-menu-title"> - ${name} + ${escape(name)} </h5> - <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}"> + <ul class="clearfix emoji-menu-list ${opts.frequentEmojis ? 'frequent-emojis' : ''}"> ${emojiList .map( (emojiName) => ` diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index f68666f8a0c..c95c90d5daf 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -1,10 +1,12 @@ <script> -import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; import { escape, debounce } from 'lodash'; import { mapActions, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert, VARIANT_INFO } from '~/flash'; import { s__, sprintf } from '~/locale'; import createEmptyBadge from '../empty_badge'; +import { PLACEHOLDERS } from '../constants'; import Badge from './badge.vue'; const badgePreviewDelayInMilliseconds = 1500; @@ -19,7 +21,7 @@ export default { GlFormGroup, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { isEditing: { @@ -49,9 +51,9 @@ export default { return this.badgeInAddForm; }, helpText() { - const placeholders = ['project_path', 'project_id', 'default_branch', 'commit_sha'] - .map((placeholder) => `<code>%{${placeholder}}</code>`) - .join(', '); + const placeholders = PLACEHOLDERS.map((placeholder) => `<code>%{${placeholder}}</code>`).join( + ', ', + ); return sprintf( s__('Badges|Supported %{docsLinkStart}variables%{docsLinkEnd}: %{placeholders}'), { diff --git a/app/assets/javascripts/badges/constants.js b/app/assets/javascripts/badges/constants.js index 8fbe3db5ef1..709436abca6 100644 --- a/app/assets/javascripts/badges/constants.js +++ b/app/assets/javascripts/badges/constants.js @@ -1,2 +1,10 @@ export const GROUP_BADGE = 'group'; export const PROJECT_BADGE = 'project'; +export const PLACEHOLDERS = [ + 'project_path', + 'project_title', + 'project_name', + 'project_id', + 'default_branch', + 'commit_sha', +]; diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index e5408d0734a..5bb310afac7 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -1,6 +1,7 @@ <script> -import { GlButton, GlSafeHtmlDirective, GlBadge } from '@gitlab/ui'; +import { GlButton, GlBadge } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NoteableNote from '~/notes/components/noteable_note.vue'; import PublishButton from './publish_button.vue'; @@ -13,7 +14,7 @@ export default { GlBadge, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagMixin()], props: { @@ -84,32 +85,25 @@ export default { }; </script> <template> - <article - class="draft-note-component note-wrapper" - @mouseenter="handleMouseEnter(draft)" - @mouseleave="handleMouseLeave(draft)" + <noteable-note + :note="draft" + :line="line" + :discussion-root="true" + :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }" + class="draft-note-component draft-note" + @handleEdit="handleEditing" + @cancelForm="handleNotEditing" + @updateSuccess="handleNotEditing" + @handleDeleteNote="deleteDraft" + @handleUpdateNote="update" + @toggleResolveStatus="toggleResolveDiscussion(draft.id)" + @mouseenter.native="handleMouseEnter(draft)" + @mouseleave.native="handleMouseLeave(draft)" > - <ul class="notes draft-notes"> - <noteable-note - :note="draft" - :line="line" - :discussion-root="true" - :class="{ 'gl-mb-0!': glFeatures.mrReviewSubmitComment }" - class="draft-note" - @handleEdit="handleEditing" - @cancelForm="handleNotEditing" - @updateSuccess="handleNotEditing" - @handleDeleteNote="deleteDraft" - @handleUpdateNote="update" - @toggleResolveStatus="toggleResolveDiscussion(draft.id)" - > - <template #note-header-info> - <gl-badge variant="warning" class="gl-mr-2">{{ __('Pending') }}</gl-badge> - </template> - </noteable-note> - </ul> - - <template v-if="!isEditingDraft"> + <template #note-header-info> + <gl-badge variant="warning" class="gl-mr-2">{{ __('Pending') }}</gl-badge> + </template> + <template v-if="!isEditingDraft" #after-note-body> <div v-if="draftCommands" v-safe-html:[$options.safeHtmlConfig]="draftCommands" @@ -133,5 +127,5 @@ export default { </gl-button> </p> </template> - </article> + </noteable-note> </template> diff --git a/app/assets/javascripts/behaviors/copy_code.js b/app/assets/javascripts/behaviors/copy_code.js index ae186aba32d..0c81ae63f21 100644 --- a/app/assets/javascripts/behaviors/copy_code.js +++ b/app/assets/javascripts/behaviors/copy_code.js @@ -7,7 +7,10 @@ class CopyCodeButton extends HTMLElement { connectedCallback() { this.for = uniqueId('code-'); - this.parentNode.querySelector('pre').setAttribute('id', this.for); + const target = this.parentNode.querySelector('pre'); + if (!target) return; + + target.setAttribute('id', this.for); this.appendChild(this.createButton()); } diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 30160248a77..220064e6673 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import './autosize'; -import './markdown/render_gfm'; import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize'; import initCopyToClipboard from './copy_to_clipboard'; import installGlEmojiElement from './gl_emoji'; diff --git a/app/assets/javascripts/behaviors/markdown/init_gfm.js b/app/assets/javascripts/behaviors/markdown/init_gfm.js new file mode 100644 index 00000000000..d9c7cee50da --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/init_gfm.js @@ -0,0 +1,13 @@ +import $ from 'jquery'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +$.fn.renderGFM = function plugin() { + this.get().forEach(renderGFM); + return this; +}; +requestIdleCallback( + () => { + renderGFM(document.body); + }, + { timeout: 500 }, +); diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index a08cf48c327..2eab5b84e3e 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -1,45 +1,52 @@ -import $ from 'jquery'; import syntaxHighlight from '~/syntax_highlight'; import highlightCurrentUser from './highlight_current_user'; import { renderKroki } from './render_kroki'; import renderMath from './render_math'; import renderSandboxedMermaid from './render_sandboxed_mermaid'; import renderMetrics from './render_metrics'; +import renderObservability from './render_observability'; import { renderJSONTable } from './render_json_table'; -// Render GitLab flavoured Markdown -// -// Delegates to syntax highlight and render math & mermaid diagrams. -// -$.fn.renderGFM = function renderGFM() { - syntaxHighlight(this.find('.js-syntax-highlight').get()); - renderKroki(this.find('.js-render-kroki[hidden]').get()); - renderMath(this.find('.js-render-math')); - renderSandboxedMermaid(this.find('.js-render-mermaid').get()); - renderJSONTable( - Array.from(this.find('[lang="json"][data-lang-params="table"]').get()).map((e) => e.parentNode), - ); - - highlightCurrentUser(this.find('.gfm-project_member').get()); +function initPopovers(elements) { + if (!elements.length) return; + import(/* webpackChunkName: 'IssuablePopoverBundle' */ '~/issuable/popover') + .then(({ default: initIssuablePopovers }) => { + initIssuablePopovers(elements); + }) + .catch(() => {}); +} - const issuablePopoverElements = this.find('.gfm-issue, .gfm-merge_request').get(); - if (issuablePopoverElements.length) { - import(/* webpackChunkName: 'IssuablePopoverBundle' */ '~/issuable/popover') - .then(({ default: initIssuablePopovers }) => { - initIssuablePopovers(issuablePopoverElements); - }) - .catch(() => {}); - } - - renderMetrics(this.find('.js-render-metrics').get()); - return this; -}; +// Render GitLab flavoured Markdown +export function renderGFM(element) { + const [ + highlightEls, + krokiEls, + mathEls, + mermaidEls, + tableEls, + userEls, + popoverEls, + metricsEls, + observabilityEls, + ] = [ + '.js-syntax-highlight', + '.js-render-kroki[hidden]', + '.js-render-math', + '.js-render-mermaid', + '[lang="json"][data-lang-params="table"]', + '.gfm-project_member', + '.gfm-issue, .gfm-merge_request', + '.js-render-metrics', + '.js-render-observability', + ].map((selector) => Array.from(element.querySelectorAll(selector))); -$(() => { - window.requestIdleCallback( - () => { - $('body').renderGFM(); - }, - { timeout: 500 }, - ); -}); + syntaxHighlight(highlightEls); + renderKroki(krokiEls); + renderMath(mathEls); + renderSandboxedMermaid(mermaidEls); + renderJSONTable(tableEls.map((e) => e.parentNode)); + highlightCurrentUser(userEls); + renderMetrics(metricsEls); + renderObservability(observabilityEls); + initPopovers(popoverEls); +} diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index ac41af4df7a..7852a909160 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -175,14 +175,14 @@ class SafeMathRenderer { } } -export default function renderMath($els) { - if (!$els.length) return; +export default function renderMath(elements) { + if (!elements.length) return; Promise.all([ import(/* webpackChunkName: 'katex' */ 'katex'), import(/* webpackChunkName: 'katex' */ 'katex/dist/katex.min.css'), ]) .then(([katex]) => { - const renderer = new SafeMathRenderer($els.get(), katex); + const renderer = new SafeMathRenderer(elements, katex); renderer.render(); renderer.attachEvents(); }) diff --git a/app/assets/javascripts/behaviors/markdown/render_observability.js b/app/assets/javascripts/behaviors/markdown/render_observability.js new file mode 100644 index 00000000000..704d85cf22e --- /dev/null +++ b/app/assets/javascripts/behaviors/markdown/render_observability.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import { darkModeEnabled } from '~/lib/utils/color_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; + +export function getFrameSrc(url) { + return `${setUrlParams({ theme: darkModeEnabled() ? 'dark' : 'light' }, url)}&kiosk`; +} + +const mountVueComponent = (element) => { + const url = [element.dataset.frameUrl]; + + return new Vue({ + el: element, + render(h) { + return h('iframe', { + style: { + height: '366px', + width: '768px', + }, + attrs: { + src: getFrameSrc(url), + frameBorder: '0', + }, + }); + }, + }); +}; + +export default function renderObservability(elements) { + elements.forEach((element) => { + mountVueComponent(element); + }); +} diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index 68f5180cc03..86a05f24dfc 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; +import '~/behaviors/markdown/init_gfm'; // MarkdownPreview // diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 97ba9e15c0f..64297da39cd 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -3,7 +3,7 @@ import ClipboardJS from 'clipboard'; import Mousetrap from 'mousetrap'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { isElementVisible } from '~/lib/utils/dom_utils'; -import { DEBOUNCE_DROPDOWN_DELAY } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import { DEBOUNCE_DROPDOWN_DELAY } from '~/sidebar/components/labels/labels_select_widget/constants'; import toast from '~/vue_shared/plugins/global_toast'; import { s__ } from '~/locale'; import Sidebar from '~/right_sidebar'; diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 716321430d2..361d736f740 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -1,5 +1,5 @@ <script> -import DefaultActions from './blob_header_default_actions.vue'; +import DefaultActions from 'jh_else_ce/blob/components/blob_header_default_actions.vue'; import BlobFilepath from './blob_header_filepath.vue'; import ViewerSwitcher from './blob_header_viewer_switcher.vue'; import { SIMPLE_BLOB_VIEWER } from './constants'; diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index 24a54358de5..8cfdc00bb40 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -5,7 +5,7 @@ const createSandbox = () => { const iframeEl = document.createElement('iframe'); setAttributes(iframeEl, { src: '/-/sandbox/swagger', - sandbox: 'allow-scripts allow-popups', + sandbox: 'allow-scripts allow-popups allow-forms', frameBorder: 0, width: '100%', // The height will be adjusted dynamically. diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 8d323c335d3..439c4258805 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import '~/behaviors/markdown/init_gfm'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import { diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 4741dd53708..509d399273d 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -66,7 +66,7 @@ export default () => { }) .catch((e) => createAlert({ - message: e, + message: e.message, }), ); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 97d8b206307..46b3f16df77 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -9,6 +9,7 @@ import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; import { insertFinalNewline } from '~/lib/utils/text_utility'; import TemplateSelectorMediator from '../blob/file_template_mediator'; import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; +import '~/behaviors/markdown/init_gfm'; export default class EditBlob { // The options object has: diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 150378f7a7d..ca86894ca40 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,8 +1,10 @@ <script> import { GlAlert } from '@gitlab/ui'; +import { breakpoints } from '@gitlab/ui/dist/utils'; import { sortBy, throttle } from 'lodash'; import Draggable from 'vuedraggable'; import { mapState, mapGetters, mapActions } from 'vuex'; +import { contentTop } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { formatBoardLists } from 'ee_else_ce/boards/boards_util'; import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; @@ -114,6 +116,8 @@ export default { group: 'boards-list', tag: 'div', value: this.boardListsToUse, + delay: 100, + delayOnTouchOnly: true, }; return this.canDragColumns ? options : {}; @@ -142,7 +146,11 @@ export default { el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); }, setBoardHeight() { - this.boardHeight = `${window.innerHeight - this.$el.getBoundingClientRect().top}px`; + if (window.innerWidth < breakpoints.md) { + this.boardHeight = `${window.innerHeight - contentTop()}px`; + } else { + this.boardHeight = `${window.innerHeight - this.$el.getBoundingClientRect().top}px`; + } }, }, }; diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 00b4e6c96a9..392a73b5859 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -14,8 +14,8 @@ import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; -import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; +import { LabelType } from '~/sidebar/components/labels/labels_select_widget/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { @@ -32,10 +32,12 @@ export default { SidebarTodoWidget, SidebarSeverity, MountingPortal, + SidebarHealthStatusWidget: () => + import('ee_component/sidebar/components/health_status/sidebar_health_status_widget.vue'), + SidebarIterationWidget: () => + import('ee_component/sidebar/components/iteration/sidebar_iteration_widget.vue'), SidebarWeightWidget: () => import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue'), - IterationSidebarDropdownWidget: () => - import('ee_component/sidebar/components/iteration_sidebar_dropdown_widget.vue'), }, mixins: [glFeatureFlagMixin()], inject: { @@ -51,6 +53,9 @@ export default { weightFeatureAvailable: { default: false, }, + healthStatusFeatureAvailable: { + default: false, + }, allowLabelEdit: { default: false, }, @@ -115,6 +120,7 @@ export default { 'setActiveItemConfidential', 'setActiveBoardItemLabels', 'setActiveItemWeight', + 'setActiveItemHealthStatus', ]), handleClose() { this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); @@ -143,7 +149,7 @@ export default { <gl-drawer v-bind="$attrs" :open="showSidebar" - class="boards-sidebar gl-absolute" + class="boards-sidebar" variant="sidebar" @close="handleClose" > @@ -187,7 +193,7 @@ export default { :issuable-type="issuableType" data-testid="sidebar-milestones" /> - <iteration-sidebar-dropdown-widget + <sidebar-iteration-widget v-if="iterationFeatureAvailable && !isIncidentSidebar" :iid="activeBoardItem.iid" :workspace-path="projectPathForActiveIssue" @@ -236,6 +242,13 @@ export default { :issuable-type="issuableType" @weightUpdated="setActiveItemWeight($event)" /> + <sidebar-health-status-widget + v-if="healthStatusFeatureAvailable" + :iid="activeBoardItem.iid" + :full-path="fullPath" + :issuable-type="issuableType" + @statusUpdated="setActiveItemHealthStatus($event)" + /> <sidebar-confidentiality-widget :iid="activeBoardItem.iid" :full-path="fullPath" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 816b22e4dc6..215691c7ba2 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -133,6 +133,8 @@ export default { 'ghost-class': 'board-card-drag-active', 'data-list-id': this.list.id, value: this.boardItems, + delay: 100, + delayOnTouchOnly: true, }; return this.canMoveIssue ? options : {}; @@ -317,7 +319,7 @@ export default { > <!-- TODO: remove the condition when https://gitlab.com/gitlab-org/gitlab/-/issues/377862 is resolved --> <board-card-move-to-position - v-if="!isEpicBoard" + v-if="!isEpicBoard && !disabled" :item="item" :index="index" :list="list" diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index eaf3facb450..4f90d77c0be 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -237,7 +237,7 @@ export default { :text="board.name" @show="loadBoards" > - <p class="gl-new-dropdown-header-top" @mousedown.prevent> + <p class="gl-dropdown-header-top" @mousedown.prevent> {{ s__('IssueBoards|Switch board') }} </p> <gl-search-box-by-type ref="searchBox" v-model="filterTerm" class="m-2" /> diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue index 605e11d1590..bc68c2e0e99 100644 --- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue +++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue @@ -12,8 +12,8 @@ import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import { - OPERATOR_IS_AND_IS_NOT, - OPERATOR_IS_ONLY, + OPERATORS_IS_NOT, + OPERATORS_IS, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, @@ -31,7 +31,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -60,7 +60,7 @@ export default { tokensCE() { const { issue, incident } = this.$options.i18n; const { types } = this.$options; - const { fetchAuthors, fetchLabels } = issueBoardFilters( + const { fetchUsers, fetchLabels } = issueBoardFilters( this.$apollo, this.fullPath, this.boardType, @@ -71,28 +71,28 @@ export default { icon: 'user', title: TOKEN_TITLE_ASSIGNEE, type: TOKEN_TYPE_ASSIGNEE, - operators: OPERATOR_IS_AND_IS_NOT, - token: AuthorToken, + operators: OPERATORS_IS_NOT, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: this.preloadedAuthors(), + fetchUsers, + preloadedUsers: this.preloadedUsers(), }, { icon: 'pencil', title: TOKEN_TITLE_AUTHOR, type: TOKEN_TYPE_AUTHOR, - operators: OPERATOR_IS_AND_IS_NOT, + operators: OPERATORS_IS_NOT, symbol: '@', - token: AuthorToken, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: this.preloadedAuthors(), + fetchUsers, + preloadedUsers: this.preloadedUsers(), }, { icon: 'labels', title: TOKEN_TITLE_LABEL, type: TOKEN_TYPE_LABEL, - operators: OPERATOR_IS_AND_IS_NOT, + operators: OPERATORS_IS_NOT, token: LabelToken, unique: false, symbol: '~', @@ -128,7 +128,7 @@ export default { title: TOKEN_TITLE_CONFIDENTIAL, unique: true, token: GlFilteredSearchToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { icon: 'eye-slash', value: 'yes', title: __('Yes') }, { icon: 'eye', value: 'no', title: __('No') }, @@ -186,7 +186,7 @@ export default { }, methods: { ...mapActions(['fetchMilestones']), - preloadedAuthors() { + preloadedUsers() { return gon?.current_user_id ? [ { diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue index a35b3f14be4..b70294c9db3 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue @@ -6,7 +6,7 @@ export default { components: { IssuableTimeTracker, }, - inject: ['timeTrackingLimitToHours'], + inject: ['timeTrackingLimitToHours', 'canUpdate'], computed: { ...mapGetters(['activeBoardItem']), initialTimeTracking() { @@ -34,5 +34,6 @@ export default { :limit-to-hours="timeTrackingLimitToHours" :initial-time-tracking="initialTimeTracking" :show-collapsed="false" + :can-add-time-entries="canUpdate" /> </template> diff --git a/app/assets/javascripts/boards/issue_board_filters.js b/app/assets/javascripts/boards/issue_board_filters.js index 699d7e12de4..4bfd92fb748 100644 --- a/app/assets/javascripts/boards/issue_board_filters.js +++ b/app/assets/javascripts/boards/issue_board_filters.js @@ -14,13 +14,13 @@ export default function issueBoardFilters(apollo, fullPath, boardType) { return isGroupBoard ? groupBoardMembers : projectBoardMembers; }; - const fetchAuthors = (authorsSearchTerm) => { + const fetchUsers = (usersSearchTerm) => { return apollo .query({ query: boardAssigneesQuery(), variables: { fullPath, - search: authorsSearchTerm, + search: usersSearchTerm, }, }) .then(({ data }) => data.workspace?.assignees.nodes.map(({ user }) => user)); @@ -42,6 +42,6 @@ export default function issueBoardFilters(apollo, fullPath, boardType) { return { fetchLabels, - fetchAuthors, + fetchUsers, }; } diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index e5437690fd4..07b127d86e2 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -928,4 +928,5 @@ export default { // EE action needs CE empty equivalent setActiveItemWeight: () => {}, + setActiveItemHealthStatus: () => {}, }; diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue index 5f782b5e652..263efcaa788 100644 --- a/app/assets/javascripts/branches/components/sort_dropdown.vue +++ b/app/assets/javascripts/branches/components/sort_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlSearchBoxByClick } from '@gitlab/ui'; import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; @@ -10,8 +10,7 @@ export default { searchPlaceholder: s__('Branches|Filter by branch name'), }, components: { - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlSearchBoxByClick, }, inject: ['projectBranchesFilteredPath', 'sortOptions', 'mode'], @@ -28,6 +27,9 @@ export default { selectedSortMethodName() { return this.sortOptions[this.selectedKey]; }, + listboxItems() { + return Object.entries(this.sortOptions).map(([value, text]) => ({ value, text })); + }, }, created() { const sortValue = getParameterValues('sort'); @@ -42,9 +44,6 @@ export default { } }, methods: { - isSortMethodSelected(sortKey) { - return sortKey === this.selectedKey; - }, visitUrlFromOption(sortKey) { this.selectedKey = sortKey; const urlParams = {}; @@ -70,20 +69,15 @@ export default { data-testid="branch-search" @submit="visitUrlFromOption(selectedKey)" /> - <gl-dropdown + + <gl-collapsible-listbox v-if="shouldShowDropdown" - :text="selectedSortMethodName" + v-model="selectedKey" + :items="listboxItems" + :toggle-text="selectedSortMethodName" class="gl-mr-3" data-testid="branches-dropdown" - > - <gl-dropdown-item - v-for="(value, key) in sortOptions" - :key="key" - :is-checked="isSortMethodSelected(key)" - is-check-item - @click="visitUrlFromOption(key)" - >{{ value }}</gl-dropdown-item - > - </gl-dropdown> + @select="visitUrlFromOption(selectedKey)" + /> </div> </template> diff --git a/app/assets/javascripts/branches/init_new_branch_ref_selector.js b/app/assets/javascripts/branches/init_new_branch_ref_selector.js new file mode 100644 index 00000000000..aad3fbb9982 --- /dev/null +++ b/app/assets/javascripts/branches/init_new_branch_ref_selector.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import RefSelector from '~/ref/components/ref_selector.vue'; + +export default function initNewBranchRefSelector() { + const el = document.querySelector('.js-new-branch-ref-selector'); + + if (!el) { + return false; + } + + const { projectId, defaultBranchName, hiddenInputName } = el.dataset; + + return new Vue({ + el, + render(createComponent) { + return createComponent(RefSelector, { + props: { + value: defaultBranchName, + name: hiddenInputName, + projectId, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue index 8db4cba529f..49a314e067c 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci/ci_lint/components/ci_lint.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui'; -import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; -import lintCiMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; +import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue'; +import lintCiMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; import SourceEditor from '~/vue_shared/components/source_editor.vue'; export default { diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci/ci_lint/index.js index 274aab45deb..382059eb17e 100644 --- a/app/assets/javascripts/ci_lint/index.js +++ b/app/assets/javascripts/ci/ci_lint/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { resolvers } from '~/pipeline_editor/graphql/resolvers'; +import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import CiLint from './components/ci_lint.vue'; diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue index 7b33d98bca0..7b33d98bca0 100644 --- a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js index e4fd423249b..e4fd423249b 100644 --- a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/components/code_snippet_alert/constants.js diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue index 4775836fcc6..4775836fcc6 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue index 9cbf60b1c8f..9cbf60b1c8f 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_section.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue index 0b57433e894..0b57433e894 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/first_pipeline_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue index d2682cf6326..d2682cf6326 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/getting_started_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue index bc9203b9c5b..bc9203b9c5b 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue index aeeb52319d2..aeeb52319d2 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index 375db7f3054..375db7f3054 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue b/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue index 049504181c4..049504181c4 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/ui/demo_job_pill.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue index 42e2d34fa3a..42e2d34fa3a 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue index 189690ce2c3..201fba837e2 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -43,9 +43,7 @@ export default { </script> <template> - <div - class="gl-bg-gray-10 gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1" - > + <div class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1"> <gl-button :href="$options.TEMPLATE_REPOSITORY_URL" size="small" diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue index 255e3cb31f1..255e3cb31f1 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue index 1f8ddae3696..ef9acc1f8f1 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue @@ -16,11 +16,11 @@ import { BRANCH_PAGINATION_LIMIT, BRANCH_SEARCH_DEBOUNCE, DEFAULT_FAILURE, -} from '~/pipeline_editor/constants'; -import updateCurrentBranchMutation from '~/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql'; -import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.query.graphql'; -import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; -import getLastCommitBranch from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; +} from '~/ci/pipeline_editor/constants'; +import updateCurrentBranchMutation from '~/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql'; +import getAvailableBranchesQuery from '~/ci/pipeline_editor/graphql/queries/available_branches.query.graphql'; +import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; +import getLastCommitBranch from '~/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; export default { i18n: { diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 8e95fad1e48..84c29e48114 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; import { EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING } from '../../constants'; import FileTreePopover from '../popovers/file_tree_popover.vue'; import BranchSwitcher from './branch_switcher.vue'; diff --git a/app/assets/javascripts/pipeline_editor/components/file_tree/container.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue index 280cd729a43..280cd729a43 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_tree/container.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/container.vue diff --git a/app/assets/javascripts/pipeline_editor/components/file_tree/file_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue index 786d483b5b9..786d483b5b9 100644 --- a/app/assets/javascripts/pipeline_editor/components/file_tree/file_item.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_tree/file_item.vue diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue index ec6ee52b6b2..ec6ee52b6b2 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index feadc60a22a..feadc60a22a 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue index 137dfca68d6..372f04075ab 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -3,8 +3,8 @@ import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; -import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql'; -import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; +import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; +import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; import { getQueryHeaders, toggleQueryPollingByVisibility, diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue index 610a570c4ce..84c0eef441f 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; -import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import { EDITOR_APP_STATUS_EMPTY, diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue index 0f19b9386e6..0f19b9386e6 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results.vue diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue index 49225a7cac7..49225a7cac7 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_param.vue diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue index ef2be2a5fba..ef2be2a5fba 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_results_value.vue diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue index ac0332cb0bd..ac0332cb0bd 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/lint/ci_lint_warnings.vue diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue index ed5466ff99c..ed5466ff99c 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue index efa6a54c638..efa6a54c638 100644 --- a/app/assets/javascripts/pipeline_editor/components/popovers/file_tree_popover.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue index 4730a521227..4730a521227 100644 --- a/app/assets/javascripts/pipeline_editor/components/popovers/validate_pipeline_popover.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue diff --git a/app/assets/javascripts/pipeline_editor/components/popovers/walkthrough_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue index c636d8b8e34..c636d8b8e34 100644 --- a/app/assets/javascripts/pipeline_editor/components/popovers/walkthrough_popover.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/walkthrough_popover.vue diff --git a/app/assets/javascripts/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue index bc076fbe349..bc076fbe349 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue index 65f399d1912..22b82f2e96f 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/editor_tab.vue @@ -41,7 +41,9 @@ import { __, s__ } from '~/locale'; export default { i18n: { - invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'), + invalid: __( + 'Your CI/CD configuration syntax is invalid. Select the Validate tab for more details.', + ), unavailable: __( "We're experiencing difficulties and this tab content is currently unavailable.", ), diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue index 7d2b9cd3d42..d7b8e7151d9 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; -import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; +import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; export default { components: { diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue index c72cff4c6f8..c72cff4c6f8 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_messages.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue index 83fcab4b343..83fcab4b343 100644 --- a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/ci/pipeline_editor/constants.js index dd25c4d433b..dd25c4d433b 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/constants.js diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql index 2d42ebb6ac3..2d42ebb6ac3 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql index 7487e328668..7487e328668 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_app_status.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql index b722c147f5f..b722c147f5f 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_current_branch.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql index 9561312f2b6..9561312f2b6 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_last_commit_branch.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql index 9025f00b343..9025f00b343 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql index 3495ca51283..3495ca51283 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql index 359b4a846c7..359b4a846c7 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/available_branches.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql index 5928d90f7c4..5928d90f7c4 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/blob_content.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql index 5354ed7c2d5..5354ed7c2d5 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql index 0df8cafa3cb..0df8cafa3cb 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql index 1f4f9d26f24..1f4f9d26f24 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql index a83129759de..a83129759de 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql index 8df6e74a5d9..8df6e74a5d9 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql index a34c8f365f4..a34c8f365f4 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/get_starter_template.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql index d62fda40237..d62fda40237 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql index 021b858d72e..021b858d72e 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/pipeline.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/pipeline.query.graphql diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js index fa1c70c1994..fa1c70c1994 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/resolvers.js diff --git a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql index 508ff22c46e..508ff22c46e 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/typedefs.graphql diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js index 6d91c339833..6d91c339833 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/ci/pipeline_editor/index.js diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue index ff848a973e3..ff848a973e3 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue index 1972125ed56..1972125ed56 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index 6e24ac6b8d4..a4ef7827f73 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -1,18 +1,321 @@ <script> -import { GlForm } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownItem, + GlFormCheckbox, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import Vue from 'vue'; +import { __, s__ } from '~/locale'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; +import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; +import { VARIABLE_TYPE, FILE_TYPE } from '../constants'; export default { components: { + GlButton, + GlDropdown, + GlDropdownItem, GlForm, + GlFormCheckbox, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlLink, + GlSprintf, + RefSelector, + TimezoneDropdown, + IntervalPatternInput, }, - inject: { - fullPath: { + inject: [ + 'fullPath', + 'projectId', + 'defaultBranch', + 'cron', + 'cronTimezone', + 'dailyLimit', + 'settingsLink', + ], + props: { + timezoneData: { + type: Array, + required: true, + }, + refParam: { + type: String, + required: false, default: '', }, }, + data() { + return { + refValue: { + shortName: this.refParam, + // this is needed until we add support for ref type in url query strings + // ensure default branch is called with full ref on load + // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 + fullName: this.refParam === this.defaultBranch ? `refs/heads/${this.refParam}` : undefined, + }, + description: '', + scheduleRef: this.defaultBranch, + activated: true, + timezone: this.cronTimezone, + formCiVariables: {}, + // TODO: Add the GraphQL query to help populate the predefined variables + // app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue#131 + predefinedValueOptions: {}, + }; + }, + i18n: { + activated: __('Activated'), + cronTimezone: s__('PipelineSchedules|Cron timezone'), + description: s__('PipelineSchedules|Description'), + shortDescriptionPipeline: s__( + 'PipelineSchedules|Provide a short description for this pipeline', + ), + savePipelineSchedule: s__('PipelineSchedules|Save pipeline schedule'), + cancel: __('Cancel'), + targetBranchTag: __('Select target branch or tag'), + intervalPattern: s__('PipelineSchedules|Interval Pattern'), + variablesDescription: s__( + 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', + ), + removeVariableLabel: s__('CiVariables|Remove variable'), + variables: s__('Pipeline|Variables'), + }, + typeOptions: { + [VARIABLE_TYPE]: __('Variable'), + [FILE_TYPE]: __('File'), + }, + formElementClasses: 'gl-md-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', + computed: { + dropdownTranslations() { + return { + dropdownHeader: this.$options.i18n.targetBranchTag, + }; + }, + refFullName() { + return this.refValue.fullName; + }, + variables() { + return this.formCiVariables[this.refFullName]?.variables ?? []; + }, + descriptions() { + return this.formCiVariables[this.refFullName]?.descriptions ?? {}; + }, + typeOptionsListbox() { + return [ + { + text: __('Variable'), + value: VARIABLE_TYPE, + }, + { + text: __('File'), + value: FILE_TYPE, + }, + ]; + }, + getEnabledRefTypes() { + return [REF_TYPE_BRANCHES, REF_TYPE_TAGS]; + }, + }, + created() { + Vue.set(this.formCiVariables, this.refFullName, { + variables: [], + descriptions: {}, + }); + + this.addEmptyVariable(this.refFullName); + }, + methods: { + addEmptyVariable(refValue) { + const { variables } = this.formCiVariables[refValue]; + + const lastVar = variables[variables.length - 1]; + if (lastVar?.key === '' && lastVar?.value === '') { + return; + } + + variables.push({ + uniqueId: uniqueId(`var-${refValue}`), + variable_type: VARIABLE_TYPE, + key: '', + value: '', + }); + }, + setVariableAttribute(key, attribute, value) { + const { variables } = this.formCiVariables[this.refFullName]; + const variable = variables.find((v) => v.key === key); + variable[attribute] = value; + }, + shouldShowValuesDropdown(key) { + return this.predefinedValueOptions[key]?.length > 1; + }, + removeVariable(index) { + this.variables.splice(index, 1); + }, + canRemove(index) { + return index < this.variables.length - 1; + }, + }, }; </script> <template> - <gl-form /> + <div class="col-lg-8"> + <gl-form> + <!--Description--> + <gl-form-group :label="$options.i18n.description" label-for="schedule-description"> + <gl-form-input + id="schedule-description" + v-model="description" + type="text" + :placeholder="$options.i18n.shortDescriptionPipeline" + data-testid="schedule-description" + /> + </gl-form-group> + <!--Interval Pattern--> + <gl-form-group :label="$options.i18n.intervalPattern" label-for="schedule-interval"> + <interval-pattern-input + id="schedule-interval" + :initial-cron-interval="cron" + :daily-limit="dailyLimit" + :send-native-errors="false" + /> + </gl-form-group> + <!--Timezone--> + <gl-form-group :label="$options.i18n.cronTimezone" label-for="schedule-timezone"> + <timezone-dropdown + id="schedule-timezone" + :value="timezone" + :timezone-data="timezoneData" + name="schedule-timezone" + /> + </gl-form-group> + <!--Branch/Tag Selector--> + <gl-form-group :label="$options.i18n.targetBranchTag" label-for="schedule-target-branch-tag"> + <ref-selector + id="schedule-target-branch-tag" + :enabled-ref-types="getEnabledRefTypes" + :project-id="projectId" + :value="scheduleRef" + :use-symbolic-ref-names="true" + :translations="dropdownTranslations" + class="gl-w-full" + /> + </gl-form-group> + <!--Variable List--> + <gl-form-group :label="$options.i18n.variables"> + <div + v-for="(variable, index) in variables" + :key="variable.uniqueId" + class="gl-mb-3 gl-pb-2" + data-testid="ci-variable-row" + data-qa-selector="ci_variable_row_container" + > + <div + class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" + > + <gl-dropdown + :text="$options.typeOptions[variable.variable_type]" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-type" + > + <gl-dropdown-item + v-for="type in Object.keys($options.typeOptions)" + :key="type" + @click="setVariableAttribute(variable.key, 'variable_type', type)" + > + {{ $options.typeOptions[type] }} + </gl-dropdown-item> + </gl-dropdown> + <gl-form-input + v-model="variable.key" + :placeholder="s__('CiVariables|Input variable key')" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-key" + data-qa-selector="ci_variable_key_field" + @change="addEmptyVariable(refFullName)" + /> + <gl-dropdown + v-if="shouldShowValuesDropdown(variable.key)" + :text="variable.value" + :class="$options.formElementClasses" + class="gl-flex-grow-1 gl-mr-0!" + data-testid="pipeline-form-ci-variable-value-dropdown" + > + <gl-dropdown-item + v-for="value in predefinedValueOptions[variable.key]" + :key="value" + data-testid="pipeline-form-ci-variable-value-dropdown-items" + @click="setVariableAttribute(variable.key, 'value', value)" + > + {{ value }} + </gl-dropdown-item> + </gl-dropdown> + <gl-form-textarea + v-else + v-model="variable.value" + :placeholder="s__('CiVariables|Input variable value')" + class="gl-mb-3 gl-h-7!" + :style="$options.textAreaStyle" + :no-resize="false" + data-testid="pipeline-form-ci-variable-value" + data-qa-selector="ci_variable_value_field" + /> + + <template v-if="variables.length > 1"> + <gl-button + v-if="canRemove(index)" + class="gl-md-ml-3 gl-mb-3" + data-testid="remove-ci-variable-row" + variant="danger" + category="secondary" + icon="clear" + :aria-label="$options.i18n.removeVariableLabel" + @click="removeVariable(index)" + /> + <gl-button + v-else + class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden" + icon="clear" + :aria-label="$options.i18n.removeVariableLabel" + /> + </template> + </div> + <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> + {{ descriptions[variable.key] }} + </div> + </div> + + <template #description + ><gl-sprintf :message="$options.i18n.variablesDescription"> + <template #link="{ content }"> + <gl-link :href="settingsLink">{{ content }}</gl-link> + </template> + </gl-sprintf></template + > + </gl-form-group> + <!--Activated--> + <gl-form-checkbox id="schedule-active" v-model="activated" class="gl-mb-3">{{ + $options.i18n.activated + }}</gl-form-checkbox> + + <gl-button type="submit" variant="confirm" data-testid="schedule-submit-button">{{ + $options.i18n.savePipelineSchedule + }}</gl-button> + <gl-button type="reset" data-testid="schedule-cancel-button">{{ + $options.i18n.cancel + }}</gl-button> + </gl-form> + </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/constants.js b/app/assets/javascripts/ci/pipeline_schedules/constants.js new file mode 100644 index 00000000000..b4ab1143f60 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/constants.js @@ -0,0 +1,2 @@ +export const VARIABLE_TYPE = 'env_var'; +export const FILE_TYPE = 'file'; diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js index d83417ab84a..445161f99cb 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_form_app.js @@ -16,7 +16,16 @@ export default (selector) => { return false; } - const { fullPath } = containerEl.dataset; + const { + fullPath, + cron, + dailyLimit, + timezoneData, + cronTimezone, + projectId, + defaultBranch, + settingsLink, + } = containerEl.dataset; return new Vue({ el: containerEl, @@ -24,9 +33,20 @@ export default (selector) => { apolloProvider, provide: { fullPath, + projectId, + defaultBranch, + dailyLimit: dailyLimit ?? '', + cronTimezone: cronTimezone ?? '', + cron: cron ?? '', + settingsLink, }, render(createElement) { - return createElement(PipelineSchedulesForm); + return createElement(PipelineSchedulesForm, { + props: { + timezoneData: JSON.parse(timezoneData), + refParam: defaultBranch, + }, + }); }, }); }; diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue index fb2ef850e4f..5a7ee9c9b28 100644 --- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue +++ b/app/assets/javascripts/ci/reports/codequality_report/components/codequality_issue_body.vue @@ -5,8 +5,8 @@ */ import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; -import ReportLink from '~/reports/components/report_link.vue'; -import { STATUS_SUCCESS, STATUS_NEUTRAL } from '~/reports/constants'; +import ReportLink from '~/ci/reports/components/report_link.vue'; +import { STATUS_SUCCESS, STATUS_NEUTRAL } from '~/ci/reports/constants'; import { SEVERITY_CLASSES, SEVERITY_ICONS } from '../constants'; export default { diff --git a/app/assets/javascripts/reports/codequality_report/constants.js b/app/assets/javascripts/ci/reports/codequality_report/constants.js index 0c472b24471..5e81245037f 100644 --- a/app/assets/javascripts/reports/codequality_report/constants.js +++ b/app/assets/javascripts/ci/reports/codequality_report/constants.js @@ -16,12 +16,7 @@ export const SEVERITY_ICONS = { unknown: 'severity-unknown', }; -// This is the icons mapping for the code Quality Merge-Request Widget Extension -// once the refactor_mr_widgets_extensions flag is activated the above SEVERITY_ICONS -// need be removed and this variable needs to be rename to SEVERITY_ICONS -// Rollout Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/341759 - -export const SEVERITY_ICONS_EXTENSION = { +export const SEVERITY_ICONS_MR_WIDGET = { info: 'severityInfo', minor: 'severityLow', major: 'severityMedium', @@ -29,3 +24,30 @@ export const SEVERITY_ICONS_EXTENSION = { blocker: 'severityCritical', unknown: 'severityUnknown', }; + +export const SEVERITIES = { + info: { + class: SEVERITY_CLASSES.info, + name: SEVERITY_ICONS.info, + }, + minor: { + class: SEVERITY_CLASSES.minor, + name: SEVERITY_ICONS.minor, + }, + major: { + class: SEVERITY_CLASSES.major, + name: SEVERITY_ICONS.major, + }, + critical: { + class: SEVERITY_CLASSES.critical, + name: SEVERITY_ICONS.critical, + }, + blocker: { + class: SEVERITY_CLASSES.blocker, + name: SEVERITY_ICONS.blocker, + }, + unknown: { + class: SEVERITY_CLASSES.unknown, + name: SEVERITY_ICONS.unknown, + }, +}; diff --git a/app/assets/javascripts/reports/codequality_report/store/actions.js b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js index 04aca11b945..04aca11b945 100644 --- a/app/assets/javascripts/reports/codequality_report/store/actions.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js index 70d11e96a54..70d11e96a54 100644 --- a/app/assets/javascripts/reports/codequality_report/store/getters.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/getters.js diff --git a/app/assets/javascripts/reports/codequality_report/store/index.js b/app/assets/javascripts/ci/reports/codequality_report/store/index.js index 5bfcd69edec..5bfcd69edec 100644 --- a/app/assets/javascripts/reports/codequality_report/store/index.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/index.js diff --git a/app/assets/javascripts/reports/codequality_report/store/mutation_types.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js index c362c973ae1..c362c973ae1 100644 --- a/app/assets/javascripts/reports/codequality_report/store/mutation_types.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutation_types.js diff --git a/app/assets/javascripts/reports/codequality_report/store/mutations.js b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js index 249c2f35c0b..249c2f35c0b 100644 --- a/app/assets/javascripts/reports/codequality_report/store/mutations.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/mutations.js diff --git a/app/assets/javascripts/reports/codequality_report/store/state.js b/app/assets/javascripts/ci/reports/codequality_report/store/state.js index f68dbc2a5fa..f68dbc2a5fa 100644 --- a/app/assets/javascripts/reports/codequality_report/store/state.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/state.js diff --git a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js index 417297df43c..417297df43c 100644 --- a/app/assets/javascripts/reports/codequality_report/store/utils/codequality_parser.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue index ca369022938..b21a486e259 100644 --- a/app/assets/javascripts/reports/components/grouped_issues_list.vue +++ b/app/assets/javascripts/ci/reports/components/grouped_issues_list.vue @@ -1,6 +1,6 @@ <script> import { s__ } from '~/locale'; -import ReportItem from '~/reports/components/report_item.vue'; +import ReportItem from '~/ci/reports/components/report_item.vue'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/ci/reports/components/issue_body.js index 4f418216024..daff1be30ff 100644 --- a/app/assets/javascripts/reports/components/issue_body.js +++ b/app/assets/javascripts/ci/reports/components/issue_body.js @@ -1,4 +1,4 @@ -import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; +import IssueStatusIcon from '~/ci/reports/components/issue_status_icon.vue'; export const components = { CodequalityIssueBody: () => import('../codequality_report/components/codequality_issue_body.vue'), diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue index bd41b8d23f1..bd41b8d23f1 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/ci/reports/components/issues_list.vue index 9df0a1953b6..ababd4b5e49 100644 --- a/app/assets/javascripts/reports/components/issues_list.vue +++ b/app/assets/javascripts/ci/reports/components/issues_list.vue @@ -1,6 +1,6 @@ <script> -import ReportItem from '~/reports/components/report_item.vue'; -import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; +import ReportItem from '~/ci/reports/components/report_item.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/ci/reports/constants'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; const wrapIssueWithState = (status, isNew = false) => (issue) => ({ diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/ci/reports/components/report_item.vue index 918263bfb5c..97d4ac7bf6f 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/ci/reports/components/report_item.vue @@ -4,7 +4,7 @@ import { componentNames, iconComponents, iconComponentNames, -} from 'ee_else_ce/reports/components/issue_body'; +} from 'ee_else_ce/ci/reports/components/issue_body'; export default { name: 'ReportItem', diff --git a/app/assets/javascripts/reports/components/report_link.vue b/app/assets/javascripts/ci/reports/components/report_link.vue index 1f68f79e487..1f68f79e487 100644 --- a/app/assets/javascripts/reports/components/report_link.vue +++ b/app/assets/javascripts/ci/reports/components/report_link.vue diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue index 468c8916b8d..468c8916b8d 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/ci/reports/components/report_section.vue diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/ci/reports/components/summary_row.vue index ee55368c829..ee55368c829 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/ci/reports/components/summary_row.vue diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/ci/reports/constants.js index bad6fa1e7b9..bad6fa1e7b9 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/ci/reports/constants.js diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue index 9fa4b521ebc..66d790acb00 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runner_show/admin_runner_show_app.vue @@ -1,5 +1,6 @@ <script> -import { GlBadge, GlTabs, GlTab, GlTooltipDirective } from '@gitlab/ui'; +import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import VueRouter from 'vue-router'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -11,11 +12,28 @@ import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; import RunnerDetails from '../components/runner_details.vue'; import RunnerJobs from '../components/runner_jobs.vue'; -import { I18N_DETAILS, I18N_FETCH_ERROR } from '../constants'; +import { I18N_DETAILS, I18N_JOBS, I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; +const ROUTE_DETAILS = 'details'; +const ROUTE_JOBS = 'jobs'; + +const routes = [ + { + path: '/', + name: ROUTE_DETAILS, + component: RunnerDetails, + }, + { + path: '/jobs', + name: ROUTE_JOBS, + component: RunnerJobs, + }, + { path: '*', redirect: { name: ROUTE_DETAILS } }, +]; + export default { name: 'AdminRunnerShowApp', components: { @@ -26,12 +44,10 @@ export default { RunnerEditButton, RunnerPauseButton, RunnerHeader, - RunnerDetails, - RunnerJobs, - }, - directives: { - GlTooltip: GlTooltipDirective, }, + router: new VueRouter({ + routes, + }), props: { runnerId: { type: String, @@ -72,11 +88,17 @@ export default { jobCount() { return formatJobCount(this.runner?.jobCount); }, + tabIndex() { + return routes.findIndex(({ name }) => name === this.$route.name); + }, }, errorCaptured(error) { this.reportToSentry(error); }, methods: { + goTo(name) { + this.$router.push({ name }); + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -85,7 +107,10 @@ export default { redirectTo(this.runnersPath); }, }, + ROUTE_DETAILS, + ROUTE_JOBS, I18N_DETAILS, + I18N_JOBS, }; </script> <template> @@ -98,15 +123,13 @@ export default { </template> </runner-header> - <gl-tabs> - <gl-tab> + <gl-tabs :value="tabIndex"> + <gl-tab @click="goTo($options.ROUTE_DETAILS)"> <template #title>{{ $options.I18N_DETAILS }}</template> - - <runner-details v-if="runner" :runner="runner" /> </gl-tab> - <gl-tab> + <gl-tab @click="goTo($options.ROUTE_JOBS)"> <template #title> - {{ s__('Runners|Jobs') }} + {{ $options.I18N_JOBS }} <gl-badge v-if="jobCount" data-testid="job-count-badge" @@ -116,9 +139,9 @@ export default { {{ jobCount }} </gl-badge> </template> - - <runner-jobs v-if="runner" :runner="runner" /> </gl-tab> + + <router-view v-if="runner" :runner="runner" /> </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/ci/runner/admin_runner_show/index.js b/app/assets/javascripts/ci/runner/admin_runner_show/index.js index ea455416648..cbd25819303 100644 --- a/app/assets/javascripts/ci/runner/admin_runner_show/index.js +++ b/app/assets/javascripts/ci/runner/admin_runner_show/index.js @@ -1,10 +1,12 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; +import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import AdminRunnerShowApp from './admin_runner_show_app.vue'; Vue.use(VueApollo); +Vue.use(VueRouter); export const initAdminRunnerShow = (selector = '#js-admin-runner-show') => { showAlertFromLocalStorage(); diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index 2915e460085..3bd20dff9cc 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -23,6 +23,7 @@ import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerActionsCell from '../components/cells/runner_actions_cell.vue'; +import RunnerJobStatusBadge from '../components/runner_job_status_badge.vue'; import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; @@ -48,6 +49,7 @@ export default { RunnerPagination, RunnerTypeTabs, RunnerActionsCell, + RunnerJobStatusBadge, }, mixins: [glFeatureFlagMixin()], inject: ['emptyStateSvgPath', 'emptyStateFilteredSvgPath'], @@ -69,6 +71,9 @@ export default { apollo: { runners: { query: allRunnersQuery, + context: { + isSingleRequest: true, + }, fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; @@ -134,6 +139,12 @@ export default { this.reportToSentry(error); }, methods: { + jobsUrl(runner) { + const url = new URL(runner.adminUrl); + url.hash = '#/jobs'; + + return url.href; + }, onToggledPaused() { // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. @@ -208,6 +219,12 @@ export default { <runner-name :runner="runner" /> </gl-link> </template> + <template #runner-job-status-badge="{ runner }"> + <runner-job-status-badge + :href="jobsUrl(runner)" + :job-status="runner.jobExecutionStatus" + /> + </template> <template #runner-actions-cell="{ runner }"> <runner-actions-cell :runner="runner" diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue index 67b9b0a266f..cfbe37f5ba2 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_status_cell.vue @@ -7,8 +7,6 @@ import RunnerPausedBadge from '../runner_paused_badge.vue'; export default { components: { RunnerStatusBadge, - RunnerUpgradeStatusBadge: () => - import('ee_component/ci/runner/components/runner_upgrade_status_badge.vue'), RunnerPausedBadge, }, directives: { @@ -34,10 +32,6 @@ export default { :runner="runner" class="gl-display-inline-block gl-max-w-full gl-text-truncate" /> - <runner-upgrade-status-badge - :runner="runner" - class="gl-display-inline-block gl-max-w-full gl-text-truncate" - /> <runner-paused-badge v-if="paused" class="gl-display-inline-block gl-max-w-full gl-text-truncate" diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index 1e44d5fccc2..4a72023b6a0 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_stacked_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -6,9 +6,11 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerName from '../runner_name.vue'; import RunnerTags from '../runner_tags.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; +import RunnerJobStatusBadge from '../runner_job_status_badge.vue'; import { formatJobCount } from '../../utils'; import { + I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -25,6 +27,7 @@ export default { RunnerName, RunnerTags, RunnerTypeBadge, + RunnerJobStatusBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), TooltipOnTruncate, @@ -44,6 +47,7 @@ export default { }, }, i18n: { + I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -75,12 +79,21 @@ export default { </gl-sprintf> </div> <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> - <tooltip-on-truncate class="gl-text-truncate gl-display-block" :title="runner.description"> + <tooltip-on-truncate + v-if="runner.description" + class="gl-text-truncate gl-display-block" + :title="runner.description" + > {{ runner.description }} </tooltip-on-truncate> + <span v-else class="gl-text-secondary">{{ $options.i18n.I18N_NO_DESCRIPTION }}</span> </div> <div> + <slot :runner="runner" name="runner-job-status-badge"> + <runner-job-status-badge :job-status="runner.jobExecutionStatus" /> + </slot> + <runner-summary-field icon="clock"> <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> <template #timeAgo> diff --git a/app/assets/javascripts/ci/runner/components/runner_detail.vue b/app/assets/javascripts/ci/runner/components/runner_detail.vue index c260670b517..9e8055a8432 100644 --- a/app/assets/javascripts/ci/runner/components/runner_detail.vue +++ b/app/assets/javascripts/ci/runner/components/runner_detail.vue @@ -49,7 +49,7 @@ export default { <template v-if="value || $scopedSlots.value"> <slot name="value">{{ value }}</slot> </template> - <span v-else class="gl-text-gray-500">{{ emptyValue }}</span> + <span v-else class="gl-text-secondary">{{ emptyValue }}</span> </dd> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_groups.vue b/app/assets/javascripts/ci/runner/components/runner_groups.vue index c3b35bd52a9..8501d165157 100644 --- a/app/assets/javascripts/ci/runner/components/runner_groups.vue +++ b/app/assets/javascripts/ci/runner/components/runner_groups.vue @@ -32,6 +32,6 @@ export default { :avatar-url="group.avatarUrl" /> </template> - <span v-else class="gl-text-gray-500">{{ __('None') }}</span> + <span v-else class="gl-text-secondary">{{ __('None') }}</span> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue new file mode 100644 index 00000000000..1e52acecfb8 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_job_status_badge.vue @@ -0,0 +1,55 @@ +<script> +import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; +import { + I18N_JOB_STATUS_RUNNING, + I18N_JOB_STATUS_IDLE, + JOB_STATUS_RUNNING, + JOB_STATUS_IDLE, +} from '../constants'; + +export default { + components: { + GlBadge, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + jobStatus: { + required: false, + default: null, + type: String, + }, + }, + computed: { + badge() { + switch (this.jobStatus) { + case JOB_STATUS_RUNNING: + return { + classes: 'gl-text-blue-600! gl-border gl-border-blue-600!', + label: I18N_JOB_STATUS_RUNNING, + }; + case JOB_STATUS_IDLE: + return { + classes: 'gl-text-gray-700! gl-border gl-border-gray-500!', + label: I18N_JOB_STATUS_IDLE, + }; + default: + return null; + } + }, + }, +}; +</script> +<template> + <gl-badge + v-if="badge" + v-bind="$attrs" + size="sm" + class="gl-mr-3 gl-bg-transparent!" + variant="muted" + :class="badge.classes" + > + {{ badge.label }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_list.vue b/app/assets/javascripts/ci/runner/components/runner_list.vue index e895537dcdc..b2aad0aac4f 100644 --- a/app/assets/javascripts/ci/runner/components/runner_list.vue +++ b/app/assets/javascripts/ci/runner/components/runner_list.vue @@ -7,7 +7,7 @@ import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.grap import { formatJobCount, tableField } from '../utils'; import RunnerBulkDelete from './runner_bulk_delete.vue'; import RunnerBulkDeleteCheckbox from './runner_bulk_delete_checkbox.vue'; -import RunnerStackedSummaryCell from './cells/runner_stacked_summary_cell.vue'; +import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerStatusPopover from './runner_status_popover.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerOwnerCell from './cells/runner_owner_cell.vue'; @@ -28,7 +28,7 @@ export default { RunnerBulkDelete, RunnerBulkDeleteCheckbox, RunnerStatusPopover, - RunnerStackedSummaryCell, + RunnerSummaryCell, RunnerStatusCell, RunnerOwnerCell, }, @@ -154,11 +154,14 @@ export default { </template> <template #cell(summary)="{ item, index }"> - <runner-stacked-summary-cell :runner="item"> + <runner-summary-cell :runner="item"> <template #runner-name="{ runner }"> <slot name="runner-name" :runner="runner" :index="index"></slot> </template> - </runner-stacked-summary-cell> + <template #runner-job-status-badge="{ runner }"> + <slot name="runner-job-status-badge" :runner="runner" :index="index"></slot> + </template> + </runner-summary-cell> </template> <template #head(owner)="{ label }"> diff --git a/app/assets/javascripts/ci/runner/components/runner_projects.vue b/app/assets/javascripts/ci/runner/components/runner_projects.vue index 84008e8eee8..4a6e90b44a9 100644 --- a/app/assets/javascripts/ci/runner/components/runner_projects.vue +++ b/app/assets/javascripts/ci/runner/components/runner_projects.vue @@ -133,7 +133,7 @@ export default { :is-owner="isOwner(project.id)" /> </template> - <div v-else class="gl-py-5 gl-text-gray-500">{{ $options.I18N_NO_PROJECTS_FOUND }}</div> + <div v-else class="gl-py-5 gl-text-secondary">{{ $options.I18N_NO_PROJECTS_FOUND }}</div> <runner-pagination :disabled="loading" diff --git a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue index 584236168ac..70226074993 100644 --- a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue +++ b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue @@ -59,21 +59,20 @@ export default { return [ { title: I18N_ALL_TYPES, - runnerType: null, }, ...tabs, ]; }, }, methods: { - onTabSelected({ runnerType }) { + onTabSelected(runnerType) { this.$emit('input', { ...this.value, runnerType, pagination: { page: 1 }, }); }, - isTabActive({ runnerType }) { + isTabActive(runnerType = null) { return runnerType === this.value.runnerType; }, tabBadgeCountVariables(runnerType) { @@ -102,8 +101,8 @@ export default { <gl-tab v-for="tab in tabs" :key="`${tab.runnerType}`" - :active="isTabActive(tab)" - @click="onTabSelected(tab)" + :active="isTabActive(tab.runnerType)" + @click="onTabSelected(tab.runnerType)" > <template #title> {{ tab.title }} diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js index 97ee8ec3eef..71a145dd4a3 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/paused_token_config.js @@ -1,5 +1,5 @@ import { __ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { PARAM_KEY_PAUSED, I18N_PAUSED } from '../../constants'; @@ -24,5 +24,5 @@ export const pausedTokenConfig = { // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 title: title.replace(/\s/g, '\u00a0'), })), - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js index 117a630719e..4bc32909777 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/status_token_config.js @@ -1,5 +1,5 @@ import { - OPERATOR_IS_ONLY, + OPERATORS_IS, TOKEN_TITLE_STATUS, } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -38,5 +38,5 @@ export const statusTokenConfig = { // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 title: title.replace(/\s/g, '\u00a0'), })), - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js index fdeba714385..369b214f952 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js +++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token_config.js @@ -1,5 +1,5 @@ import { s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { PARAM_KEY_TAG } from '../../constants'; import TagToken from './tag_token.vue'; @@ -8,5 +8,5 @@ export const tagTokenConfig = { title: s__('Runners|Tags'), type: PARAM_KEY_TAG, token: TagToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue index 4ad9259f59d..c33c42f3afe 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue @@ -16,13 +16,13 @@ import { INSTANCE_TYPE, GROUP_TYPE } from '../../constants'; * <strong/> tag. * * ```vue - * <runner-count-stat + * <runner-count * #default="{ count }" * :scope="INSTANCE_TYPE" * :variables="{ status: 'ONLINE' }" * > * <strong>{{ count }}</strong> - * </runner-count-stat> + * </runner-count> * ``` * * Use `:skip="true"` to prevent data from being fetched and diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue index 3965e5551f1..2e50dc13d2d 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue @@ -1,5 +1,4 @@ <script> -import RunnerSingleStat from '~/ci/runner/components/stat/runner_single_stat.vue'; import { I18N_STATUS_ONLINE, I18N_STATUS_OFFLINE, @@ -8,9 +7,19 @@ import { STATUS_OFFLINE, STATUS_STALE, } from '../../constants'; +import RunnerSingleStat from './runner_single_stat.vue'; +import RunnerCount from './runner_count.vue'; + +/** + * Shows general stats about the runners. + * + * First it checks if there are any runners in this context, and if so, + * shows more details for different status. + */ export default { components: { + RunnerCount, RunnerSingleStat, RunnerUpgradeStatusStats: () => import('ee_component/ci/runner/components/stat/runner_upgrade_status_stats.vue'), @@ -71,19 +80,21 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-flex-wrap gl-py-6"> - <runner-single-stat - v-for="stat in stats" - :key="stat.key" - :scope="scope" - v-bind="stat.props" - class="gl-px-5" - /> + <runner-count #default="{ count }" :scope="scope" :variables="variables"> + <div v-if="count" class="gl-display-flex gl-flex-wrap gl-py-6"> + <runner-single-stat + v-for="stat in stats" + :key="stat.key" + :scope="scope" + v-bind="stat.props" + class="gl-px-5" + /> - <runner-upgrade-status-stats - class="gl-display-contents" - :scope="scope" - :variables="variables" - /> - </div> + <runner-upgrade-status-stats + class="gl-display-contents" + :scope="scope" + :variables="variables" + /> + </div> + </runner-count> </template> diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index dfc5f0c4152..31900a1fe89 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -32,6 +32,10 @@ export const I18N_STATUS_NEVER_CONTACTED = s__('Runners|Never contacted'); export const I18N_STATUS_OFFLINE = s__('Runners|Offline'); export const I18N_STATUS_STALE = s__('Runners|Stale'); +// Executor Status +export const I18N_JOB_STATUS_RUNNING = s__('Runners|Running'); +export const I18N_JOB_STATUS_IDLE = s__('Runners|Idle'); + // Status help popover export const I18N_STATUS_POPOVER_TITLE = s__('Runners|Runner statuses'); @@ -82,6 +86,7 @@ export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); // List +export const I18N_NO_DESCRIPTION = s__('Runners|No description'); export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', ); @@ -94,6 +99,7 @@ export const I18N_ADMIN = s__('Runners|Administrator'); // Runner details export const I18N_DETAILS = s__('Runners|Details'); +export const I18N_JOBS = s__('Runners|Jobs'); export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})'); export const I18N_FILTER_PROJECTS = s__('Runners|Filter projects'); export const I18N_CLEAR_FILTER_PROJECTS = __('Clear'); @@ -134,6 +140,11 @@ export const STATUS_NEVER_CONTACTED = 'NEVER_CONTACTED'; export const STATUS_OFFLINE = 'OFFLINE'; export const STATUS_STALE = 'STALE'; +// CiRunnerJobExecutionStatus + +export const JOB_STATUS_RUNNING = 'RUNNING'; +export const JOB_STATUS_IDLE = 'IDLE'; + // CiRunnerAccessLevel export const ACCESS_LEVEL_NOT_PROTECTED = 'NOT_PROTECTED'; diff --git a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql index 0dff011daaa..6f72509f599 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/list_item_shared.fragment.graphql @@ -12,6 +12,7 @@ fragment ListItemShared on CiRunner { createdAt contactedAt status(legacyMode: null) + jobExecutionStatus userPermissions { updateRunner deleteRunner diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 91c22923075..57ceaa24b6e 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -82,6 +82,9 @@ export default { apollo: { runners: { query: groupRunnersQuery, + context: { + isSingleRequest: true, + }, fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; diff --git a/app/assets/javascripts/ci/runner/runner_search_utils.js b/app/assets/javascripts/ci/runner/runner_search_utils.js index adc832b0600..3dc99baa329 100644 --- a/app/assets/javascripts/ci/runner/runner_search_utils.js +++ b/app/assets/javascripts/ci/runner/runner_search_utils.js @@ -176,6 +176,7 @@ export const fromSearchToUrl = ( [PARAM_KEY_RUNNER_TYPE]: [], [PARAM_KEY_MEMBERSHIP]: [], [PARAM_KEY_TAG]: [], + [PARAM_KEY_PAUSED]: [], // Current filters ...filterToQueryObject(processFilters(filters), { filteredSearchTermKey: PARAM_KEY_SEARCH, diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue index 9d8cb40b60a..661389f4059 100644 --- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue +++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue @@ -13,7 +13,7 @@ import { } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import Api, { DEFAULT_PER_PAGE } from '~/api'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_PAYLOAD_TOO_LARGE } from '~/lib/utils/http_status'; import { __, s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -145,7 +145,7 @@ export default { let message = ''; if (error?.response?.data?.message?.name) { message = this.$options.i18n.uploadErrorMessages.duplicate; - } else if (error.response.status === httpStatusCodes.PAYLOAD_TOO_LARGE) { + } else if (error.response.status === HTTP_STATUS_PAYLOAD_TOO_LARGE) { message = sprintf(this.$options.i18n.uploadErrorMessages.tooLarge, { limit: this.fileSizeLimit, }); diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue index c8f5ac1736d..4466a6a8081 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue @@ -46,6 +46,7 @@ export default { :id="graphqlId" :are-scoped-variables-available="areScopedVariablesAvailable" component-name="GroupVariables" + entity="group" :full-path="groupPath" :mutation-data="$options.mutationData" :query-data="$options.queryData" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue index 2c4818e20c1..6326940148a 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_project_variables.vue @@ -48,6 +48,7 @@ export default { :id="graphqlId" :are-scoped-variables-available="true" component-name="ProjectVariables" + entity="project" :full-path="projectFullPath" :mutation-data="$options.mutationData" :query-data="$options.queryData" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 94f8cb9e906..00177539cdc 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -29,6 +29,7 @@ import { ENVIRONMENT_SCOPE_LINK_TITLE, EVENT_LABEL, EVENT_ACTION, + EXPANDED_VARIABLES_NOTE, EDIT_VARIABLE_ACTION, VARIABLE_ACTIONS, variableOptions, @@ -46,6 +47,7 @@ export default { awsTipMessage: AWS_TIP_MESSAGE, containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, + expandedVariablesNote: EXPANDED_VARIABLES_NOTE, components: { CiEnvironmentsDropdown, GlAlert, @@ -127,7 +129,7 @@ export default { }, containsVariableReference() { const regex = /\$/; - return regex.test(this.variable.value); + return regex.test(this.variable.value) && this.isExpanded; }, displayMaskedError() { return !this.canMask && this.variable.masked; @@ -135,6 +137,9 @@ export default { isEditing() { return this.mode === EDIT_VARIABLE_ACTION; }, + isExpanded() { + return !this.variable.raw; + }, isTipVisible() { return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); }, @@ -208,6 +213,9 @@ export default { hideModal() { this.$refs.modal.hide(); }, + onShow() { + this.setVariableProtectedByDefault(); + }, resetModalHandler() { this.resetVariableData(); this.resetValidationErrorEvents(); @@ -220,6 +228,9 @@ export default { setEnvironmentScope(scope) { this.variable = { ...this.variable, environmentScope: scope }; }, + setVariableRaw(expanded) { + this.variable = { ...this.variable, raw: !expanded }; + }, setVariableProtected() { this.variable = { ...this.variable, protected: true }; }, @@ -275,7 +286,7 @@ export default { static lazy @hidden="resetModalHandler" - @shown="setVariableProtectedByDefault" + @shown="onShow" > <form> <gl-form-combobox @@ -304,6 +315,13 @@ export default { class="gl-font-monospace!" spellcheck="false" /> + <p + v-if="variable.raw" + class="gl-mt-2 gl-mb-0 text-secondary" + data-testid="raw-variable-tip" + > + {{ __('Variable value will be evaluated as raw string.') }} + </p> </gl-form-group> <div class="gl-display-flex"> @@ -361,7 +379,6 @@ export default { {{ __('Export variable to pipelines running on protected branches and tags only.') }} </p> </gl-form-checkbox> - <gl-form-checkbox ref="masked-ci-variable" v-model="variable.masked" @@ -371,7 +388,7 @@ export default { <gl-link target="_blank" :href="maskedEnvironmentVariablesLink"> <gl-icon name="question" :size="12" /> </gl-link> - <p class="gl-mt-2 gl-mb-0 text-secondary"> + <p class="gl-mt-2 text-secondary"> {{ __('Variable will be masked in job logs.') }} <span :class="{ @@ -385,6 +402,24 @@ export default { }}</gl-link> </p> </gl-form-checkbox> + <gl-form-checkbox + ref="expanded-ci-variable" + :checked="isExpanded" + data-testid="ci-variable-expanded-checkbox" + @change="setVariableRaw" + > + {{ __('Expand variable reference') }} + <gl-link target="_blank" :href="containsVariableReferenceLink"> + <gl-icon name="question" :size="12" /> + </gl-link> + <p class="gl-mt-2 gl-mb-0 gl-text-secondary"> + <gl-sprintf :message="$options.expandedVariablesNote"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </gl-form-checkbox> </gl-form-group> </form> <gl-collapse :visible="isTipVisible"> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue index 94fd6c3892c..3c6114b38ce 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue @@ -14,6 +14,11 @@ export default { required: false, default: false, }, + entity: { + type: String, + required: false, + default: '', + }, environments: { type: Array, required: false, @@ -27,7 +32,11 @@ export default { isLoading: { type: Boolean, required: false, - default: false, + }, + maxVariableLimit: { + type: Number, + required: false, + default: 0, }, variables: { type: Array, @@ -75,7 +84,9 @@ export default { <div class="row"> <div class="col-lg-12"> <ci-variable-table + :entity="entity" :is-loading="isLoading" + :max-variable-limit="maxVariableLimit" :variables="variables" @set-selected-variable="setSelectedVariable" /> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue index 7ee250cea98..6e39bda0b07 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_shared.vue @@ -26,6 +26,11 @@ export default { required: true, type: String, }, + entity: { + required: false, + type: String, + default: '', + }, fullPath: { required: false, type: String, @@ -90,6 +95,7 @@ export default { isInitialLoading: true, isLoadingMoreItems: false, loadingCounter: 0, + maxVariableLimit: 0, pageInfo: {}, }; }, @@ -107,6 +113,8 @@ export default { return this.queryData.ciVariables.lookup(data)?.nodes || []; }, result({ data }) { + this.maxVariableLimit = this.queryData.ciVariables.lookup(data)?.limit || 0; + this.pageInfo = this.queryData.ciVariables.lookup(data)?.pageInfo || this.pageInfo; this.hasNextPage = this.pageInfo?.hasNextPage || false; @@ -221,9 +229,11 @@ export default { <template> <ci-variable-settings :are-scoped-variables-available="areScopedVariablesAvailable" + :entity="entity" :hide-environment-scope="hideEnvironmentScope" :is-loading="isLoading" :variables="ciVariables" + :max-variable-limit="maxVariableLimit" :environments="environments" @add-variable="addVariable" @delete-variable="deleteVariable" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 3cdcb68e919..345a8def49d 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -1,8 +1,21 @@ <script> -import { GlButton, GlLoadingIcon, GlModalDirective, GlTable, GlTooltipDirective } from '@gitlab/ui'; -import { s__, __ } from '~/locale'; +import { + GlAlert, + GlButton, + GlLoadingIcon, + GlModalDirective, + GlTable, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { ADD_CI_VARIABLE_MODAL_ID, variableText } from '../constants'; +import { + ADD_CI_VARIABLE_MODAL_ID, + DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT, + EXCEEDS_VARIABLE_LIMIT_TEXT, + MAXIMUM_VARIABLE_LIMIT_REACHED, + variableText, +} from '../constants'; import { convertEnvironmentScope } from '../utils'; export default { @@ -41,6 +54,7 @@ export default { }, ], components: { + GlAlert, GlButton, GlLoadingIcon, GlTable, @@ -51,10 +65,19 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { + entity: { + type: String, + required: false, + default: '', + }, isLoading: { type: Boolean, required: true, }, + maxVariableLimit: { + type: Number, + required: true, + }, variables: { type: Array, required: true, @@ -66,6 +89,23 @@ export default { }; }, computed: { + exceedsVariableLimit() { + return this.maxVariableLimit > 0 && this.variables.length >= this.maxVariableLimit; + }, + exceedsVariableLimitText() { + if (this.exceedsVariableLimit && this.entity) { + return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { + entity: this.entity, + currentVariableCount: this.variables.length, + maxVariableLimit: this.maxVariableLimit, + }); + } + + return DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT; + }, + showAlert() { + return !this.isLoading && this.exceedsVariableLimit; + }, valuesButtonText() { return this.areValuesHidden ? __('Reveal values') : __('Hide values'); }, @@ -104,17 +144,29 @@ export default { if (item.masked) { options.push(s__('CiVariables|Masked')); } + if (!item.raw) { + options.push(s__('CiVariables|Expanded')); + } return options.join(', '); }, }, + maximumVariableLimitReached: MAXIMUM_VARIABLE_LIMIT_REACHED, }; </script> <template> <div class="ci-variable-table" data-testid="ci-variable-table"> <gl-loading-icon v-if="isLoading" /> + <gl-alert + v-if="showAlert" + :dismissible="false" + :title="$options.maximumVariableLimitReached" + variant="info" + > + {{ exceedsVariableLimitText }} + </gl-alert> <gl-table - v-else + v-if="!isLoading" :fields="fields" :items="variablesWithOptions" tbody-tr-class="js-ci-variable-row" @@ -178,7 +230,7 @@ export default { </div> </template> <template #cell(options)="{ item }"> - <span>{{ item.options }}</span> + <span data-testid="ci-variable-table-row-options">{{ item.options }}</span> </template> <template #cell(environmentScope)="{ item }"> <div @@ -215,6 +267,14 @@ export default { </p> </template> </gl-table> + <gl-alert + v-if="showAlert" + :dismissible="false" + :title="$options.maximumVariableLimitReached" + variant="info" + > + {{ exceedsVariableLimitText }} + </gl-alert> <div class="ci-variable-actions gl-display-flex gl-mt-5"> <gl-button v-gl-modal-directive="$options.modalId" @@ -223,6 +283,7 @@ export default { variant="confirm" category="primary" :aria-label="__('Add')" + :disabled="exceedsVariableLimit" @click="setSelectedVariable()" >{{ __('Add variable') }}</gl-button > diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index ccad08ef8b6..828d0724d93 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable'; @@ -43,6 +43,7 @@ export const defaultVariableState = { key: '', masked: false, protected: false, + raw: false, value: '', variableType: variableTypes.envType, }; @@ -69,10 +70,19 @@ export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY]; export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __( - 'Values that contain the %{codeStart}$%{codeEnd} character can be considered a variable reference and expanded. %{docsLinkStart}Learn more.%{docsLinkEnd}', + 'Unselect "Expand variable reference" if you want to use the variable value as a raw string.', ); export const ENVIRONMENT_SCOPE_LINK_TITLE = __('Learn more'); +export const EXCEEDS_VARIABLE_LIMIT_TEXT = s__( + 'CiVariables|This %{entity} has %{currentVariableCount} defined CI/CD variables. The maximum number of variables per %{entity} is %{maxVariableLimit}. To add new variables, you must reduce the number of defined variables.', +); +export const DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT = s__( + 'CiVariables|You have reached the maximum number of variables available. To add new variables, you must reduce the number of defined variables.', +); +export const MAXIMUM_VARIABLE_LIMIT_REACHED = s__( + 'CiVariables|Maximum number of variables reached.', +); export const ADD_VARIABLE_ACTION = 'ADD_VARIABLE'; export const EDIT_VARIABLE_ACTION = 'EDIT_VARIABLE'; @@ -85,6 +95,10 @@ export const ADD_MUTATION_ACTION = 'add'; export const UPDATE_MUTATION_ACTION = 'update'; export const DELETE_MUTATION_ACTION = 'delete'; +export const EXPANDED_VARIABLES_NOTE = __( + '%{codeStart}$%{codeEnd} will be treated as the start of a reference to another variable.', +); + export const environmentFetchErrorText = __( 'There was an error fetching the environments information.', ); diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql index c44ee2ecc1d..24388637672 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql @@ -16,6 +16,7 @@ mutation addGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPath: environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql index 53e9b411dd2..f7c8e209ccd 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql @@ -16,6 +16,7 @@ mutation deleteGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPa environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql index 2dddca14bd8..757e61a5cd3 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql @@ -16,6 +16,7 @@ mutation updateGroupVariable($variable: CiVariable!, $endpoint: String!, $fullPa environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql index 39504770e33..fa315084d86 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_add_variable.mutation.graphql @@ -16,6 +16,7 @@ mutation addProjectVariable($variable: CiVariable!, $endpoint: String!, $fullPat environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql index f55c255e332..c3358cc35b9 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_delete_variable.mutation.graphql @@ -21,6 +21,7 @@ mutation deleteProjectVariable( environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql index fc589e8a939..fde92cef4cb 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/project_update_variable.mutation.graphql @@ -21,6 +21,7 @@ mutation updateProjectVariable( environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql index b5555fe4401..900154cd24d 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql @@ -5,6 +5,7 @@ query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) { group(fullPath: $fullPath) { id ciVariables(after: $after, first: $first) { + limit pageInfo { ...PageInfo } @@ -14,6 +15,7 @@ query getGroupVariables($after: String, $first: Int = 100, $fullPath: ID!) { environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql index 08b5bf7af16..ee75eba7547 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/project_variables.query.graphql @@ -5,6 +5,7 @@ query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) { project(fullPath: $fullPath) { id ciVariables(after: $after, first: $first) { + limit pageInfo { ...PageInfo } @@ -13,6 +14,7 @@ query getProjectVariables($after: String, $first: Int = 100, $fullPath: ID!) { environmentScope masked protected + raw } } } diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql index 2667d6606fe..9b255c3c182 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/variables.query.graphql @@ -11,6 +11,7 @@ query getVariables($after: String, $first: Int = 100) { ... on CiInstanceVariable { masked protected + raw } } } diff --git a/app/assets/javascripts/clusters_list/clusters_util.js b/app/assets/javascripts/clusters_list/clusters_util.js index ee36a295513..25a8426500e 100644 --- a/app/assets/javascripts/clusters_list/clusters_util.js +++ b/app/assets/javascripts/clusters_list/clusters_util.js @@ -1,10 +1,14 @@ -import { ACTIVE_CONNECTION_TIME } from './constants'; +import { ACTIVE_CONNECTION_TIME, NAME_MAX_LENGTH } from './constants'; + +function getTruncatedName(name) { + return name.substring(0, NAME_MAX_LENGTH); +} export function generateAgentRegistrationCommand({ name, token, version, address }) { return `helm repo add gitlab https://charts.gitlab.io helm repo update helm upgrade --install ${name} gitlab/gitlab-agent \\ - --namespace gitlab-agent \\ + --namespace gitlab-agent-${getTruncatedName(name)} \\ --create-namespace \\ --set image.tag=v${version} \\ --set config.token=${token} \\ diff --git a/app/assets/javascripts/clusters_list/components/agent_token.vue b/app/assets/javascripts/clusters_list/components/agent_token.vue index 4dd6d84566c..93c37226a09 100644 --- a/app/assets/javascripts/clusters_list/components/agent_token.vue +++ b/app/assets/javascripts/clusters_list/components/agent_token.vue @@ -1,22 +1,24 @@ <script> -import { GlAlert, GlFormInputGroup, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlFormInputGroup, GlLink, GlSprintf, GlIcon } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue'; import { generateAgentRegistrationCommand } from '../clusters_util'; -import { I18N_AGENT_TOKEN } from '../constants'; +import { I18N_AGENT_TOKEN, HELM_VERSION_POLICY_URL } from '../constants'; export default { i18n: I18N_AGENT_TOKEN, advancedInstallPath: helpPagePath('user/clusters/agent/install/index', { anchor: 'advanced-installation-method', }), + HELM_VERSION_POLICY_URL, components: { GlAlert, CodeBlock, GlFormInputGroup, GlLink, GlSprintf, + GlIcon, ModalCopyButton, }, inject: ['kasAddress', 'kasVersion'], @@ -77,6 +79,11 @@ export default { <p> {{ $options.i18n.basicInstallBody }} + <gl-sprintf :message="$options.i18n.helmVersionText"> + <template #link="{ content }" + ><gl-link :href="$options.HELM_VERSION_POLICY_URL" target="_blank" + >{{ content }} <gl-icon name="external-link" :size="12" /></gl-link></template + ></gl-sprintf> </p> <p class="gl-display-flex gl-align-items-flex-start"> diff --git a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue index bde76c46b4b..365e0384d87 100644 --- a/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue +++ b/app/assets/javascripts/clusters_list/components/available_agents_dropdown.vue @@ -1,23 +1,13 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlDropdownText, - GlSearchBoxByType, - GlSprintf, -} from '@gitlab/ui'; +import { GlCollapsibleListbox, GlButton, GlSprintf } from '@gitlab/ui'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '../constants'; export default { name: 'AvailableAgentsDropdown', i18n: I18N_AVAILABLE_AGENTS_DROPDOWN, components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, + GlButton, GlSprintf, }, props: { @@ -46,13 +36,21 @@ export default { return this.selectedAgent; }, + dropdownItems() { + return this.availableAgents.map((agent) => { + return { + value: agent, + text: agent, + }; + }); + }, shouldRenderCreateButton() { return this.searchTerm && !this.availableAgents.includes(this.searchTerm); }, filteredResults() { const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); - return this.availableAgents.filter((resultString) => - resultString.toLowerCase().includes(lowerCasedSearchTerm), + return this.dropdownItems.filter((item) => + item.value.toLowerCase().includes(lowerCasedSearchTerm), ); }, }, @@ -60,59 +58,48 @@ export default { selectAgent(agent) { this.$emit('agentSelected', agent); this.selectedAgent = agent; - this.clearSearch(); - }, - isSelected(agent) { - return this.selectedAgent === agent; - }, - clearSearch() { - this.searchTerm = ''; - }, - focusSearch() { - this.$refs.searchInput.focusInput(); - }, - handleShow() { - this.clearSearch(); - this.focusSearch(); + + this.$refs.dropdown.closeAndFocus(); }, onKeyEnter() { if (!this.searchTerm?.length) { return; } - this.$refs.dropdown.hide(); this.selectAgent(this.searchTerm); }, + searchAgent(searchQuery) { + this.searchTerm = searchQuery; + }, }, }; </script> <template> - <gl-dropdown ref="dropdown" :text="dropdownText" :loading="isRegistering" @shown="handleShow"> - <template #header> - <gl-search-box-by-type - ref="searchInput" - v-model.trim="searchTerm" - @keydown.enter.stop.prevent="onKeyEnter" - /> - </template> - <gl-dropdown-item - v-for="agent in filteredResults" - :key="agent" - :is-checked="isSelected(agent)" - is-check-item - @click="selectAgent(agent)" + <div @keydown.enter.stop.prevent="onKeyEnter"> + <gl-collapsible-listbox + ref="dropdown" + v-model="selectedAgent" + class="gl-w-full" + toggle-class="select-agent-dropdown" + :items="filteredResults" + :toggle-text="dropdownText" + :loading="isRegistering" + :searchable="true" + :no-results-text="$options.i18n.noResults" + @search="searchAgent" + @select="selectAgent" > - {{ agent }} - </gl-dropdown-item> - <gl-dropdown-text v-if="!filteredResults.length" ref="noMatchingResults">{{ - $options.i18n.noResults - }}</gl-dropdown-text> - <template v-if="shouldRenderCreateButton"> - <gl-dropdown-divider /> - <gl-dropdown-item data-testid="create-config-button" @click="selectAgent(searchTerm)"> - <gl-sprintf :message="$options.i18n.createButton"> - <template #searchTerm>{{ searchTerm }}</template> - </gl-sprintf> - </gl-dropdown-item> - </template> - </gl-dropdown> + <template v-if="shouldRenderCreateButton" #footer> + <gl-button + category="tertiary" + class="gl-justify-content-start! gl-border-t-1! gl-border-t-solid gl-border-t-gray-200 gl-pl-7! gl-rounded-top-left-none! gl-rounded-top-right-none!" + :class="{ 'gl-mt-3': !filteredResults.length }" + @click="selectAgent(searchTerm)" + > + <gl-sprintf :message="$options.i18n.createButton"> + <template #searchTerm>{{ searchTerm }}</template> + </gl-sprintf> + </gl-button> + </template> + </gl-collapsible-listbox> + </div> </template> diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js index 7bc8a1a7304..615754459d6 100644 --- a/app/assets/javascripts/clusters_list/constants.js +++ b/app/assets/javascripts/clusters_list/constants.js @@ -4,6 +4,7 @@ import { helpPagePath } from '~/helpers/help_page_helper'; export const MAX_LIST_COUNT = 25; export const INSTALL_AGENT_MODAL_ID = 'install-agent'; export const ACTIVE_CONNECTION_TIME = 480000; +export const NAME_MAX_LENGTH = 50; export const CLUSTER_ERRORS = { default: { @@ -100,6 +101,9 @@ export const I18N_AGENT_TOKEN = { basicInstallBody: s__( 'ClusterAgents|From a terminal, connect to your cluster and run this command. The token is included in the command.', ), + helmVersionText: s__( + 'ClusterAgents|Use a Helm version compatible with your Kubernetes version (see %{linkStart}Helm version support policy%{linkEnd}).', + ), advancedInstallTitle: s__('ClusterAgents|Advanced installation methods'), advancedInstallBody: s__( @@ -107,6 +111,8 @@ export const I18N_AGENT_TOKEN = { ), }; +export const HELM_VERSION_POLICY_URL = 'https://helm.sh/docs/topics/version_skew/'; + export const I18N_AGENT_MODAL = { registerAgentButton: s__('ClusterAgents|Register'), close: __('Close'), diff --git a/app/assets/javascripts/constants.js b/app/assets/javascripts/constants.js new file mode 100644 index 00000000000..c56d45166a0 --- /dev/null +++ b/app/assets/javascripts/constants.js @@ -0,0 +1,3 @@ +import { s__ } from '~/locale'; + +export const MODIFIER_KEY = window.gl?.client?.isMac ? '⌘' : s__('KeyboardKey|Ctrl+'); diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue index a9668ebdb69..98b7203778f 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block_bubble_menu.vue @@ -166,9 +166,7 @@ export default { icon="arrow-left" @click.prevent.stop="showCustomLanguageInput = false" /> - <p - class="gl-text-center gl-new-dropdown-header-top gl-mb-0! gl-border-none! gl-pb-1!" - > + <p class="gl-text-center gl-dropdown-header-top gl-mb-0! gl-border-none! gl-pb-1!"> {{ __('Create custom type') }} </p> </div> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 22381377389..53a37fc0c51 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -11,7 +11,7 @@ import FormattingBubbleMenu from './bubble_menus/formatting_bubble_menu.vue'; import CodeBlockBubbleMenu from './bubble_menus/code_block_bubble_menu.vue'; import LinkBubbleMenu from './bubble_menus/link_bubble_menu.vue'; import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue'; -import TopToolbar from './top_toolbar.vue'; +import FormattingToolbar from './formatting_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; export default { @@ -20,7 +20,7 @@ export default { ContentEditorAlert, ContentEditorProvider, TiptapEditorContent, - TopToolbar, + FormattingToolbar, FormattingBubbleMenu, CodeBlockBubbleMenu, LinkBubbleMenu, @@ -57,6 +57,11 @@ export default { default: false, validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus), }, + useBottomToolbar: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -163,8 +168,8 @@ export default { class="md-area" :class="{ 'is-focused': focused }" > - <top-toolbar ref="toolbar" class="gl-mb-4" /> - <div class="gl-relative"> + <formatting-toolbar v-if="!useBottomToolbar" ref="toolbar" class="gl-border-b" /> + <div class="gl-relative gl-mt-4"> <formatting-bubble-menu /> <code-block-bubble-menu /> <link-bubble-menu /> @@ -176,6 +181,7 @@ export default { /> <loading-indicator v-if="isLoading" /> </div> + <formatting-toolbar v-if="useBottomToolbar" ref="toolbar" class="gl-border-t" /> </div> </div> </content-editor-provider> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue index 460368b6a11..8a25ad3fd96 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue @@ -24,9 +24,7 @@ export default { }; </script> <template> - <div - class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-3 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" - > + <div class="gl-display-flex gl-flex-wrap gl-pb-3 gl-pt-3"> <toolbar-text-style-dropdown data-testid="text-styles" class="gl-mr-3" diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue index 001b34a00fa..37e6ef61d50 100644 --- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -210,10 +210,10 @@ export default { <template> <ul :class="{ show: items.length > 0 }" - class="gl-new-dropdown dropdown-menu gl-relative" + class="gl-dropdown dropdown-menu gl-relative" data-testid="content-editor-suggestions-dropdown" > - <div class="gl-new-dropdown-inner gl-overflow-y-auto"> + <div class="gl-dropdown-inner gl-overflow-y-auto"> <gl-dropdown-item v-for="(item, index) in items" ref="dropdownItems" diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue index 6bb122153ef..93b31ea7d20 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -58,6 +58,9 @@ export default { right lazy > + <gl-dropdown-item @click="insert('comment')"> + {{ __('Comment') }} + </gl-dropdown-item> <gl-dropdown-item @click="insert('codeBlock')"> {{ __('Code block') }} </gl-dropdown-item> diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 27432b1e18b..1d85bfcc965 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -23,6 +23,10 @@ export default CodeBlockLowlight.extend({ // eslint-disable-next-line @gitlab/require-i18n-strings default: 'code highlight', }, + langParams: { + default: null, + parseHTML: (element) => element.dataset.langParams, + }, }; }, addInputRules() { diff --git a/app/assets/javascripts/content_editor/extensions/comment.js b/app/assets/javascripts/content_editor/extensions/comment.js new file mode 100644 index 00000000000..8e247e552a3 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/comment.js @@ -0,0 +1,49 @@ +import { Node, textblockTypeInputRule } from '@tiptap/core'; + +export const commentInputRegex = /^<!--[\s\n]$/; + +export default Node.create({ + name: 'comment', + content: 'text*', + marks: '', + group: 'block', + code: true, + isolating: true, + defining: true, + + parseHTML() { + return [ + { + tag: 'comment', + preserveWhitespace: 'full', + getContent(element, schema) { + const node = schema.node('paragraph', {}, [ + schema.text( + element.textContent.replace(/&#x([0-9A-F]{2,4});/gi, (_, code) => + String.fromCharCode(parseInt(code, 16)), + ) || ' ', + ), + ]); + return node.content; + }, + }, + ]; + }, + + renderHTML() { + return [ + 'pre', + { class: 'gl-p-0 gl-border-0 gl-bg-transparent gl-text-gray-300' }, + ['span', { class: 'content-editor-comment' }, 0], + ]; + }, + + addInputRules() { + return [ + textblockTypeInputRule({ + find: commentInputRegex, + type: this.type, + }), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 65849ec4d0d..fc4c108b773 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -52,6 +52,22 @@ export default Image.extend({ return img.getAttribute('title'); }, }, + width: { + default: null, + parseHTML: (element) => { + const img = resolveImageEl(element); + + return img.getAttribute('width'); + }, + }, + height: { + default: null, + parseHTML: (element) => { + const img = resolveImageEl(element); + + return img.getAttribute('height'); + }, + }, isReference: { default: false, renderHTML: () => '', @@ -76,6 +92,8 @@ export default Image.extend({ src: HTMLAttributes.src, alt: HTMLAttributes.alt, title: HTMLAttributes.title, + width: HTMLAttributes.width, + height: HTMLAttributes.height, }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/reference_label.js b/app/assets/javascripts/content_editor/extensions/reference_label.js index 716e191c3d5..9dff0b7a689 100644 --- a/app/assets/javascripts/content_editor/extensions/reference_label.js +++ b/app/assets/javascripts/content_editor/extensions/reference_label.js @@ -1,5 +1,5 @@ import { VueNodeViewRenderer } from '@tiptap/vue-2'; -import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants'; import LabelWrapper from '../components/wrappers/label.vue'; import Reference from './reference'; diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index ba9ce705c62..61c6be574d0 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -10,6 +10,7 @@ import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; import ColorChip from '../extensions/color_chip'; +import Comment from '../extensions/comment'; import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; @@ -100,6 +101,7 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, + Comment, CodeBlockHighlight, DescriptionItem, DescriptionList, diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js index fa46bd9ff81..796dc06ad93 100644 --- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js @@ -1,4 +1,5 @@ import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; +import { replaceCommentsWith } from '~/lib/utils/dom_utils'; export default ({ render }) => { /** @@ -22,7 +23,9 @@ export default ({ render }) => { if (!html) return {}; const parser = new DOMParser(); - const { body } = parser.parseFromString(html, 'text/html'); + const { body } = parser.parseFromString(`<body>${html}</body>`, 'text/html'); + + replaceCommentsWith(body, 'comment'); // append original source as a comment that nodes can access body.append(document.createComment(markdown)); diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 958c27c281a..4e29f85004b 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -12,6 +12,7 @@ import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; +import Comment from '../extensions/comment'; import Diagram from '../extensions/diagram'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; @@ -50,6 +51,7 @@ import Text from '../extensions/text'; import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { + renderComment, renderCodeBlock, renderHardBreak, renderTable, @@ -130,6 +132,7 @@ const defaultSerializerConfig = { }), [BulletList.name]: preserveUnchanged(renderBulletList), [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), + [Comment.name]: renderComment, [Diagram.name]: preserveUnchanged(renderCodeBlock), [DescriptionList.name]: renderHTMLNode('dl', true), [DescriptionItem.name]: (state, node, parent, index) => { diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 5c0cb21075a..131c79357bf 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -308,7 +308,7 @@ export function renderHardBreak(state, node, parent, index) { } export function renderImage(state, node) { - const { alt, canonicalSrc, src, title, isReference } = node.attrs; + const { alt, canonicalSrc, src, title, width, height, isReference } = node.attrs; if (isString(src) || isString(canonicalSrc)) { const quotedTitle = title ? ` ${state.quote(title)}` : ''; @@ -316,7 +316,17 @@ export function renderImage(state, node) { ? `[${canonicalSrc}]` : `(${state.esc(canonicalSrc || src)}${quotedTitle})`; - state.write(`![${state.esc(alt || '')}]${sourceExpression}`); + const sizeAttributes = []; + if (width) { + sizeAttributes.push(`width=${JSON.stringify(width)}`); + } + if (height) { + sizeAttributes.push(`height=${JSON.stringify(height)}`); + } + + const attributes = sizeAttributes.length ? `{${sizeAttributes.join(' ')}}` : ''; + + state.write(`![${state.esc(alt || '')}]${sourceExpression}${attributes}`); } } @@ -324,8 +334,19 @@ export function renderPlayable(state, node) { renderImage(state, node); } +export function renderComment(state, node) { + state.text('<!--'); + state.text(node.textContent); + state.text('-->'); + state.closeBlock(node); +} + export function renderCodeBlock(state, node) { - state.write(`\`\`\`${node.attrs.language || ''}\n`); + state.write( + `\`\`\`${ + (node.attrs.language || '') + (node.attrs.langParams ? `:${node.attrs.langParams}` : '') + }\n`, + ); state.text(node.textContent, false); state.ensureNewLine(); state.write('```'); diff --git a/app/assets/javascripts/crm/components/form.vue b/app/assets/javascripts/crm/components/crm_form.vue index ea6a6892bbd..ea6a6892bbd 100644 --- a/app/assets/javascripts/crm/components/form.vue +++ b/app/assets/javascripts/crm/components/crm_form.vue diff --git a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue index b29089519e2..a851c7a9e85 100644 --- a/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue +++ b/app/assets/javascripts/crm/contacts/components/contact_form_wrapper.vue @@ -2,7 +2,7 @@ import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_CRM_CONTACT, TYPE_GROUP } from '~/graphql_shared/constants'; -import ContactForm from '../../components/form.vue'; +import CrmForm from '../../components/crm_form.vue'; import getGroupOrganizationsQuery from '../../organizations/components/graphql/get_group_organizations.query.graphql'; import getGroupContactsQuery from './graphql/get_group_contacts.query.graphql'; import createContactMutation from './graphql/create_contact.mutation.graphql'; @@ -10,7 +10,7 @@ import updateContactMutation from './graphql/update_contact.mutation.graphql'; export default { components: { - ContactForm, + CrmForm, }, inject: ['groupFullPath', 'groupId'], props: { @@ -111,7 +111,7 @@ export default { </script> <template> - <contact-form + <crm-form :drawer-open="true" :get-query="getQuery" get-query-node-path="group.contacts" diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue index 32900d45f22..01bff4b69d6 100644 --- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue +++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue @@ -2,14 +2,14 @@ import { s__, __ } from '~/locale'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_CRM_ORGANIZATION, TYPE_GROUP } from '~/graphql_shared/constants'; -import OrganizationForm from '../../components/form.vue'; +import CrmForm from '../../components/crm_form.vue'; import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql'; import createOrganizationMutation from './graphql/create_organization.mutation.graphql'; import updateOrganizationMutation from './graphql/update_organization.mutation.graphql'; export default { components: { - OrganizationForm, + CrmForm, }, inject: ['groupFullPath', 'groupId'], props: { @@ -73,7 +73,7 @@ export default { </script> <template> - <organization-form + <crm-form :drawer-open="true" :get-query="getQuery" get-query-node-path="group.organizations" diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue index 411e482b0ce..c6aeb6c726d 100644 --- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form_fields.vue @@ -185,7 +185,6 @@ export default { name="prometheus_metric[title]" class="form-control" :placeholder="s__('Metrics|e.g. Throughput')" - data-qa-selector="custom_metric_prometheus_title_field" required /> <span class="form-text text-muted">{{ s__('Metrics|Used as a title for the chart') }}</span> @@ -209,7 +208,6 @@ export default { <gl-form-input id="prometheus_metric_query" v-model.trim="query" - data-qa-selector="custom_metric_prometheus_query_field" name="prometheus_metric[query]" class="form-control" :placeholder="s__('Metrics|e.g. rate(http_requests_total[5m])')" @@ -247,7 +245,6 @@ export default { <gl-form-input id="prometheus_metric_y_label" v-model="yLabel" - data-qa-selector="custom_metric_prometheus_y_label_field" name="prometheus_metric[y_label]" class="form-control" :placeholder="s__('Metrics|e.g. Requests/second')" @@ -267,7 +264,6 @@ export default { <gl-form-input id="prometheus_metric_unit" v-model="unit" - data-qa-selector="custom_metric_prometheus_unit_label_field" name="prometheus_metric[unit]" class="form-control" :placeholder="s__('Metrics|e.g. req/sec')" @@ -282,7 +278,6 @@ export default { <gl-form-input id="prometheus_metric_legend" v-model="legend" - data-qa-selector="custom_metric_prometheus_legend_label_field" name="prometheus_metric[legend]" class="form-control" :placeholder="s__('Metrics|e.g. HTTP requests')" diff --git a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue index 81d74c64124..48ab9ce0a3c 100644 --- a/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue +++ b/app/assets/javascripts/deploy_tokens/components/new_deploy_token.vue @@ -13,27 +13,7 @@ import { createAlert, VARIANT_INFO } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { formatDate } from '~/lib/utils/datetime_utility'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { s__ } from '~/locale'; - -function defaultData() { - return { - expiresAt: null, - name: '', - newTokenDetails: null, - readRepository: false, - writeRepository: false, - readRegistry: false, - writeRegistry: false, - readPackageRegistry: false, - writePackageRegistry: false, - username: '', - placeholders: { - link: { link: ['link_start', 'link_end'] }, - i: { i: ['i_start', 'i_end'] }, - code: { code: ['code_start', 'code_end'] }, - }, - }; -} +import translations from '../deploy_token_translations'; export default { components: { @@ -72,45 +52,9 @@ export default { }, data() { - return defaultData(); - }, - translations: { - addTokenButton: s__('DeployTokens|Create deploy token'), - addTokenExpiryLabel: s__('DeployTokens|Expiration date (optional)'), - addTokenExpiryDescription: s__( - 'DeployTokens|Enter an expiration date for your token. Defaults to never expire.', - ), - addTokenHeader: s__('DeployTokens|New deploy token'), - addTokenDescription: s__( - 'DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}', - ), - addTokenNameLabel: s__('DeployTokens|Name'), - addTokenNameDescription: s__('DeployTokens|Enter a unique name for your deploy token.'), - addTokenScopesLabel: s__('DeployTokens|Scopes (select at least one)'), - addTokenUsernameDescription: s__( - 'DeployTokens|Enter a username for your token. Defaults to %{code_start}gitlab+deploy-token-{n}%{code_end}.', - ), - addTokenUsernameLabel: s__('DeployTokens|Username (optional)'), - newTokenCopyMessage: s__('DeployTokens|Copy deploy token'), - newProjectTokenCreated: s__('DeployTokens|Your new project deploy token has been created.'), - newGroupTokenCreated: s__('DeployTokens|Your new group deploy token has been created.'), - newTokenDescription: s__( - 'DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.', - ), - newTokenMessage: s__('DeployTokens|Your New Deploy Token'), - newTokenUsernameCopy: s__('DeployTokens|Copy username'), - newTokenUsernameDescription: s__( - 'DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}', - ), - readRepositoryHelp: s__('DeployTokens|Allows read-only access to the repository.'), - readRegistryHelp: s__('DeployTokens|Allows read-only access to registry images.'), - writeRegistryHelp: s__('DeployTokens|Allows read and write access to registry images.'), - readPackageRegistryHelp: s__('DeployTokens|Allows read-only access to the package registry.'), - writePackageRegistryHelp: s__( - 'DeployTokens|Allows read and write access to the package registry.', - ), - createTokenFailedAlert: s__('DeployTokens|Failed to create a new deployment token'), + return this.defaultData(); }, + translations, computed: { formattedExpiryDate() { return this.expiresAt ? formatDate(this.expiresAt, 'yyyy-mm-dd') : ''; @@ -122,20 +66,78 @@ export default { }, }, methods: { + defaultData() { + return { + expiresAt: null, + name: '', + newTokenDetails: null, + readRepository: false, + writeRepository: false, + readRegistry: false, + writeRegistry: false, + readPackageRegistry: false, + writePackageRegistry: false, + scopes: [ + { + id: 'deploy_token_read_repository', + isShown: true, + value: false, + helpText: this.$options.translations.readRepositoryHelp, + scopeName: 'read_repository', + }, + { + id: 'deploy_token_read_registry', + isShown: this.$props.containerRegistryEnabled, + value: false, + helpText: this.$options.translations.readRegistryHelp, + scopeName: 'read_registry', + }, + { + id: 'deploy_token_write_registry', + isShown: this.$props.containerRegistryEnabled, + value: false, + helpText: this.$options.translations.writeRegistryHelp, + scopeName: 'write_registry', + }, + { + id: 'deploy_token_read_package_registry', + isShown: this.$props.packagesRegistryEnabled, + value: false, + helpText: this.$options.translations.readPackageRegistryHelp, + scopeName: 'read_package_registry', + }, + { + id: 'deploy_token_write_package_registry', + isShown: this.$props.packagesRegistryEnabled, + value: false, + helpText: this.$options.translations.writePackageRegistryHelp, + scopeName: 'write_package_registry', + }, + ], + username: '', + placeholders: { + link: { link: ['link_start', 'link_end'] }, + i: { i: ['i_start', 'i_end'] }, + code: { code: ['code_start', 'code_end'] }, + }, + }; + }, createDeployToken() { + const scopes = {}; + this.scopes.forEach((scope) => { + scopes[scope.scopeName] = scope.value; + }); + const body = { + deploy_token: { + expires_at: this.expiresAt, + name: this.name, + username: this.username, + ...scopes, + }, + }; + return axios - .post(this.createNewTokenPath, { - deploy_token: { - expires_at: this.expiresAt, - name: this.name, - read_repository: this.readRepository, - read_registry: this.readRegistry, - write_registry: this.writeRegistry, - read_package_registry: this.readPackageRegistry, - write_package_registry: this.writePackageRegistry, - username: this.username, - }, - }) + .post(this.createNewTokenPath, body) .then((response) => { this.newTokenDetails = response.data; this.resetData(); @@ -152,7 +154,7 @@ export default { }); }, resetData() { - const newData = defaultData(); + const newData = this.defaultData(); delete newData.newTokenDetails; Object.keys(newData).forEach((k) => { this[k] = newData[k]; @@ -269,55 +271,19 @@ export default { > <div id="deploy-token-scopes"> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> - <gl-form-checkbox - id="deploy_token_read_repository" - v-model="readRepository" - name="deploy_token_read_repository" - data-qa-selector="deploy_token_read_repository_checkbox" - > - read_repository - <template #help>{{ $options.translations.readRepositoryHelp }}</template> - </gl-form-checkbox> - <gl-form-checkbox - v-if="containerRegistryEnabled" - id="deploy_token_read_registry" - v-model="readRegistry" - name="deploy_token_read_registry" - data-qa-selector="deploy_token_read_registry_checkbox" - > - read_registry - <template #help>{{ $options.translations.readRegistryHelp }}</template> - </gl-form-checkbox> - <gl-form-checkbox - v-if="containerRegistryEnabled" - id="deploy_token_write_registry" - v-model="writeRegistry" - name="deploy_token_write_registry" - data-qa-selector="deploy_token_write_registry_checkbox" - > - write_registry - <template #help>{{ $options.translations.writeRegistryHelp }}</template> - </gl-form-checkbox> - <gl-form-checkbox - v-if="packagesRegistryEnabled" - id="deploy_token_read_package_registry" - v-model="readPackageRegistry" - name="deploy_token_read_package_registry" - data-qa-selector="deploy_token_read_package_registry_checkbox" - > - read_package_registry - <template #help>{{ $options.translations.readPackageRegistryHelp }}</template> - </gl-form-checkbox> - <gl-form-checkbox - v-if="packagesRegistryEnabled" - id="deploy_token_write_package_registry" - v-model="writePackageRegistry" - name="deploy_token_write_package_registry" - data-qa-selector="deploy_token_write_package_registry_checkbox" - > - write_package_registry - <template #help>{{ $options.translations.writePackageRegistryHelp }}</template> - </gl-form-checkbox> + <template v-for="scope in scopes"> + <gl-form-checkbox + v-if="scope.isShown" + :id="scope.id" + :key="scope.id" + v-model="scope.value" + :name="scope.id" + :data-qa-selector="`${scope.id}_checkbox`" + > + {{ scope.scopeName }} + <template #help>{{ scope.helpText }}</template> + </gl-form-checkbox> + </template> <!-- eslint-enable @gitlab/vue-require-i18n-strings --> </div> </gl-form-group> diff --git a/app/assets/javascripts/deploy_tokens/deploy_token_translations.js b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js new file mode 100644 index 00000000000..3767e9e6170 --- /dev/null +++ b/app/assets/javascripts/deploy_tokens/deploy_token_translations.js @@ -0,0 +1,41 @@ +import { s__ } from '~/locale'; + +const translations = { + addTokenButton: s__('DeployTokens|Create deploy token'), + addTokenExpiryLabel: s__('DeployTokens|Expiration date (optional)'), + addTokenExpiryDescription: s__( + 'DeployTokens|Enter an expiration date for your token. Defaults to never expire.', + ), + addTokenHeader: s__('DeployTokens|New deploy token'), + addTokenDescription: s__( + 'DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}', + ), + addTokenNameLabel: s__('DeployTokens|Name'), + addTokenNameDescription: s__('DeployTokens|Enter a unique name for your deploy token.'), + addTokenScopesLabel: s__('DeployTokens|Scopes (select at least one)'), + addTokenUsernameDescription: s__( + 'DeployTokens|Enter a username for your token. Defaults to %{code_start}gitlab+deploy-token-{n}%{code_end}.', + ), + addTokenUsernameLabel: s__('DeployTokens|Username (optional)'), + newTokenCopyMessage: s__('DeployTokens|Copy deploy token'), + newProjectTokenCreated: s__('DeployTokens|Your new project deploy token has been created.'), + newGroupTokenCreated: s__('DeployTokens|Your new group deploy token has been created.'), + newTokenDescription: s__( + 'DeployTokens|Use this token as a password. Save it. This password can %{i_start}not%{i_end} be recovered.', + ), + newTokenMessage: s__('DeployTokens|Your New Deploy Token'), + newTokenUsernameCopy: s__('DeployTokens|Copy username'), + newTokenUsernameDescription: s__( + 'DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}', + ), + readRepositoryHelp: s__('DeployTokens|Allows read-only access to the repository.'), + readRegistryHelp: s__('DeployTokens|Allows read-only access to registry images.'), + writeRegistryHelp: s__('DeployTokens|Allows read and write access to registry images.'), + readPackageRegistryHelp: s__('DeployTokens|Allows read-only access to the package registry.'), + writePackageRegistryHelp: s__( + 'DeployTokens|Allows read and write access to the package registry.', + ), + createTokenFailedAlert: s__('DeployTokens|Failed to create a new deployment token'), +}; + +export default translations; diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/render.js b/app/assets/javascripts/deprecated_jquery_dropdown/render.js index 0f612989bb4..97698d55011 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/render.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/render.js @@ -149,7 +149,7 @@ function renderLink(row, data, { options, group, index }) { } function getOptionRenderer({ options, instance }) { - return options.renderRow && ((li, data) => options.renderRow(data, instance)); + return options.renderRow && ((li, data, params) => options.renderRow(data, instance, params)); } function getRenderer(data, params) { diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 2ac62b9b927..c090a66a69d 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -15,6 +15,7 @@ import Autosize from 'autosize'; import $ from 'jquery'; import { escape, uniqueId } from 'lodash'; import Vue from 'vue'; +import { createAlert, VARIANT_INFO } from '~/flash'; import '~/lib/utils/jquery_at_who'; import AjaxCache from '~/lib/utils/ajax_cache'; import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js'; @@ -24,7 +25,6 @@ import * as constants from '~/notes/constants'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import Autosave from './autosave'; import loadAwardsHandler from './awards_handler'; -import createFlash from './flash'; import { defaultAutocompleteConfig } from './gfm_auto_complete'; import GLForm from './gl_form'; import axios from './lib/utils/axios_utils'; @@ -40,6 +40,7 @@ import { localTimeAgo } from './lib/utils/datetime_utility'; import { getLocationHash } from './lib/utils/url_utility'; import { sprintf, s__, __ } from './locale'; import TaskList from './task_list'; +import '~/behaviors/markdown/init_gfm'; window.autosize = Autosize; @@ -81,7 +82,7 @@ export default class Notes { this.keydownNoteText = this.keydownNoteText.bind(this); this.toggleCommitList = this.toggleCommitList.bind(this); this.postComment = this.postComment.bind(this); - this.clearFlashWrapper = this.clearFlash.bind(this); + this.clearAlertWrapper = this.clearAlert.bind(this); this.onHashChange = this.onHashChange.bind(this); this.notes_url = notes_url; @@ -431,9 +432,9 @@ export default class Notes { if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) { $notesList.find('.system-note.being-posted').remove(); } - this.addFlash({ + this.addAlert({ message: noteEntity.errors.commands_only, - type: 'notice', + variant: VARIANT_INFO, parent: this.parentTimeline.get(0), }); this.refresh(); @@ -656,7 +657,7 @@ export default class Notes { } else if ($form.hasClass('js-discussion-note-form')) { formParentTimeline = $form.closest('.discussion-notes').find('.notes'); } - return this.addFlash({ + return this.addAlert({ message: __( 'Your comment could not be submitted! Please check your network connection and try again.', ), @@ -665,7 +666,7 @@ export default class Notes { } updateNoteError() { - createFlash({ + createAlert({ message: __( 'Your comment could not be updated! Please check your network connection and try again.', ), @@ -1338,15 +1339,12 @@ export default class Notes { }); } - addFlash(...flashParams) { - this.flashContainer = createFlash(...flashParams); + addAlert(...alertParams) { + this.alert = createAlert(...alertParams); } - clearFlash() { - if (this.flashContainer) { - this.flashContainer.style.display = 'none'; - this.flashContainer = null; - } + clearAlert() { + this.alert?.dismiss(); } cleanForm($form) { @@ -1535,7 +1533,7 @@ export default class Notes { * b. Reset comment form to original state. * b) If request failed * 1. Remove placeholder element - * 2. Show error Flash message about failure + * 2. Show error alert message about failure */ postComment(e) { e.preventDefault(); @@ -1645,7 +1643,7 @@ export default class Notes { } // Clear previous form errors - this.clearFlashWrapper(); + this.clearAlertWrapper(); // Check if this was discussion comment if (isDiscussionForm) { diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index a4430b15752..3091c6703b4 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -4,7 +4,7 @@ import { ApolloMutation } from 'vue-apollo'; import { createAlert } from '~/flash'; import { s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; -import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; +import { updateGlobalTodoCount } from '~/sidebar/utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue'; import { isLoggedIn } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index e629f74ba02..af4bf7eb14d 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -1,13 +1,7 @@ <script> -import { - GlAvatar, - GlAvatarLink, - GlButton, - GlLink, - GlSafeHtmlDirective, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink, GlButton, GlLink, GlTooltipDirective } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; @@ -33,7 +27,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { note: { diff --git a/app/assets/javascripts/design_management/components/design_todo_button.vue b/app/assets/javascripts/design_management/components/design_todo_button.vue index 013dd1d89f3..a1a23d61093 100644 --- a/app/assets/javascripts/design_management/components/design_todo_button.vue +++ b/app/assets/javascripts/design_management/components/design_todo_button.vue @@ -1,6 +1,6 @@ <script> import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; -import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; +import TodoButton from '~/sidebar/components/todo_toggle/todo_button.vue'; import createDesignTodoMutation from '../graphql/mutations/create_design_todo.mutation.graphql'; import getDesignQuery from '../graphql/queries/get_design.query.graphql'; import allVersionsMixin from '../mixins/all_versions'; diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue index f10545faea6..c96487d0d08 100644 --- a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue +++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlAvatar, GlCollapsibleListbox } from '@gitlab/ui'; import defaultAvatarUrl from 'images/no_avatar.png'; import { __, sprintf } from '~/locale'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -8,13 +8,19 @@ import { findVersionId } from '../../utils/design_management_utils'; export default { components: { - GlDropdown, - GlDropdownItem, - GlSprintf, + GlAvatar, + GlCollapsibleListbox, TimeAgo, }, mixins: [allVersionsMixin], computed: { + allVersionsList() { + return this.allVersions.map(({ id, ...item }, index) => ({ + value: id, + index, + ...item, + })); + }, queryVersion() { return this.$route.query.version; }, @@ -29,17 +35,11 @@ export default { // then return the latest version (index 0) return idx !== -1 ? idx : 0; }, - currentVersionId() { - if (this.queryVersion) return this.queryVersion; - - const currentVersion = this.allVersions[this.currentVersionIdx]; - return this.findVersionId(currentVersion.id); - }, dropdownText() { if (this.isLatestVersion) { return __('Showing latest version'); } - // allVersions is sorted in reverse chronological order (latest first) + // allVersions is sorted in reverse chronological order (the latest first) const currentVersionNumber = this.allVersions.length - this.currentVersionIdx; return sprintf(__('Showing version #%{versionNumber}'), { @@ -55,47 +55,49 @@ export default { query: { version: this.findVersionId(versionId) }, }); }, - versionText(versionId) { - if (this.findVersionId(versionId) === this.latestVersionId) { - return __('Version %{versionNumber} (latest)'); - } - return __('Version %{versionNumber}'); + versionText(item) { + const versionNumber = this.allVersions.length - item.index; + const message = + this.findVersionId(item.value) === this.latestVersionId + ? __('Version %{versionNumber} (latest)') + : __('Version %{versionNumber}'); + return sprintf(message, { versionNumber }); }, getAvatarUrl(version) { return version?.author?.avatarUrl || defaultAvatarUrl; }, + getAuthorName(author) { + return author?.name; + }, }, }; </script> <template> - <gl-dropdown :text="dropdownText" size="small"> - <gl-dropdown-item - v-for="(version, index) in allVersions" - :key="version.id" - is-check-item - is-check-centered - :is-checked="findVersionId(version.id) === currentVersionId" - :avatar-url="getAvatarUrl(version)" - @click="routeToVersion(version.id)" - > - <strong> - <gl-sprintf :message="versionText(version.id)"> - <template #versionNumber> - {{ allVersions.length - index }} - </template> - </gl-sprintf> - </strong> - - <div v-if="version.author" class="gl-text-gray-600 gl-mt-1"> - <div>{{ version.author.name }}</div> - <time-ago - v-if="version.createdAt" - class="text-1" - :time="version.createdAt" - tooltip-placement="bottom" - /> - </div> - </gl-dropdown-item> - </gl-dropdown> + <gl-collapsible-listbox + is-check-centered + :items="allVersionsList" + :toggle-text="dropdownText" + :selected="designsVersion" + size="small" + @select="routeToVersion" + > + <template #list-item="{ item }"> + <span class="gl-display-flex gl-align-items-center"> + <gl-avatar :alt="getAuthorName(item.author)" :size="32" :src="getAvatarUrl(item)" /> + <span class="gl-display-flex gl-flex-direction-column"> + <span class="gl-font-weight-bold">{{ versionText(item) }}</span> + <span v-if="item.author" class="gl-text-gray-600 gl-mt-1"> + <span class="gl-display-block">{{ getAuthorName(item.author) }}</span> + <time-ago + v-if="item.createdAt" + class="text-1" + :time="item.createdAt" + tooltip-placement="bottom" + /> + </span> + </span> + </span> + </template> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index d4c177e2e5f..f448e2f9e3d 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -6,7 +6,7 @@ import { ApolloMutation } from 'vue-apollo'; import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings'; import { createAlert } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; -import { updateGlobalTodoCount } from '~/vue_shared/components/sidebar/todo_toggle/utils'; +import { updateGlobalTodoCount } from '~/sidebar/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DesignDestroyer from '../../components/design_destroyer.vue'; import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 5a45797ed98..1857ff557e6 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -1,5 +1,6 @@ <script> -import { GlButtonGroup, GlButton, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -32,7 +33,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], props: { diff --git a/app/assets/javascripts/diffs/components/diff_code_quality.vue b/app/assets/javascripts/diffs/components/diff_code_quality.vue index 8498724740f..11aa856619b 100644 --- a/app/assets/javascripts/diffs/components/diff_code_quality.vue +++ b/app/assets/javascripts/diffs/components/diff_code_quality.vue @@ -1,8 +1,12 @@ <script> import { GlButton, GlIcon } from '@gitlab/ui'; -import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { NEW_CODE_QUALITY_FINDINGS } from '../i18n'; export default { + i18n: { + newFindings: NEW_CODE_QUALITY_FINDINGS, + }, components: { GlButton, GlIcon }, props: { codeQuality: { @@ -22,22 +26,33 @@ export default { </script> <template> - <div data-testid="diff-codequality" class="gl-relative"> - <ul - class="gl-list-style-none gl-mb-0 gl-p-0 codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10" + <div + data-testid="diff-codequality" + class="gl-relative codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10 gl-pl-5 gl-pt-4 gl-pb-4" + > + <h4 + data-testid="diff-codequality-findings-heading" + class="gl-mt-0 gl-mb-0 gl-font-base gl-font-regular" > + {{ $options.i18n.newFindings }} + </h4> + <ul class="gl-list-style-none gl-mb-0 gl-p-0"> <li v-for="finding in codeQuality" :key="finding.description" - class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100 gl-font-regular" + class="gl-pt-1 gl-pb-1 gl-font-regular gl-display-flex" > - <gl-icon - :size="12" - :name="severityIcon(finding.severity)" - :class="severityClass(finding.severity)" - class="codequality-severity-icon" - /> - {{ finding.description }} + <span class="gl-mr-3"> + <gl-icon + :size="12" + :name="severityIcon(finding.severity)" + :class="severityClass(finding.severity)" + class="codequality-severity-icon" + /> + </span> + <span> + <span class="severity-copy">{{ finding.severity }}</span> - {{ finding.description }} + </span> </li> </ul> <gl-button diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue index 3766c125325..8b747aa08dd 100644 --- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue +++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue @@ -1,13 +1,18 @@ <script> +import { GlButton } from '@gitlab/ui'; import { mapGetters } from 'vuex'; -import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue'; +import { START_THREAD } from '../i18n'; + export default { name: 'DiffDiscussionReply', + i18n: { + START_THREAD, + }, components: { + GlButton, NoteSignedOutWidget, - ReplyPlaceholder, }, props: { hasForm: { @@ -34,11 +39,9 @@ export default { <template v-if="userCanReply"> <slot v-if="hasForm" name="form"></slot> <template v-else-if="renderReplyPlaceholder"> - <reply-placeholder - :placeholder-text="__('Start a new discussion…')" - :label-text="__('New discussion')" - @focus="$emit('showNewDiscussionForm')" - /> + <gl-button @click="$emit('showNewDiscussionForm')"> + {{ $options.i18n.START_THREAD }} + </gl-button> </template> </template> <note-signed-out-widget v-else /> diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index b2098b9e82d..8fcbc4b5cce 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -1,6 +1,7 @@ <script> -import { GlTooltipDirective, GlSafeHtmlDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert } from '~/flash'; import { s__, sprintf } from '~/locale'; import { UNFOLD_COUNT, INLINE_DIFF_LINES_KEY } from '../constants'; @@ -21,7 +22,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { file: { diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 8f041d1e670..564f776edd2 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,13 +1,8 @@ <script> -import { - GlButton, - GlLoadingIcon, - GlSafeHtmlDirective as SafeHtml, - GlSprintf, - GlAlert, -} from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlSprintf, GlAlert } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { IdState } from 'vendor/vue-virtual-scroller'; import DiffContent from 'jh_else_ce/diffs/components/diff_content.vue'; import { createAlert } from '~/flash'; diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 91c3df39e32..dff61acdfba 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -1,7 +1,6 @@ <script> import { GlTooltipDirective, - GlSafeHtmlDirective, GlIcon, GlBadge, GlButton, @@ -14,6 +13,7 @@ import { } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { IdState } from 'vendor/vue-virtual-scroller'; import { scrollToElement } from '~/lib/utils/common_utils'; import { truncateSha } from '~/lib/utils/text_utility'; @@ -44,7 +44,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [IdState({ idProp: (vm) => vm.diffFile.file_hash }), glFeatureFlagsMixin()], i18n: { diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue index 5ea118afe78..aa9a17d18e3 100644 --- a/app/assets/javascripts/diffs/components/diff_view.vue +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -1,5 +1,4 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapGetters, mapState, mapActions } from 'vuex'; import { IdState } from 'vendor/vue-virtual-scroller'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -22,9 +21,6 @@ export default { DiffCommentCell, DraftNote, }, - directives: { - SafeHtml, - }, mixins: [ draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash }), @@ -307,7 +303,11 @@ export default { class="diff-td notes-content parallel old" > <div v-for="draft in lineDrafts(line, 'left')" :key="draft.id" class="content"> - <draft-note :draft="draft" :line="line.left" /> + <article class="note-wrapper"> + <ul class="notes draft-notes"> + <draft-note :draft="draft" :line="line.left" /> + </ul> + </article> </div> </div> <div @@ -315,7 +315,11 @@ export default { class="diff-td notes-content parallel new" > <div v-for="draft in lineDrafts(line, 'right')" :key="draft.id" class="content"> - <draft-note :draft="draft" :line="line.right" /> + <article class="note-wrapper"> + <ul class="notes draft-notes"> + <draft-note :draft="draft" :line="line.right" /> + </ul> + </article> </div> </div> </div> diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index f7f4aad3ad0..0f44eb06cb3 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -19,6 +19,7 @@ export const DIFF_FILE = { autoCollapsed: __('Files with large changes are collapsed by default.'), expand: __('Expand file'), }; +export const START_THREAD = __('Start another thread'); export const SETTINGS_DROPDOWN = { whitespace: __('Show whitespace changes'), @@ -49,3 +50,5 @@ export const CONFLICT_TEXT = { }; export const HIDE_COMMENTS = __('Hide comments'); + +export const NEW_CODE_QUALITY_FINDINGS = __('New code quality findings'); diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index b4ff5e4f250..7da5ef54b80 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { getCookie, parseBoolean, removeCookie } from '~/lib/utils/common_utils'; +import notesStore from '~/mr_notes/stores'; import eventHub from '../notes/event_hub'; import DiffsApp from './components/app.vue'; @@ -9,7 +10,7 @@ import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants' import { getReviewsForMergeRequest } from './utils/file_reviews'; import { getDerivedMergeRequestInformation } from './utils/merge_request'; -export default function initDiffsApp(store) { +export default function initDiffsApp(store = notesStore) { const vm = new Vue({ el: '#js-diffs-app', name: 'MergeRequestDiffs', diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index c73012527a2..96a73917820 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -52,7 +52,7 @@ import { isCollapsed } from '../utils/diff_file'; import { markFileReview, setReviewsForMergeRequest } from '../utils/file_reviews'; import { getDerivedMergeRequestInformation } from '../utils/merge_request'; import { queueRedisHllEvents } from '../utils/queue_events'; -import TreeWorker from '../workers/tree_worker'; +import TreeWorker from '../workers/tree_worker?worker'; import * as types from './mutation_types'; import { getDiffPositionByLineCode, @@ -444,20 +444,27 @@ export const scrollToLineIfNeededParallel = (_, line) => { } }; -export const loadCollapsedDiff = ({ commit, getters, state }, file) => - axios - .get(file.load_collapsed_diff_url, { - params: { - commit_id: getters.commitId, - w: state.showWhitespace ? '0' : '1', - }, - }) - .then((res) => { - commit(types.ADD_COLLAPSED_DIFFS, { - file, - data: res.data, - }); +export const loadCollapsedDiff = ({ commit, getters, state }, file) => { + const versionPath = state.mergeRequestDiff?.version_path; + const loadParams = { + commit_id: getters.commitId, + w: state.showWhitespace ? '0' : '1', + }; + + if (versionPath) { + const { diffId, startSha } = getDerivedMergeRequestInformation({ endpoint: versionPath }); + + loadParams.diff_id = diffId; + loadParams.start_sha = startSha; + } + + return axios.get(file.load_collapsed_diff_url, { params: loadParams }).then((res) => { + commit(types.ADD_COLLAPSED_DIFFS, { + file, + data: res.data, }); + }); +}; /** * Toggles the file discussions after user clicked on the toggle discussions button. diff --git a/app/assets/javascripts/diffs/utils/merge_request.js b/app/assets/javascripts/diffs/utils/merge_request.js index edb4304f558..43e04a814c5 100644 --- a/app/assets/javascripts/diffs/utils/merge_request.js +++ b/app/assets/javascripts/diffs/utils/merge_request.js @@ -1,14 +1,30 @@ const endpointRE = /^(\/?(.+?)\/(.+?)\/-\/merge_requests\/(\d+)).*$/i; +function getVersionInfo({ endpoint } = {}) { + const dummyRoot = 'https://gitlab.com'; + const endpointUrl = new URL(endpoint, dummyRoot); + const params = Object.fromEntries(endpointUrl.searchParams.entries()); + + const { start_sha: startSha, diff_id: diffId } = params; + + return { + diffId, + startSha, + }; +} + export function getDerivedMergeRequestInformation({ endpoint } = {}) { let mrPath; let userOrGroup; let project; let id; + let diffId; + let startSha; const matches = endpointRE.exec(endpoint); if (matches) { [, mrPath, userOrGroup, project, id] = matches; + ({ diffId, startSha } = getVersionInfo({ endpoint })); } return { @@ -16,5 +32,7 @@ export function getDerivedMergeRequestInformation({ endpoint } = {}) { userOrGroup, project, id, + diffId, + startSha, }; } diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar.vue b/app/assets/javascripts/editor/components/source_editor_toolbar.vue index 2c177634bbe..c72145f9d2f 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar.vue @@ -57,13 +57,12 @@ export default { > <div v-for="group in $options.groups" :key="group"> <gl-button-group v-if="hasGroupItems(group)"> - <template v-for="item in getGroupItems(group)"> - <source-editor-toolbar-button - :key="item.id" - :button="item" - @click="$emit('click', item)" - /> - </template> + <source-editor-toolbar-button + v-for="item in getGroupItems(group)" + :key="item.id" + :button="item" + @click="$emit('click', item)" + /> </gl-button-group> </div> </section> diff --git a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue index 6ce48ddf89a..38f586f0773 100644 --- a/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue +++ b/app/assets/javascripts/editor/components/source_editor_toolbar_button.vue @@ -31,12 +31,19 @@ export default { return Object.entries(this.button).length > 0; }, }, + mounted() { + if (this.button.data) { + Object.entries(this.button.data).forEach(([attr, value]) => { + this.$el.dataset[attr] = value; + }); + } + }, methods: { - clickHandler() { + clickHandler(event) { if (this.button.onClick) { - this.button.onClick(); + this.button.onClick(event); } - this.$emit('click'); + this.$emit('click', event); }, }, }; @@ -52,7 +59,7 @@ export default { :icon="icon" :title="label" :aria-label="label" - data-qa-selector="editor_toolbar_button" - @click="clickHandler" + :class="button.class" + @click="clickHandler($event)" /> </template> diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index 83cfdd25757..d0649ecccba 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -1,5 +1,6 @@ +import { MODIFIER_KEY } from '~/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { s__, __ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; export const URI_PREFIX = 'gitlab'; export const CONTENT_UPDATE_DEBOUNCE = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; @@ -62,3 +63,104 @@ export const EXTENSION_MARKDOWN_PREVIEW_PANEL_WIDTH = 0.5; // 50% of the width export const EXTENSION_MARKDOWN_PREVIEW_UPDATE_DELAY = 250; // ms export const EXTENSION_MARKDOWN_PREVIEW_LABEL = __('Preview Markdown'); export const EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL = __('Hide Live Preview'); +export const EXTENSION_MARKDOWN_BUTTONS = [ + { + id: 'bold', + label: sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { + modifierKey: MODIFIER_KEY, + }), + data: { + mdTag: '**', + mdShortcuts: '["mod+b"]', + }, + }, + { + id: 'italic', + label: sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { + modifierKey: MODIFIER_KEY, + }), + data: { + mdTag: '_', + mdShortcuts: '["mod+i"]', + }, + }, + { + id: 'strikethrough', + label: sprintf(s__('MarkdownEditor|Add strikethrough text (%{modifierKey}⇧X)'), { + modifierKey: MODIFIER_KEY, + }), + data: { + mdTag: '~~', + mdShortcuts: '["mod+shift+x]', + }, + }, + { + id: 'quote', + label: __('Insert a quote'), + data: { + mdTag: '> ', + mdPrepend: true, + }, + }, + { + id: 'code', + label: __('Insert code'), + data: { + mdTag: '`', + mdBlock: '```', + }, + }, + { + id: 'link', + label: sprintf(s__('MarkdownEditor|Add a link (%{modifier_key}K)'), { + modifierKey: MODIFIER_KEY, + }), + data: { + mdTag: '[{text}](url)', + mdSelect: 'url', + mdShortcuts: '["mod+k"]', + }, + }, + { + id: 'list-bulleted', + label: __('Add a bullet list'), + data: { + mdTag: '- ', + mdPrepend: true, + }, + }, + { + id: 'list-numbered', + label: __('Add a numbered list'), + data: { + mdTag: '1. ', + mdPrepend: true, + }, + }, + { + id: 'list-task', + label: __('Add a checklist'), + data: { + mdTag: '- [ ] ', + mdPrepend: true, + }, + }, + { + id: 'details-block', + label: __('Add a collapsible section'), + data: { + mdTag: '<details><summary>Click to expand</summary>\n{text}\n</details>', + mdPrepend: true, + mdSelect: __('Click to expand'), + }, + }, + { + id: 'table', + label: __('Add a table'), + data: { + /* eslint-disable-next-line @gitlab/require-i18n-strings */ + mdTag: '| header | header |\n| ------ | ------ |\n| | |\n| | |', + mdPrepend: true, + }, + }, +]; diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js index a16fe93026e..6105a577996 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_ext.js @@ -1,8 +1,37 @@ +import { insertMarkdownText } from '~/lib/utils/text_markdown'; +import { EDITOR_TOOLBAR_RIGHT_GROUP, EXTENSION_MARKDOWN_BUTTONS } from '../constants'; + export class EditorMarkdownExtension { static get extensionName() { return 'EditorMarkdown'; } + onSetup(instance) { + this.toolbarButtons = []; + if (instance.toolbar) { + this.setupToolbar(instance); + } + } + onBeforeUnuse(instance) { + const ids = this.toolbarButtons.map((item) => item.id); + if (instance.toolbar) { + instance.toolbar.removeItems(ids); + } + } + + setupToolbar(instance) { + this.toolbarButtons = EXTENSION_MARKDOWN_BUTTONS.map((btn) => { + return { + ...btn, + icon: btn.id, + group: EDITOR_TOOLBAR_RIGHT_GROUP, + category: 'tertiary', + onClick: (e) => instance.insertMarkdown(e), + }; + }); + instance.toolbar.addItems(this.toolbarButtons); + } + // eslint-disable-next-line class-methods-use-this provides() { return { @@ -36,6 +65,25 @@ export class EditorMarkdownExtension { pos.lineNumber += dy; instance.setPosition(pos); }, + insertMarkdown: (instance, e) => { + const { + mdTag: tag, + mdBlock: blockTag, + mdPrepend, + mdSelect: select, + } = e.currentTarget.dataset; + + insertMarkdownText({ + tag, + blockTag, + wrap: !mdPrepend, + select, + selected: instance.getSelectedText(), + text: instance.getValue(), + editor: instance, + }); + instance.focus(); + }, /** * Adjust existing selection to select text within the original selection. * - If `selectedText` is not supplied, we fetch selected text with diff --git a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js index dd4a7a689d7..58ddaa94d5e 100644 --- a/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js +++ b/app/assets/javascripts/editor/extensions/source_editor_markdown_livepreview_ext.js @@ -120,6 +120,9 @@ export class EditorMarkdownPreviewExtension { category: 'primary', selectedLabel: EXTENSION_MARKDOWN_HIDE_PREVIEW_LABEL, onClick: () => instance.togglePreview(), + data: { + qaSelector: 'editor_toolbar_button', + }, }, ]; instance.toolbar.addItems(this.toolbarButtons); diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 45f063a2048..d94aa73e43a 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -41,6 +41,9 @@ "before_script": { "$ref": "#/definitions/before_script" }, + "hooks": { + "$ref": "#/definitions/hooks" + }, "cache": { "$ref": "#/definitions/cache" }, @@ -202,25 +205,11 @@ "when": { "markdownDescription": "Configure when artifacts are uploaded depended on job status. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#artifactswhen).", "default": "on_success", - "oneOf": [ - { - "enum": [ - "on_success" - ], - "description": "Upload artifacts only when the job succeeds (this is the default)." - }, - { - "enum": [ - "on_failure" - ], - "description": "Upload artifacts only when the job fails." - }, - { - "enum": [ - "always" - ], - "description": "Upload artifacts regardless of job status." - } + "type": "string", + "enum": [ + "on_success", + "on_failure", + "always" ] }, "expire_in": { @@ -347,10 +336,10 @@ "include_item": { "oneOf": [ { - "description": "Will infer the method based on the value. E.g. `https://...` strings will be of type `include:remote`, and `/templates/...` will be of type `include:local`.", + "description": "Will infer the method based on the value. E.g. `https://...` strings will be of type `include:remote`, and `/templates/...` or `templates/...` will be of type `include:local`.", "type": "string", "format": "uri-reference", - "pattern": "^(https?://|/).+\\.ya?ml$" + "pattern": "^(https?://|/?.?-?(?!\\w+://)\\w).+\\.ya?ml$" }, { "type": "object", @@ -585,56 +574,98 @@ ] } }, + "id_tokens": { + "type": "object", + "markdownDescription": "Defines JWTs to be injected as environment variables.", + "patternProperties": { + ".*": { + "type": "object", + "properties": { + "aud": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + ] + } + }, + "required": [ + "aud" + ], + "additionalProperties": false + } + } + }, "secrets": { "type": "object", "markdownDescription": "Defines secrets to be injected as environment variables. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#secrets).", - "additionalProperties": { - "type": "object", - "description": "Environment variable name", - "properties": { - "vault": { - "oneOf": [ - { - "type": "string", - "description": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`)" - }, - { - "type": "object", - "properties": { - "engine": { - "type": "object", - "properties": { - "name": { - "type": "string" + "patternProperties": { + ".*": { + "type": "object", + "properties": { + "vault": { + "oneOf": [ + { + "type": "string", + "markdownDescription": "The secret to be fetched from Vault (e.g. 'production/db/password@ops' translates to secret 'ops/data/production/db', field `password`). [Learn More](https://docs.gitlab.com/ee/ci/yaml/#secretsvault)" + }, + { + "type": "object", + "properties": { + "engine": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + } }, - "path": { - "type": "string" - } + "required": [ + "name", + "path" + ] }, - "required": [ - "name", - "path" - ] - }, - "path": { - "type": "string" + "path": { + "type": "string" + }, + "field": { + "type": "string" + } }, - "field": { - "type": "string" - } - }, - "required": [ - "engine", - "path", - "field" - ] - } - ] - } - }, - "required": [ - "vault" - ] + "required": [ + "engine", + "path", + "field" + ], + "additionalProperties": false + } + ] + }, + "file": { + "type": "boolean", + "default": true, + "markdownDescription": "Configures the secret to be stored as either a file or variable type CI/CD variable. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#secretsfile)" + }, + "token": { + "type": "string", + "description": "Specifies the JWT variable that should be used to authenticate with Hashicorp Vault." + } + }, + "required": [ + "vault" + ], + "additionalProperties": false + } } }, "before_script": { @@ -739,7 +770,17 @@ "type": "object", "properties": { "value": { - "type": "string" + "type": "string", + "markdownDescription": "Default value of the variable. If used with `options`, `value` must be included in the array. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#prefill-variables-in-manual-pipelines)" + }, + "options": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true, + "markdownDescription": "A list of predefined values that users can select from in the **Run pipeline** page when running a pipeline manually. [Learn More](https://docs.gitlab.com/ee/ci/pipelines/index.html#configure-a-list-of-selectable-values-for-a-prefilled-variable)" }, "description": { "type": "string", @@ -959,6 +1000,7 @@ "default": false }, "when": { + "type": "string", "markdownDescription": "Defines when to save the cache, based on the status of the job. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#cachewhen).", "default": "on_success", "enum": [ @@ -1200,6 +1242,9 @@ "after_script": { "$ref": "#/definitions/after_script" }, + "hooks": { + "$ref": "#/definitions/hooks" + }, "rules": { "$ref": "#/definitions/rules" }, @@ -1209,6 +1254,9 @@ "cache": { "$ref": "#/definitions/cache" }, + "id_tokens": { + "$ref": "#/definitions/id_tokens" + }, "secrets": { "$ref": "#/definitions/secrets" }, @@ -1861,6 +1909,39 @@ } ] } + }, + "hooks": { + "type": "object", + "markdownDescription": "Specifies lists of commands to execute on the runner at certain stages of job execution. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#hooks).", + "properties": { + "pre_get_sources_script": { + "markdownDescription": "Specifies a list of commands to execute on the runner before updating the Git repository and any submodules. [Learn More](https://docs.gitlab.com/ee/ci/yaml/#hookspre_get_sources_script).", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": false } } }
\ No newline at end of file diff --git a/app/assets/javascripts/environments/components/deploy_board.vue b/app/assets/javascripts/environments/components/deploy_board.vue index f22a0705b3d..31bc462f0b9 100644 --- a/app/assets/javascripts/environments/components/deploy_board.vue +++ b/app/assets/javascripts/environments/components/deploy_board.vue @@ -15,10 +15,10 @@ import { GlLink, GlTooltip, GlTooltipDirective, - GlSafeHtmlDirective as SafeHtml, GlSprintf, } from '@gitlab/ui'; import { isEmpty } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; import InstanceComponent from '~/vue_shared/components/deployment_instance.vue'; import { STATUS_MAP, CANARY_STATUS } from '../constants'; diff --git a/app/assets/javascripts/environments/environment_details/constants.js b/app/assets/javascripts/environments/environment_details/constants.js new file mode 100644 index 00000000000..56c70c354b7 --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/constants.js @@ -0,0 +1,47 @@ +import { __ } from '~/locale'; + +export const ENVIRONMENT_DETAILS_PAGE_SIZE = 20; +export const ENVIRONMENT_DETAILS_TABLE_FIELDS = [ + { + key: 'status', + label: __('Status'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'id', + label: __('ID'), + columnClass: 'gl-w-5p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'triggerer', + label: __('Triggerer'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'commit', + label: __('Commit'), + columnClass: 'gl-w-20p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'job', + label: __('Job'), + columnClass: 'gl-w-20p', + tdClass: 'gl-vertical-align-middle!', + }, + { + key: 'created', + label: __('Created'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', + }, + { + key: 'deployed', + label: __('Deployed'), + columnClass: 'gl-w-10p', + tdClass: 'gl-vertical-align-middle! gl-white-space-nowrap', + }, +]; diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue new file mode 100644 index 00000000000..435d3fd820e --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -0,0 +1,118 @@ +<script> +import { + GlTableLite, + GlAvatarLink, + GlAvatar, + GlLink, + GlTooltipDirective, + GlTruncate, + GlBadge, + GlLoadingIcon, +} from '@gitlab/ui'; +import Commit from '~/vue_shared/components/commit.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import environmentDetailsQuery from '../graphql/queries/environment_details.query.graphql'; +import { convertToDeploymentTableRow } from '../helpers/deployment_data_transformation_helper'; +import DeploymentStatusBadge from '../components/deployment_status_badge.vue'; +import { ENVIRONMENT_DETAILS_PAGE_SIZE, ENVIRONMENT_DETAILS_TABLE_FIELDS } from './constants'; + +export default { + components: { + GlLoadingIcon, + GlBadge, + DeploymentStatusBadge, + TimeAgoTooltip, + GlTableLite, + GlAvatarLink, + GlAvatar, + GlLink, + GlTruncate, + Commit, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + projectFullPath: { + type: String, + required: true, + }, + environmentName: { + type: String, + required: true, + }, + }, + apollo: { + project: { + query: environmentDetailsQuery, + variables() { + return { + projectFullPath: this.projectFullPath, + environmentName: this.environmentName, + pageSize: ENVIRONMENT_DETAILS_PAGE_SIZE, + }; + }, + }, + }, + data() { + return { + project: { + loading: true, + }, + loading: 0, + tableFields: ENVIRONMENT_DETAILS_TABLE_FIELDS, + }; + }, + computed: { + deployments() { + return this.project.environment?.deployments.nodes.map(convertToDeploymentTableRow) || []; + }, + isLoading() { + return this.$apollo.queries.project.loading; + }, + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="isLoading" size="lg" class="mt-3" /> + <gl-table-lite v-else :items="deployments" :fields="tableFields" fixed stacked="lg"> + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + <template #cell(status)="{ item }"> + <div> + <deployment-status-badge :status="item.status" /> + </div> + </template> + <template #cell(id)="{ item }"> + <strong>{{ item.id }}</strong> + </template> + <template #cell(triggerer)="{ item }"> + <gl-avatar-link :href="item.triggerer.webUrl"> + <gl-avatar + v-gl-tooltip + :title="item.triggerer.name" + :src="item.triggerer.avatarUrl" + :size="24" + /> + </gl-avatar-link> + </template> + <template #cell(commit)="{ item }"> + <commit v-bind="item.commit" /> + </template> + <template #cell(job)="{ item }"> + <gl-link v-if="item.job" :href="item.job.webPath"> + <gl-truncate :text="item.job.label" /> + </gl-link> + <gl-badge v-else variant="info">{{ __('API') }}</gl-badge> + </template> + <template #cell(created)="{ item }"> + <time-ago-tooltip :time="item.created" /> + </template> + <template #cell(deployed)="{ item }"> + <time-ago-tooltip :time="item.deployed" /> + </template> + </gl-table-lite> + </div> +</template> diff --git a/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql new file mode 100644 index 00000000000..e8f2a2cdf7f --- /dev/null +++ b/app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql @@ -0,0 +1,48 @@ +query getEnvironmentDetails($projectFullPath: ID!, $environmentName: String, $pageSize: Int) { + project(fullPath: $projectFullPath) { + id + name + fullPath + environment(name: $environmentName) { + id + name + deployments(orderBy: { createdAt: DESC }, first: $pageSize) { + nodes { + id + iid + status + ref + tag + job { + name + id + webPath + } + commit { + id + shortId + message + webUrl + authorGravatar + authorName + authorEmail + author { + id + name + avatarUrl + webUrl + } + } + triggerer { + id + webUrl + name + avatarUrl + } + createdAt + finishedAt + } + } + } + } +} diff --git a/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js new file mode 100644 index 00000000000..bfe92fe3125 --- /dev/null +++ b/app/assets/javascripts/environments/helpers/deployment_data_transformation_helper.js @@ -0,0 +1,62 @@ +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +/** + * This function transforms Commit object coming from GraphQL to object compatible with app/assets/javascripts/vue_shared/components/commit.vue author object + * @param {Object} Commit + * @returns {Object} + */ +export const getAuthorFromCommit = (commit) => { + if (commit.author) { + return { + username: commit.author.name, + path: commit.author.webUrl, + avatar_url: commit.author.avatarUrl, + }; + } + return { + username: commit.authorName, + path: `mailto:${commit.authorEmail}`, + avatar_url: commit.authorGravatar, + }; +}; + +/** + * This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/vue_shared/components/commit.vue + * @param {Object} deploymentNode + * @returns {Object} + */ +export const getCommitFromDeploymentNode = (deploymentNode) => { + if (!deploymentNode.commit) { + throw new Error("deploymentNode argument doesn't have 'commit' field", deploymentNode); + } + return { + title: deploymentNode.commit.message, + commitUrl: deploymentNode.commit.webUrl, + shortSha: deploymentNode.commit.shortId, + tag: deploymentNode.tag, + commitRef: { + name: deploymentNode.ref, + }, + author: getAuthorFromCommit(deploymentNode.commit), + }; +}; + +/** + * This function transforms deploymentNode object coming from GraphQL to object compatible with app/assets/javascripts/environments/environment_details/page.vue table + * @param {Object} deploymentNode + * @returns {Object} + */ +export const convertToDeploymentTableRow = (deploymentNode) => { + return { + status: deploymentNode.status.toLowerCase(), + id: deploymentNode.iid, + triggerer: deploymentNode.triggerer, + commit: getCommitFromDeploymentNode(deploymentNode), + job: deploymentNode.job && { + webPath: deploymentNode.job.webPath, + label: `${deploymentNode.job.name} (#${getIdFromGraphQLId(deploymentNode.job.id)})`, + }, + created: deploymentNode.createdAt || '', + deployed: deploymentNode.finishedAt || '', + }; +}; diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js index 6df4fad83f2..ba816599ac2 100644 --- a/app/assets/javascripts/environments/mount_show.js +++ b/app/assets/javascripts/environments/mount_show.js @@ -1,6 +1,8 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import EnvironmentsDetailHeader from './components/environments_detail_header.vue'; +import { apolloProvider } from './graphql/client'; import environmentsMixin from './mixins/environments_mixin'; export const initHeader = () => { @@ -41,7 +43,33 @@ export const initHeader = () => { cancelAutoStopPath: dataset.environmentCancelAutoStopPath, terminalPath: dataset.environmentTerminalPath, metricsPath: dataset.environmentMetricsPath, - updatePath: dataset.environmentEditPath, + updatePath: dataset.tnvironmentEditPath, + }, + }); + }, + }); +}; + +export const initPage = async () => { + if (!gon.features.environmentDetailsVue) { + return null; + } + const EnvironmentsDetailPageModule = await import('./environment_details/index.vue'); + const EnvironmentsDetailPage = EnvironmentsDetailPageModule.default; + const dataElement = document.getElementById('environments-detail-view'); + const dataSet = convertObjectPropsToCamelCase(JSON.parse(dataElement.dataset.details)); + + Vue.use(VueApollo); + const el = document.getElementById('environment_details_page'); + return new Vue({ + el, + apolloProvider: apolloProvider(), + provide: {}, + render(createElement) { + return createElement(EnvironmentsDetailPage, { + props: { + projectFullPath: dataSet.projectFullPath, + environmentName: dataSet.name, }, }); }, diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index de4b11699fc..122c7c005e9 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -357,7 +357,7 @@ export default { > <span class="d-flex"> <gl-icon - class="gl-new-dropdown-item-check-icon" + class="gl-dropdown-item-check-icon" :class="{ invisible: !isCurrentStatusFilter(status) }" name="mobile-issue-close" /> @@ -374,7 +374,7 @@ export default { > <span class="d-flex"> <gl-icon - class="gl-new-dropdown-item-check-icon" + class="gl-dropdown-item-check-icon" :class="{ invisible: !isCurrentSortField(field) }" name="mobile-issue-close" /> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index 34d01f21da2..6ddd982ebf1 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -1,5 +1,6 @@ <script> -import { GlTooltip, GlSprintf, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlTooltip, GlSprintf, GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue index f0f42d19ea5..286b214b511 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -4,6 +4,8 @@ import { __, s__, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { labelForStrategy } from '../utils'; +import StrategyLabel from './strategy_label.vue'; + export default { i18n: { deleteLabel: __('Delete'), @@ -15,6 +17,7 @@ export default { GlButton, GlModal, GlToggle, + StrategyLabel, }, directives: { GlTooltip: GlTooltipDirective, @@ -166,14 +169,13 @@ export default { <div class="table-mobile-content d-flex flex-wrap justify-content-end justify-content-md-start js-feature-flag-environments" > - <gl-badge + <strategy-label v-for="strategy in featureFlag.strategies" :key="strategy.id" - data-testid="strategy-badge" - variant="info" - class="gl-mr-3 gl-mt-2 gl-white-space-normal gl-text-left gl-px-5" - >{{ strategyBadgeText(strategy) }}</gl-badge - > + data-testid="strategy-label" + class="w-100 gl-mr-3 gl-mt-2 gl-white-space-normal gl-text-left" + v-bind="strategyBadgeText(strategy)" + /> </div> </div> diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue index 1a470d74b59..0fde87dd0ba 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue @@ -90,10 +90,10 @@ export default { :id="inputId" :value="percentage" :state="isValid" - class="rollout-percentage gl-text-right gl-w-9" type="number" min="0" max="100" + size="xs" @input="onPercentageChange" /> <span class="ml-1">%</span> diff --git a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue index 91e1b85d66e..0acb0d4366c 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue @@ -56,10 +56,10 @@ export default { :id="inputId" :value="percentage" :state="isValid" - class="rollout-percentage gl-text-right gl-w-9" type="number" min="0" max="100" + size="xs" @input="onPercentageChange" /> <span class="gl-ml-2">%</span> diff --git a/app/assets/javascripts/feature_flags/components/strategy_label.vue b/app/assets/javascripts/feature_flags/components/strategy_label.vue new file mode 100644 index 00000000000..c2d3ec5708f --- /dev/null +++ b/app/assets/javascripts/feature_flags/components/strategy_label.vue @@ -0,0 +1,29 @@ +<script> +export default { + props: { + name: { + type: String, + required: true, + }, + scopes: { + type: String, + required: false, + default: null, + }, + parameters: { + type: String, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <div> + <strong class="gl-fw-bold" + >{{ name }}<span v-if="parameters"> - {{ parameters }}</span + >:</strong + > + <span v-if="scopes">{{ scopes }}</span> + </div> +</template> diff --git a/app/assets/javascripts/feature_flags/utils.js b/app/assets/javascripts/feature_flags/utils.js index e77cb8406cc..47deeab0571 100644 --- a/app/assets/javascripts/feature_flags/utils.js +++ b/app/assets/javascripts/feature_flags/utils.js @@ -50,17 +50,11 @@ const scopeName = ({ environment_scope: scope }) => export const labelForStrategy = (strategy) => { const { name, parameters } = badgeTextByType[strategy.name]; + const scopes = strategy.scopes.map(scopeName).join(', '); - if (parameters) { - return sprintf('%{name} - %{parameters}: %{scopes}', { - name, - parameters: parameters(strategy), - scopes: strategy.scopes.map(scopeName).join(', '), - }); - } - - return sprintf('%{name}: %{scopes}', { + return { name, - scopes: strategy.scopes.map(scopeName).join(', '), - }); + parameters: parameters ? parameters(strategy) : null, + scopes, + }; }; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue index 79d7eb94569..1c6e6380e76 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue +++ b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue @@ -1,12 +1,7 @@ <script> import clusterPopover from '@gitlab/svgs/dist/illustrations/cluster_popover.svg'; -import { - GlPopover, - GlSprintf, - GlLink, - GlButton, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlPopover, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import { POPOVER_TARGET_ID } from './constants'; import { dismiss } from './feature_highlight_helper'; diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index d9c627f5c93..397ba879866 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -1,9 +1,16 @@ -import { __, s__ } from '~/locale'; +import { __ } from '~/locale'; +import { + TOKEN_TITLE_APPROVED_BY, + TOKEN_TITLE_REVIEWER, + TOKEN_TYPE_APPROVED_BY, + TOKEN_TYPE_REVIEWER, + TOKEN_TYPE_TARGET_BRANCH, +} from '~/vue_shared/components/filtered_search_bar/constants'; export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { const reviewerToken = { - formattedKey: s__('SearchToken|Reviewer'), - key: 'reviewer', + formattedKey: TOKEN_TITLE_REVIEWER, + key: TOKEN_TYPE_REVIEWER, type: 'string', param: 'username', symbol: '@', @@ -53,7 +60,7 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { if (!disableTargetBranchFilter) { const targetBranchToken = { formattedKey: __('Target-Branch'), - key: 'target-branch', + key: TOKEN_TYPE_TARGET_BRANCH, type: 'string', param: '', symbol: '', @@ -67,8 +74,8 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { const approvedBy = { token: { - formattedKey: __('Approved-By'), - key: 'approved-by', + formattedKey: TOKEN_TITLE_APPROVED_BY, + key: TOKEN_TYPE_APPROVED_BY, type: 'array', param: 'usernames[]', symbol: '@', @@ -76,8 +83,8 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { tag: '@approved-by', }, tokenAlternative: { - formattedKey: __('Approved-By'), - key: 'approved-by', + formattedKey: TOKEN_TITLE_APPROVED_BY, + key: TOKEN_TYPE_APPROVED_BY, type: 'string', param: 'usernames', symbol: '@', @@ -85,25 +92,25 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { condition: [ { url: 'approved_by_usernames[]=None', - tokenKey: 'approved-by', + tokenKey: TOKEN_TYPE_APPROVED_BY, value: __('None'), operator: '=', }, { url: 'not[approved_by_usernames][]=None', - tokenKey: 'approved-by', + tokenKey: TOKEN_TYPE_APPROVED_BY, value: __('None'), operator: '!=', }, { url: 'approved_by_usernames[]=Any', - tokenKey: 'approved-by', + tokenKey: TOKEN_TYPE_APPROVED_BY, value: __('Any'), operator: '=', }, { url: 'not[approved_by_usernames][]=Any', - tokenKey: 'approved-by', + tokenKey: TOKEN_TYPE_APPROVED_BY, value: __('Any'), operator: '!=', }, diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 3913e4e8d81..1f8baa470d8 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -1,5 +1,17 @@ import { sortMilestonesByDueDate } from '~/milestones/utils'; -import { mergeUrlParams } from '../lib/utils/url_utility'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { + TOKEN_TYPE_APPROVED_BY, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_REVIEWER, + TOKEN_TYPE_TARGET_BRANCH, +} from '~/vue_shared/components/filtered_search_bar/constants'; import DropdownEmoji from './dropdown_emoji'; import DropdownHint from './dropdown_hint'; import DropdownNonUser from './dropdown_non_user'; @@ -58,17 +70,17 @@ export default class AvailableDropdownMappings { getMappings() { return { - author: { + [TOKEN_TYPE_AUTHOR]: { reference: null, gl: DropdownUser, element: this.container.querySelector('#js-dropdown-author'), }, - assignee: { + [TOKEN_TYPE_ASSIGNEE]: { reference: null, gl: DropdownUser, element: this.container.querySelector('#js-dropdown-assignee'), }, - reviewer: { + [TOKEN_TYPE_REVIEWER]: { reference: null, gl: DropdownUser, element: this.container.querySelector('#js-dropdown-reviewer'), @@ -78,12 +90,12 @@ export default class AvailableDropdownMappings { gl: DropdownUser, element: this.container.getElementById('js-dropdown-attention-requested'), }, - 'approved-by': { + [TOKEN_TYPE_APPROVED_BY]: { reference: null, gl: DropdownUser, element: this.container.querySelector('#js-dropdown-approved-by'), }, - milestone: { + [TOKEN_TYPE_MILESTONE]: { reference: null, gl: DropdownNonUser, extraArguments: { @@ -93,7 +105,7 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-milestone'), }, - release: { + [TOKEN_TYPE_RELEASE]: { reference: null, gl: DropdownNonUser, extraArguments: { @@ -106,7 +118,7 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-release'), }, - label: { + [TOKEN_TYPE_LABEL]: { reference: null, gl: DropdownNonUser, extraArguments: { @@ -116,7 +128,7 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-label'), }, - 'my-reaction': { + [TOKEN_TYPE_MY_REACTION]: { reference: null, gl: DropdownEmoji, element: this.container.querySelector('#js-dropdown-my-reaction'), @@ -126,12 +138,12 @@ export default class AvailableDropdownMappings { gl: DropdownNonUser, element: this.container.querySelector('#js-dropdown-wip'), }, - confidential: { + [TOKEN_TYPE_CONFIDENTIAL]: { reference: null, gl: DropdownNonUser, element: this.container.querySelector('#js-dropdown-confidential'), }, - 'target-branch': { + [TOKEN_TYPE_TARGET_BRANCH]: { reference: null, gl: DropdownNonUser, extraArguments: { diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index e07dccd11e8..b328ae6a872 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -1,4 +1,17 @@ -export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer', 'attention']; +import { + TOKEN_TYPE_APPROVED_BY, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_REVIEWER, +} from '~/vue_shared/components/filtered_search_bar/constants'; + +export const USER_TOKEN_TYPES = [ + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_APPROVED_BY, + TOKEN_TYPE_REVIEWER, + 'attention', +]; export const DROPDOWN_TYPE = { hint: 'hint', diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 22e1604871a..38909db0555 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -1,4 +1,5 @@ import { last } from 'lodash'; +import { TOKEN_TYPE_LABEL } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchContainer from './container'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchTokenizer from './filtered_search_tokenizer'; @@ -113,7 +114,7 @@ export default class DropdownUtils { visualToken && visualToken.querySelector('.value') && visualToken.querySelector('.value').textContent.trim(); - if (tokenName === 'label' && tokenValue) { + if (tokenName === TOKEN_TYPE_LABEL && tokenValue) { // remove leading symbol and wrapping quotes tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, ''); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index bc0f5398b4c..16c70fdd069 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -10,8 +10,12 @@ import { DOWN_KEY_CODE, } from '~/lib/utils/keycodes'; import { __ } from '~/locale'; -import { addClassIfElementExists } from '../lib/utils/dom_utils'; -import { visitUrl, getUrlParamsArray, getParameterByName } from '../lib/utils/url_utility'; +import { addClassIfElementExists } from '~/lib/utils/dom_utils'; +import { visitUrl, getUrlParamsArray, getParameterByName } from '~/lib/utils/url_utility'; +import { + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchContainer from './container'; import DropdownUtils from './dropdown_utils'; import eventHub from './event_hub'; @@ -675,7 +679,7 @@ export default class FilteredSearchManager { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - const tokenName = 'assignee'; + const tokenName = TOKEN_TYPE_ASSIGNEE; const canEdit = this.canEdit && this.canEdit(tokenName); const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]); const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]); @@ -688,7 +692,7 @@ export default class FilteredSearchManager { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - const tokenName = 'author'; + const tokenName = TOKEN_TYPE_AUTHOR; const canEdit = this.canEdit && this.canEdit(tokenName); const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]); const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]); diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 0c01220a7be..4994559e923 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,5 +1,6 @@ import { spriteIcon } from '~/lib/utils/common_utils'; import { objectToQuery } from '~/lib/utils/url_utility'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchContainer from './container'; import VisualTokenValue from './visual_token_value'; @@ -38,7 +39,7 @@ export default class FilteredSearchVisualTokens { lastVisualToken, isLastVisualTokenValid: lastVisualToken === null || - lastVisualToken.className.indexOf('filtered-search-term') !== -1 || + lastVisualToken.className.indexOf(FILTERED_SEARCH_TERM) !== -1 || (lastVisualToken && lastVisualToken.querySelector('.operator') !== null && lastVisualToken.querySelector('.value') !== null), @@ -113,7 +114,7 @@ export default class FilteredSearchVisualTokens { } = options; const li = document.createElement('li'); li.classList.add('js-visual-token'); - li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); + li.classList.add(isSearchTerm ? FILTERED_SEARCH_TERM : 'filtered-search-token'); if (!isSearchTerm) { li.classList.add(tokenClass); @@ -239,7 +240,7 @@ export default class FilteredSearchVisualTokens { static addSearchVisualToken(searchTerm) { const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) { + if (lastVisualToken && lastVisualToken.classList.contains(FILTERED_SEARCH_TERM)) { lastVisualToken.querySelector('.name').textContent += ` ${searchTerm}`; } else { FilteredSearchVisualTokens.addVisualTokenElement({ diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index d6e7887f93f..8aa99ec52f9 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -7,13 +7,20 @@ import { TOKEN_TITLE_MILESTONE, TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_RELEASE, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_REVIEWER, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchTokenKeys from './filtered_search_token_keys'; export const tokenKeys = [ { formattedKey: TOKEN_TITLE_AUTHOR, - key: 'author', + key: TOKEN_TYPE_AUTHOR, type: 'string', param: 'username', symbol: '@', @@ -22,7 +29,7 @@ export const tokenKeys = [ }, { formattedKey: TOKEN_TITLE_ASSIGNEE, - key: 'assignee', + key: TOKEN_TYPE_ASSIGNEE, type: 'string', param: 'username', symbol: '@', @@ -31,7 +38,7 @@ export const tokenKeys = [ }, { formattedKey: TOKEN_TITLE_MILESTONE, - key: 'milestone', + key: TOKEN_TYPE_MILESTONE, type: 'string', param: 'title', symbol: '%', @@ -40,7 +47,7 @@ export const tokenKeys = [ }, { formattedKey: TOKEN_TITLE_RELEASE, - key: 'release', + key: TOKEN_TYPE_RELEASE, type: 'string', param: 'tag', symbol: '', @@ -49,7 +56,7 @@ export const tokenKeys = [ }, { formattedKey: TOKEN_TITLE_LABEL, - key: 'label', + key: TOKEN_TYPE_LABEL, type: 'array', param: 'name[]', symbol: '~', @@ -62,7 +69,7 @@ if (gon.current_user_id) { // Appending tokenkeys only logged-in tokenKeys.push({ formattedKey: TOKEN_TITLE_MY_REACTION, - key: 'my-reaction', + key: TOKEN_TYPE_MY_REACTION, type: 'string', param: 'emoji', symbol: '', @@ -74,7 +81,7 @@ if (gon.current_user_id) { export const alternativeTokenKeys = [ { formattedKey: TOKEN_TITLE_LABEL, - key: 'label', + key: TOKEN_TYPE_LABEL, type: 'string', param: 'name', symbol: '~', @@ -85,77 +92,77 @@ export const conditions = flattenDeep( [ { url: 'assignee_id=None', - tokenKey: 'assignee', + tokenKey: TOKEN_TYPE_ASSIGNEE, value: __('None'), }, { url: 'assignee_id=Any', - tokenKey: 'assignee', + tokenKey: TOKEN_TYPE_ASSIGNEE, value: __('Any'), }, { url: 'reviewer_id=None', - tokenKey: 'reviewer', + tokenKey: TOKEN_TYPE_REVIEWER, value: __('None'), }, { url: 'reviewer_id=Any', - tokenKey: 'reviewer', + tokenKey: TOKEN_TYPE_REVIEWER, value: __('Any'), }, { url: 'author_username=support-bot', - tokenKey: 'author', + tokenKey: TOKEN_TYPE_AUTHOR, value: 'support-bot', }, { url: 'milestone_title=None', - tokenKey: 'milestone', + tokenKey: TOKEN_TYPE_MILESTONE, value: __('None'), }, { url: 'milestone_title=Any', - tokenKey: 'milestone', + tokenKey: TOKEN_TYPE_MILESTONE, value: __('Any'), }, { url: 'milestone_title=%23upcoming', - tokenKey: 'milestone', + tokenKey: TOKEN_TYPE_MILESTONE, value: __('Upcoming'), }, { url: 'milestone_title=%23started', - tokenKey: 'milestone', + tokenKey: TOKEN_TYPE_MILESTONE, value: __('Started'), }, { url: 'release_tag=None', - tokenKey: 'release', + tokenKey: TOKEN_TYPE_RELEASE, value: __('None'), }, { url: 'release_tag=Any', - tokenKey: 'release', + tokenKey: TOKEN_TYPE_RELEASE, value: __('Any'), }, { url: 'label_name[]=None', - tokenKey: 'label', + tokenKey: TOKEN_TYPE_LABEL, value: __('None'), }, { url: 'label_name[]=Any', - tokenKey: 'label', + tokenKey: TOKEN_TYPE_LABEL, value: __('Any'), }, { url: 'my_reaction_emoji=None', - tokenKey: 'my-reaction', + tokenKey: TOKEN_TYPE_MY_REACTION, value: __('None'), }, { url: 'my_reaction_emoji=Any', - tokenKey: 'my-reaction', + tokenKey: TOKEN_TYPE_MY_REACTION, value: __('Any'), }, ].map((condition) => { diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index 1ad2006d689..33fda7533e4 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -8,6 +8,7 @@ import { createAlert } from '~/flash'; import AjaxCache from '~/lib/utils/ajax_cache'; import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; +import { TOKEN_TYPE_LABEL } from '~/vue_shared/components/filtered_search_bar/constants'; export default class VisualTokenValue { constructor(tokenValue, tokenType, tokenOperator) { @@ -23,7 +24,7 @@ export default class VisualTokenValue { return; } - if (tokenType === 'label') { + if (tokenType === TOKEN_TYPE_LABEL) { this.updateLabelTokenColor(tokenValueContainer); } else if (USER_TOKEN_TYPES.includes(tokenType)) { this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement); diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index dc6c4642e94..9e804b60d59 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -114,6 +114,7 @@ const addDismissFlashClickListener = (flashEl, fadeTransition) => { * @param {object} [options.parent] - Reference to parent element under which alert needs to appear. Defaults to `document`. * @param {Function} [options.onDismiss] - Handler to call when this alert is dismissed. * @param {string} [options.containerSelector] - Selector for the container of the alert + * @param {boolean} [options.preservePrevious] - Set to `true` to preserve previous alerts. Defaults to `false`. * @param {object} [options.primaryButton] - Object describing primary button of alert * @param {string} [options.primaryButton.link] - Href of primary button * @param {string} [options.primaryButton.text] - Text of primary button @@ -131,6 +132,7 @@ const createAlert = function createAlert({ variant = VARIANT_DANGER, parent = document, containerSelector = '.flash-container', + preservePrevious = false, primaryButton = null, secondaryButton = null, onDismiss = null, @@ -143,7 +145,11 @@ const createAlert = function createAlert({ if (!alertContainer) return null; const el = document.createElement('div'); - alertContainer.appendChild(el); + if (preservePrevious) { + alertContainer.appendChild(el); + } else { + alertContainer.replaceChildren(el); + } return new Vue({ el, diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue index 33ab1d5cd7f..89b6885091c 100644 --- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -1,6 +1,7 @@ <script> -import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { snakeCase } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; import { mapVuexModuleState } from '~/lib/utils/vuex_module_mappers'; @@ -15,7 +16,7 @@ export default { ProjectAvatar, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [trackingMixin], inject: ['vuexModule'], diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 49c47e9d778..293cd2df16f 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -538,7 +538,12 @@ class GfmAutoComplete { setupLabels($input) { const instance = this; const fetchData = this.fetchData.bind(this); - const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' }; + const LABEL_COMMAND = { + LABEL: '/label', + LABELS: '/labels', + UNLABEL: '/unlabel', + RELABEL: '/relabel', + }; let command = ''; $input.atwho({ @@ -570,13 +575,9 @@ class GfmAutoComplete { matcher(flag, subtext) { const subtextNodes = subtext.split(/\n+/g).pop().split(GfmAutoComplete.regexSubtext); - // Check if ~ is followed by '/label', '/relabel' or '/unlabel' commands. + // Check if ~ is followed by '/label', '/labels', '/relabel' or '/unlabel' commands. command = subtextNodes.find((node) => { - if ( - node === LABEL_COMMAND.LABEL || - node === LABEL_COMMAND.RELABEL || - node === LABEL_COMMAND.UNLABEL - ) { + if (Object.values(LABEL_COMMAND).includes(node)) { return node; } return null; @@ -621,7 +622,7 @@ class GfmAutoComplete { // The `LABEL_COMMAND.RELABEL` is intentionally skipped // because we want to return all the labels (unfiltered) for that command. - if (command === LABEL_COMMAND.LABEL) { + if (command === LABEL_COMMAND.LABEL || command === LABEL_COMMAND.LABELS) { // Return labels with set: undefined. return data.filter((label) => !label.set); } else if (command === LABEL_COMMAND.UNLABEL) { @@ -996,7 +997,7 @@ GfmAutoComplete.Issues = { return value.reference || '${atwho-at}${id}'; }, templateFunction({ id, title, reference }) { - return `<li><small>${reference || id}</small> ${escape(title)}</li>`; + return `<li><small>${escape(reference || id)}</small> ${escape(title)}</li>`; }, }; // Milestones diff --git a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue index f17a05999b0..bf71f682048 100644 --- a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue +++ b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue @@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { captureException } from '@sentry/browser'; import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue'; -import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml'; +import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml?raw'; import { logError } from '~/lib/logger'; import { s__ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert.vue b/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert.vue new file mode 100644 index 00000000000..89dc68ec73e --- /dev/null +++ b/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert.vue @@ -0,0 +1,76 @@ +<script> +import { GlAlert, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { UPGRADE_DOCS_URL, ABOUT_RELEASES_PAGE } from '../constants'; + +export default { + name: 'SecurityPatchUpgradeAlert', + i18n: { + alertTitle: s__('VersionCheck|Critical security upgrade available'), + alertBody: s__( + 'VersionCheck|You are currently on version %{currentVersion}. We strongly recommend upgrading your GitLab installation. %{link}', + ), + learnMore: s__('VersionCheck|Learn more about this critical security release.'), + primaryButtonText: s__('VersionCheck|Upgrade now'), + }, + components: { + GlAlert, + GlSprintf, + GlLink, + GlButton, + }, + mixins: [Tracking.mixin()], + props: { + currentVersion: { + type: String, + required: true, + }, + }, + mounted() { + this.track('render', { + label: 'security_patch_upgrade_alert', + property: this.currentVersion, + }); + }, + methods: { + trackLearnMoreClick() { + this.track('click_link', { + label: 'security_patch_upgrade_alert_learn_more', + property: this.currentVersion, + }); + }, + trackUpgradeNowClick() { + this.track('click_link', { + label: 'security_patch_upgrade_alert_upgrade_now', + property: this.currentVersion, + }); + }, + }, + UPGRADE_DOCS_URL, + ABOUT_RELEASES_PAGE, +}; +</script> + +<template> + <gl-alert :title="$options.i18n.alertTitle" variant="danger" :dismissible="false"> + <gl-sprintf :message="$options.i18n.alertBody"> + <template #currentVersion> + <span class="gl-font-weight-bold">{{ currentVersion }}</span> + </template> + <template #link> + <gl-link :href="$options.ABOUT_RELEASES_PAGE" @click="trackLearnMoreClick">{{ + $options.i18n.learnMore + }}</gl-link> + </template> + </gl-sprintf> + <template #actions> + <gl-button + :href="$options.UPGRADE_DOCS_URL" + variant="confirm" + @click="trackUpgradeNowClick" + >{{ $options.i18n.primaryButtonText }}</gl-button + > + </template> + </gl-alert> +</template> diff --git a/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert_modal.vue b/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert_modal.vue new file mode 100644 index 00000000000..4638ba8a268 --- /dev/null +++ b/app/assets/javascripts/gitlab_version_check/components/security_patch_upgrade_alert_modal.vue @@ -0,0 +1,160 @@ +<script> +import { GlModal, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { glEmojiTag } from '~/emoji'; +import { s__, sprintf } from '~/locale'; +import Tracking from '~/tracking'; +import { getHideAlertModalCookie, setHideAlertModalCookie } from '../utils'; +import { + UPGRADE_DOCS_URL, + ABOUT_RELEASES_PAGE, + ALERT_MODAL_ID, + TRACKING_ACTIONS, + TRACKING_LABELS, +} from '../constants'; + +export default { + name: 'SecurityPatchUpgradeAlertModal', + i18n: { + modalTitle: s__('VersionCheck|Important notice - Critical security release'), + modalBodyNoStableVersions: s__( + 'VersionCheck|You are currently on version %{currentVersion}! We strongly recommend upgrading your GitLab installation immediately.', + ), + modalBodyStableVersions: s__( + 'VersionCheck|You are currently on version %{currentVersion}! We strongly recommend upgrading your GitLab installation to one of the following versions immediately: %{latestStableVersions}.', + ), + modalDetails: s__('VersionCheck|%{details}'), + learnMore: s__('VersionCheck|Learn more about this critical security release.'), + primaryButtonText: s__('VersionCheck|Upgrade now'), + secondaryButtonText: s__('VersionCheck|Remind me again in 3 days'), + }, + components: { + GlModal, + GlSprintf, + GlLink, + GlButton, + }, + directives: { + SafeHtml, + }, + mixins: [Tracking.mixin()], + props: { + currentVersion: { + type: String, + required: true, + }, + details: { + type: String, + required: false, + default: '', + }, + latestStableVersions: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + visible: true, + }; + }, + computed: { + alertEmoji() { + return glEmojiTag('rotating_light'); + }, + modalBody() { + if (this.latestStableVersions?.length > 0) { + return this.$options.i18n.modalBodyStableVersions; + } + + return this.$options.i18n.modalBodyNoStableVersions; + }, + modalDetails() { + return sprintf(this.$options.i18n.modalDetails, { details: this.details }); + }, + latestStableVersionsStrings() { + return this.latestStableVersions?.length > 0 ? this.latestStableVersions.join(', ') : ''; + }, + }, + created() { + if (getHideAlertModalCookie(this.currentVersion)) { + this.visible = false; + return; + } + + this.dispatchTrackingEvent(TRACKING_ACTIONS.RENDER, TRACKING_LABELS.MODAL); + }, + methods: { + dispatchTrackingEvent(action, label) { + this.track(action, { + label, + property: this.currentVersion, + }); + }, + trackLearnMoreClick() { + this.dispatchTrackingEvent(TRACKING_ACTIONS.CLICK_LINK, TRACKING_LABELS.LEARN_MORE_LINK); + }, + trackRemindMeLaterClick() { + this.dispatchTrackingEvent(TRACKING_ACTIONS.CLICK_BUTTON, TRACKING_LABELS.REMIND_ME_BTN); + setHideAlertModalCookie(this.currentVersion); + this.$refs.alertModal.hide(); + }, + trackUpgradeNowClick() { + this.dispatchTrackingEvent(TRACKING_ACTIONS.CLICK_LINK, TRACKING_LABELS.UPGRADE_BTN_LINK); + setHideAlertModalCookie(this.currentVersion); + }, + trackModalDismissed() { + this.dispatchTrackingEvent(TRACKING_ACTIONS.CLICK_BUTTON, TRACKING_LABELS.DISMISS); + }, + }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, + UPGRADE_DOCS_URL, + ABOUT_RELEASES_PAGE, + ALERT_MODAL_ID, +}; +</script> + +<template> + <gl-modal + ref="alertModal" + :modal-id="$options.ALERT_MODAL_ID" + :visible="visible" + @close="trackModalDismissed" + > + <template #modal-title> + <span v-safe-html:[$options.safeHtmlConfig]="alertEmoji"></span> + <span data-testid="alert-modal-title">{{ $options.i18n.modalTitle }}</span> + </template> + <template #default> + <div data-testid="alert-modal-body" class="gl-mb-6"> + <gl-sprintf :message="modalBody"> + <template #currentVersion> + <span class="gl-font-weight-bold">{{ currentVersion }}</span> + </template> + <template #latestStableVersions> + <span class="gl-font-weight-bold">{{ latestStableVersionsStrings }}</span> + </template> + </gl-sprintf> + </div> + <div v-if="details" data-testid="alert-modal-details" class="gl-mb-6"> + {{ modalDetails }} + </div> + <gl-link :href="$options.ABOUT_RELEASES_PAGE" @click="trackLearnMoreClick">{{ + $options.i18n.learnMore + }}</gl-link> + </template> + <template #modal-footer> + <gl-button data-testid="alert-modal-remind-button" @click="trackRemindMeLaterClick">{{ + $options.i18n.secondaryButtonText + }}</gl-button> + <gl-button + data-testid="alert-modal-upgrade-button" + :href="$options.UPGRADE_DOCS_URL" + variant="confirm" + @click="trackUpgradeNowClick" + >{{ $options.i18n.primaryButtonText }}</gl-button + > + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/gitlab_version_check/constants.js b/app/assets/javascripts/gitlab_version_check/constants.js index 259723a4e22..049397148ab 100644 --- a/app/assets/javascripts/gitlab_version_check/constants.js +++ b/app/assets/javascripts/gitlab_version_check/constants.js @@ -7,3 +7,25 @@ export const STATUS_TYPES = { }; export const UPGRADE_DOCS_URL = helpPagePath('update/index'); + +export const ABOUT_RELEASES_PAGE = 'https://about.gitlab.com/releases/categories/releases/'; + +export const ALERT_MODAL_ID = 'security-patch-upgrade-alert-modal'; + +export const COOKIE_EXPIRATION = 3; + +export const COOKIE_SUFFIX = '-hide-alert-modal'; + +export const TRACKING_ACTIONS = { + RENDER: 'render', + CLICK_LINK: 'click_link', + CLICK_BUTTON: 'click_button', +}; + +export const TRACKING_LABELS = { + MODAL: 'security_patch_upgrade_alert_modal', + LEARN_MORE_LINK: 'security_patch_upgrade_alert_modal_learn_more', + REMIND_ME_BTN: 'security_patch_upgrade_alert_modal_remind_3_days', + UPGRADE_BTN_LINK: 'security_patch_upgrade_alert_modal_upgrade_now', + DISMISS: 'security_patch_upgrade_alert_modal_close', +}; diff --git a/app/assets/javascripts/gitlab_version_check/index.js b/app/assets/javascripts/gitlab_version_check/index.js index 203ce10ef57..edb7e9abe49 100644 --- a/app/assets/javascripts/gitlab_version_check/index.js +++ b/app/assets/javascripts/gitlab_version_check/index.js @@ -1,50 +1,98 @@ import Vue from 'vue'; -import * as Sentry from '@sentry/browser'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import axios from '~/lib/utils/axios_utils'; -import { joinPaths } from '~/lib/utils/url_utility'; +import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import GitlabVersionCheckBadge from './components/gitlab_version_check_badge.vue'; +import SecurityPatchUpgradeAlert from './components/security_patch_upgrade_alert.vue'; +import SecurityPatchUpgradeAlertModal from './components/security_patch_upgrade_alert_modal.vue'; -const mountGitlabVersionCheckBadge = ({ el, status }) => { - const { size } = el.dataset; +const mountGitlabVersionCheckBadge = (el) => { + const { size, version } = el.dataset; const actionable = parseBoolean(el.dataset.actionable); - return new Vue({ - el, - render(createElement) { - return createElement(GitlabVersionCheckBadge, { - props: { - size, - actionable, - status, - }, - }); - }, - }); + try { + const { severity } = JSON.parse(version); + + // If no severity (status) data don't worry about rendering + if (!severity) { + return null; + } + + return new Vue({ + el, + render(createElement) { + return createElement(GitlabVersionCheckBadge, { + props: { + size, + actionable, + status: severity, + }, + }); + }, + }); + } catch { + return null; + } }; -export default async () => { - const versionCheckBadges = [...document.querySelectorAll('.js-gitlab-version-check-badge')]; +const mountSecurityPatchUpgradeAlert = (el) => { + const { currentVersion } = el.dataset; - // If there are no version check elements, exit out - if (versionCheckBadges?.length <= 0) { + try { + return new Vue({ + el, + render(createElement) { + return createElement(SecurityPatchUpgradeAlert, { + props: { + currentVersion, + }, + }); + }, + }); + } catch { return null; } +}; - const status = await axios - .get(joinPaths('/', gon.relative_url_root, '/admin/version_check.json')) - .then((res) => { - return res.data?.severity; - }) - .catch((e) => { - Sentry.captureException(e); - return null; +const mountSecurityPatchUpgradeAlertModal = (el) => { + const { currentVersion, version } = el.dataset; + + try { + const { details, latestStableVersions } = convertObjectPropsToCamelCase(JSON.parse(version)); + + return new Vue({ + el, + render(createElement) { + return createElement(SecurityPatchUpgradeAlertModal, { + props: { + currentVersion, + details, + latestStableVersions, + }, + }); + }, }); + } catch { + return null; + } +}; + +export default () => { + const renderedApps = []; - // If we don't have a status there is nothing to render - if (status) { - return versionCheckBadges.map((el) => mountGitlabVersionCheckBadge({ el, status })); + const securityPatchUpgradeAlert = document.getElementById('js-security-patch-upgrade-alert'); + const securityPatchUpgradeAlertModal = document.getElementById( + 'js-security-patch-upgrade-alert-modal', + ); + const versionCheckBadges = [...document.querySelectorAll('.js-gitlab-version-check-badge')]; + + if (securityPatchUpgradeAlert) { + renderedApps.push(mountSecurityPatchUpgradeAlert(securityPatchUpgradeAlert)); } - return null; + if (securityPatchUpgradeAlertModal) { + renderedApps.push(mountSecurityPatchUpgradeAlertModal(securityPatchUpgradeAlertModal)); + } + + renderedApps.push(...versionCheckBadges.map((el) => mountGitlabVersionCheckBadge(el))); + + return renderedApps; }; diff --git a/app/assets/javascripts/gitlab_version_check/utils.js b/app/assets/javascripts/gitlab_version_check/utils.js new file mode 100644 index 00000000000..d2f4349483c --- /dev/null +++ b/app/assets/javascripts/gitlab_version_check/utils.js @@ -0,0 +1,18 @@ +import { setCookie, getCookie, parseBoolean } from '~/lib/utils/common_utils'; +import { COOKIE_EXPIRATION, COOKIE_SUFFIX } from './constants'; + +const buildKey = (currentVersion) => { + return `${currentVersion}${COOKIE_SUFFIX}`; +}; + +export const setHideAlertModalCookie = (currentVersion) => { + const key = buildKey(currentVersion); + + setCookie(key, true, { expires: COOKIE_EXPIRATION }); +}; + +export const getHideAlertModalCookie = (currentVersion) => { + const key = buildKey(currentVersion); + + return parseBoolean(getCookie(key)); +}; diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql index 64f547f933a..3ecaee435e2 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql @@ -1,4 +1,5 @@ fragment AlertListItem on AlertManagementAlert { + id iid title severity diff --git a/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql b/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql index ba1e607bc10..9ec87ba291d 100644 --- a/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql +++ b/app/assets/javascripts/graphql_shared/mutations/alert_status_update.mutation.graphql @@ -4,6 +4,7 @@ mutation updateAlertStatus($projectPath: ID!, $status: AlertManagementStatus!, $ updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) { errors alert { + id iid status endedAt diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index e8b0174b8f6..5467105ac3c 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -7,10 +7,12 @@ "CiGroupVariable", "CiInstanceVariable", "CiManualVariable", - "CiProjectVariable" + "CiProjectVariable", + "PipelineScheduleVariable" ], "CommitSignature": [ "GpgSignature", + "SshSignature", "X509Signature" ], "CurrentUserTodos": [ @@ -144,10 +146,13 @@ "WorkItemWidget": [ "WorkItemWidgetAssignees", "WorkItemWidgetDescription", + "WorkItemWidgetHealthStatus", "WorkItemWidgetHierarchy", "WorkItemWidgetIteration", "WorkItemWidgetLabels", "WorkItemWidgetMilestone", + "WorkItemWidgetNotes", + "WorkItemWidgetProgress", "WorkItemWidgetStartAndDueDate", "WorkItemWidgetStatus", "WorkItemWidgetWeight" diff --git a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql index 8debc6113d1..77b95bb8910 100644 --- a/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/alert_details.query.graphql @@ -5,6 +5,7 @@ query alertDetails($fullPath: ID!, $alertId: String) { id alertManagementAlerts(iid: $alertId) { nodes { + id ...AlertDetailItem } } diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 15f5a3518a5..46d5341ea97 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -1,21 +1,25 @@ <script> -import { GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal, GlEmptyState } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { mergeUrlParams, getParameterByName } from '~/lib/utils/url_utility'; -import { HIDDEN_CLASS } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import { COMMON_STR, CONTENT_LIST_CLASS } from '../constants'; +import { COMMON_STR } from '../constants'; import eventHub from '../event_hub'; import GroupsComponent from './groups.vue'; -import EmptyState from './empty_state.vue'; export default { + i18n: { + searchEmptyState: { + title: __('No results found'), + description: __('Edit your search and try again'), + }, + }, components: { GroupsComponent, GlModal, GlLoadingIcon, - EmptyState, + GlEmptyState, }, props: { action: { @@ -40,20 +44,14 @@ export default { type: Boolean, required: true, }, - renderEmptyState: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { isModalVisible: false, isLoading: true, - isSearchEmpty: false, + fromSearch: false, targetGroup: null, targetParentGroup: null, - showEmptyState: false, }; }, computed: { @@ -79,6 +77,9 @@ export default { groups() { return this.store.getGroups(); }, + hasGroups() { + return this.groups && this.groups.length > 0; + }, pageInfo() { return this.store.getPaginationInfo(); }, @@ -231,47 +232,17 @@ export default { this.targetGroup.isBeingRemoved = false; }); }, - showLegacyEmptyState() { - const { containerEl } = this; - - if (!containerEl) return; - - const contentListEl = containerEl.querySelector(CONTENT_LIST_CLASS); - const emptyStateEl = containerEl.querySelector('.empty-state'); - - if (contentListEl) { - contentListEl.remove(); - } - - if (emptyStateEl) { - emptyStateEl.classList.remove(HIDDEN_CLASS); - } - }, updatePagination(headers) { this.store.setPaginationInfo(headers); }, updateGroups(groups, fromSearch) { - const hasGroups = groups && groups.length > 0; - - if (this.renderEmptyState) { - this.isSearchEmpty = fromSearch && !hasGroups; - } else { - this.isSearchEmpty = !hasGroups; - } + this.fromSearch = fromSearch; if (fromSearch) { this.store.setSearchedGroups(groups); } else { this.store.setGroups(groups); } - - if (this.action && !hasGroups && !fromSearch) { - if (this.renderEmptyState) { - this.showEmptyState = true; - } else { - this.showLegacyEmptyState(); - } - } }, }, }; @@ -285,14 +256,16 @@ export default { size="lg" class="loading-animation prepend-top-20" /> - <groups-component - v-else - :groups="groups" - :search-empty="isSearchEmpty" - :page-info="pageInfo" - :action="action" - /> - <empty-state v-if="showEmptyState" /> + <template v-else> + <groups-component v-if="hasGroups" :groups="groups" :page-info="pageInfo" :action="action" /> + <gl-empty-state + v-else-if="fromSearch" + :title="$options.i18n.searchEmptyState.title" + :description="$options.i18n.searchEmptyState.description" + data-testid="search-empty-state" + /> + <slot v-else name="empty-state"></slot> + </template> <gl-modal modal-id="leave-group-modal" :visible="isModalVisible" diff --git a/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue new file mode 100644 index 00000000000..535758750f9 --- /dev/null +++ b/app/assets/javascripts/groups/components/empty_states/archived_projects_empty_state.vue @@ -0,0 +1,21 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +export default { + components: { GlEmptyState }, + i18n: { + title: s__('GroupsEmptyState|No archived projects.'), + }, + inject: ['newProjectIllustration'], +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="newProjectIllustration" + :svg-height="100" + /> +</template> diff --git a/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue new file mode 100644 index 00000000000..7223321bf3e --- /dev/null +++ b/app/assets/javascripts/groups/components/empty_states/shared_projects_empty_state.vue @@ -0,0 +1,21 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +export default { + components: { GlEmptyState }, + i18n: { + title: s__('GroupsEmptyState|No shared projects.'), + }, + inject: ['newProjectIllustration'], +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="newProjectIllustration" + :svg-height="100" + /> +</template> diff --git a/app/assets/javascripts/groups/components/empty_state.vue b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue index 4219b52737d..955cb1ca63e 100644 --- a/app/assets/javascripts/groups/components/empty_state.vue +++ b/app/assets/javascripts/groups/components/empty_states/subgroups_and_projects_empty_state.vue @@ -83,7 +83,6 @@ export default { </div> <gl-empty-state v-else - class="gl-mt-5" :title="$options.i18n.withoutLinks.title" :svg-path="emptySubgroupIllustration" :description="$options.i18n.withoutLinks.description" diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 961af800971..d9781ef9c84 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -9,8 +9,8 @@ import { GlPopover, GlLink, GlTooltipDirective, - GlSafeHtmlDirective, } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { visitUrl } from '~/lib/utils/url_utility'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; @@ -29,7 +29,7 @@ import ItemTypeIcon from './item_type_icon.vue'; export default { directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { GlAvatar, @@ -200,11 +200,9 @@ export default { class="no-expand gl-mr-3 gl-text-gray-900!" :itemprop="microdata.nameItemprop" > - {{ - // ending bracket must be by closing tag to prevent - // link hover text-decoration from over-extending - group.name - }} + <!-- ending bracket must be by closing tag to prevent --> + <!-- link hover text-decoration from over-extending --> + {{ group.name }} </a> <gl-icon v-gl-tooltip.hover.bottom diff --git a/app/assets/javascripts/groups/components/group_name_and_path.vue b/app/assets/javascripts/groups/components/group_name_and_path.vue index 9a1ea2f1812..5f997ecc7ba 100644 --- a/app/assets/javascripts/groups/components/group_name_and_path.vue +++ b/app/assets/javascripts/groups/components/group_name_and_path.vue @@ -59,7 +59,7 @@ export default { learnMore: s__('Groups|Learn more'), }, inputSize: { md: 'lg' }, - changingGroupPathHelpPagePath: helpPagePath('user/group/index', { + changingGroupPathHelpPagePath: helpPagePath('user/group/manage', { anchor: 'change-a-groups-path', }), mattermostDataBindName: 'create_chat_team', diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 43aa0753082..5075be62214 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -1,5 +1,4 @@ <script> -import { GlEmptyState } from '@gitlab/ui'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -12,7 +11,6 @@ export default { }, components: { PaginationLinks, - GlEmptyState, }, props: { groups: { @@ -23,10 +21,6 @@ export default { type: Object, required: true, }, - searchEmpty: { - type: Boolean, - required: true, - }, action: { type: String, required: false, @@ -46,18 +40,11 @@ export default { <template> <div class="groups-list-tree-container" data-qa-selector="groups_list_tree_container"> - <gl-empty-state - v-if="searchEmpty" - :title="$options.i18n.emptyStateTitle" - :description="$options.i18n.emptyStateDescription" + <group-folder :groups="groups" :action="action" /> + <pagination-links + :change="change" + :page-info="pageInfo" + class="d-flex justify-content-center gl-mt-3" /> - <template v-else> - <group-folder :groups="groups" :action="action" /> - <pagination-links - :change="change" - :page-info="pageInfo" - class="d-flex justify-content-center gl-mt-3" - /> - </template> </div> </template> diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 46ab30367a0..79a2e11b0bb 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -13,19 +13,32 @@ import { } from '../constants'; import eventHub from '../event_hub'; import GroupsApp from './app.vue'; +import SubgroupsAndProjectsEmptyState from './empty_states/subgroups_and_projects_empty_state.vue'; +import SharedProjectsEmptyState from './empty_states/shared_projects_empty_state.vue'; +import ArchivedProjectsEmptyState from './empty_states/archived_projects_empty_state.vue'; const [SORTING_ITEM_NAME] = OVERVIEW_TABS_SORTING_ITEMS; const MIN_SEARCH_LENGTH = 3; export default { - components: { GlTabs, GlTab, GroupsApp, GlSearchBoxByType, GlSorting, GlSortingItem }, + components: { + GlTabs, + GlTab, + GroupsApp, + GlSearchBoxByType, + GlSorting, + GlSortingItem, + SubgroupsAndProjectsEmptyState, + SharedProjectsEmptyState, + ArchivedProjectsEmptyState, + }, inject: ['endpoints', 'initialSort'], data() { const tabs = [ { title: this.$options.i18n[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS], key: ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, - renderEmptyState: true, + emptyStateComponent: SubgroupsAndProjectsEmptyState, lazy: this.$route.name !== ACTIVE_TAB_SUBGROUPS_AND_PROJECTS, service: new GroupsService(this.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), store: new GroupsStore({ showSchemaMarkup: true }), @@ -33,7 +46,7 @@ export default { { title: this.$options.i18n[ACTIVE_TAB_SHARED], key: ACTIVE_TAB_SHARED, - renderEmptyState: false, + emptyStateComponent: SharedProjectsEmptyState, lazy: this.$route.name !== ACTIVE_TAB_SHARED, service: new GroupsService(this.endpoints[ACTIVE_TAB_SHARED]), store: new GroupsStore(), @@ -41,7 +54,7 @@ export default { { title: this.$options.i18n[ACTIVE_TAB_ARCHIVED], key: ACTIVE_TAB_ARCHIVED, - renderEmptyState: false, + emptyStateComponent: ArchivedProjectsEmptyState, lazy: this.$route.name !== ACTIVE_TAB_ARCHIVED, service: new GroupsService(this.endpoints[ACTIVE_TAB_ARCHIVED]), store: new GroupsStore(), @@ -158,18 +171,16 @@ export default { <template> <gl-tabs content-class="gl-pt-0" :value="activeTabIndex" @input="handleTabInput"> <gl-tab - v-for="{ key, title, renderEmptyState, lazy, service, store } in tabs" + v-for="{ key, title, emptyStateComponent, lazy, service, store } in tabs" :key="key" :title="title" :lazy="lazy" > - <groups-app - :action="key" - :service="service" - :store="store" - :hide-projects="false" - :render-empty-state="renderEmptyState" - /> + <groups-app :action="key" :service="service" :store="store" :hide-projects="false"> + <template v-if="emptyStateComponent" #empty-state> + <component :is="emptyStateComponent" /> + </template> + </groups-app> </gl-tab> <template #tabs-end> <li class="gl-flex-grow-1 gl-align-self-center gl-w-full gl-lg-w-auto gl-py-2"> diff --git a/app/assets/javascripts/groups/components/transfer_group_form.vue b/app/assets/javascripts/groups/components/transfer_group_form.vue index 15a193f7cb8..3da417ebf0a 100644 --- a/app/assets/javascripts/groups/components/transfer_group_form.vue +++ b/app/assets/javascripts/groups/components/transfer_group_form.vue @@ -73,6 +73,7 @@ export default { :disabled="disableSubmitButton" :phrase="confirmationPhrase" :button-text="confirmButtonText" + button-qa-selector="transfer_group_button" @confirm="$emit('confirm')" /> </div> diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 4d03a523486..f58781fa9ec 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -1,7 +1,9 @@ import Vue from 'vue'; +import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import { highCountTrim } from '~/lib/utils/text_utility'; import Tracking from '~/tracking'; import Translate from '~/vue_shared/translate'; +import { parseBoolean } from '~/lib/utils/common_utils'; /** * Updates todo counter when todos are toggled. @@ -99,6 +101,7 @@ function trackShowUserDropdownLink(trackEvent, elToTrack, el) { }); }); } + export function initNavUserDropdownTracking() { const el = document.querySelector('.js-nav-user-dropdown'); const buyEl = document.querySelector('.js-buy-pipeline-minutes-link'); @@ -108,5 +111,23 @@ export function initNavUserDropdownTracking() { } } +function initNewNavToggle() { + const el = document.querySelector('.js-new-nav-toggle'); + if (!el) return false; + + return new Vue({ + el, + render(h) { + return h(NewNavToggle, { + props: { + enabled: parseBoolean(el.dataset.enabled), + endpoint: el.dataset.endpoint, + }, + }); + }, + }); +} + requestIdleCallback(initStatusTriggers); requestIdleCallback(initNavUserDropdownTracking); +requestIdleCallback(initNewNavToggle); diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 8fc0ce48e61..bf5daf29b21 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -4,7 +4,6 @@ import { GlOutsideDirective as Outside, GlIcon, GlToken, - GlSafeHtmlDirective as SafeHtml, GlTooltipDirective, GlResizeObserverDirective, } from '@gitlab/ui'; @@ -56,7 +55,7 @@ export default { false, ), }, - directives: { SafeHtml, Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, + directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective }, components: { GlSearchBoxByType, HeaderSearchDefaultItems, diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue index 025c48f355d..c85fb4f4158 100644 --- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue +++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue @@ -6,9 +6,9 @@ import { GlAvatar, GlAlert, GlLoadingIcon, - GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; import { mapState, mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import highlight from '~/lib/utils/highlight'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index 332ccee510f..cda3379309c 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -26,6 +26,8 @@ export const GROUPS_CATEGORY = s__('GlobalSearch|Groups'); export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects'); +export const USERS_CATEGORY = s__('GlobalSearch|Users'); + export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues'); export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests'); @@ -68,6 +70,7 @@ export const DROPDOWN_ORDER = [ RECENT_EPICS_CATEGORY, GROUPS_CATEGORY, PROJECTS_CATEGORY, + USERS_CATEGORY, IN_THIS_PROJECT_CATEGORY, SETTINGS_CATEGORY, HELP_CATEGORY, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index d02dc67d933..ef3da57c240 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -1,6 +1,7 @@ <script> -import { GlModal, GlSafeHtmlDirective, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlModal, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { n__ } from '~/locale'; import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants'; import { createUnexpectedCommitError } from '../../lib/errors'; @@ -17,7 +18,7 @@ export default { GlButton, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, GlTooltip: GlTooltipDirective, }, data() { diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue index 5272c4310d8..dd343bc5f79 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { directives: { diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue index 67eedc6b37f..eba9bbcdf09 100644 --- a/app/assets/javascripts/ide/components/error_message.vue +++ b/app/assets/javascripts/ide/components/error_message.vue @@ -1,6 +1,7 @@ <script> -import { GlAlert, GlLoadingIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { components: { @@ -8,7 +9,7 @@ export default { GlLoadingIcon, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { message: { diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index 8d6a0b99e0c..9676233a443 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -1,7 +1,8 @@ <script> -import { GlTooltipDirective, GlButton, GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui'; import { throttle } from 'lodash'; import { mapActions, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import JobDescription from './detail/description.vue'; import ScrollButton from './detail/scroll_button.vue'; @@ -14,7 +15,7 @@ const scrollPositions = { export default { directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { GlButton, diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 9a529bdcee1..ea1dbee4669 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -80,7 +80,7 @@ export default { @click="createNewItem('blob')" /> </li> - <li><upload :path="path" @create="createTempEntry" /></li> + <upload :path="path" @create="createTempEntry" /> <li> <item-button :label="__('New directory')" diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index 76d8a0aff3d..7c10e055e91 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -65,7 +65,7 @@ export default { </script> <template> - <div> + <li> <item-button :class="buttonCssClasses" :show-label="showLabel" @@ -84,5 +84,5 @@ export default { data-qa-selector="file_upload_field" @change="openFile" /> - </div> + </li> </template> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index c74a5052573..da2d4fbe7f0 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -7,7 +7,6 @@ import PipelinesList from '../pipelines/list.vue'; import Clientside from '../preview/clientside.vue'; import ResizablePanel from '../resizable_panel.vue'; import TerminalView from '../terminal/view.vue'; -import SwitchEditorsView from '../switch_editors/switch_editors_view.vue'; import CollapsibleSidebar from './collapsible_sidebar.vue'; // Need to add the width of the nav buttons since the resizable container contains those as well @@ -21,7 +20,7 @@ export default { }, computed: { ...mapState('terminal', { isTerminalVisible: 'isVisible' }), - ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled', 'canUseNewWebIde']), + ...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']), ...mapGetters(['packageJson']), ...mapState('rightPane', ['isOpen']), showLivePreview() { @@ -30,12 +29,6 @@ export default { rightExtensionTabs() { return [ { - show: this.canUseNewWebIde, - title: __('Switch editors'), - views: [{ component: SwitchEditorsView, ...rightSidebarViews.switchEditors }], - icon: 'bullhorn', - }, - { show: true, title: __('Pipelines'), views: [ @@ -60,7 +53,6 @@ export default { }, }, WIDTH, - SWITCH_EDITORS_VIEW_NAME: rightSidebarViews.switchEditors.name, }; </script> @@ -72,11 +64,6 @@ export default { :min-size="$options.WIDTH" :resizable="isOpen" > - <collapsible-sidebar - class="gl-w-full" - :extension-tabs="rightExtensionTabs" - :init-open-view="$options.SWITCH_EDITORS_VIEW_NAME" - side="right" - /> + <collapsible-sidebar class="gl-w-full" :extension-tabs="rightExtensionTabs" side="right" /> </resizable-panel> </template> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 7f513afe82e..7f662f528d7 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -1,17 +1,8 @@ <script> -import { - GlLoadingIcon, - GlIcon, - GlSafeHtmlDirective as SafeHtml, - GlTabs, - GlTab, - GlBadge, - GlAlert, -} from '@gitlab/ui'; -import { escape } from 'lodash'; +import { GlLoadingIcon, GlIcon, GlTabs, GlTab, GlBadge, GlAlert } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import IDEServices from '~/ide/services'; -import { sprintf, __ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import JobsList from '../jobs/list.vue'; import EmptyState from './empty_state.vue'; @@ -48,16 +39,6 @@ export default { 'stages', 'isLoadingJobs', ]), - ciLintText() { - return sprintf( - __('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'), - { - linkStart: `<a href="${escape(this.currentProject.web_url)}/-/ci/lint">`, - linkEnd: '</a>', - }, - false, - ); - }, showLoadingIcon() { return this.isLoadingPipeline && !this.hasLoadedPipeline; }, @@ -101,9 +82,8 @@ export default { :dismissible="false" class="gl-mt-5" > - <p class="gl-mb-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p> + <p class="gl-mb-0">{{ __('Unable to create pipeline') }}</p> <p class="gl-mb-0 break-word">{{ latestPipeline.yamlError }}</p> - <p v-safe-html="ciLintText" class="gl-mb-0"></p> </gl-alert> <gl-tabs v-else> <gl-tab :active="!pipelineFailed"> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 5f35dbdc5e7..3c9c0b1ade1 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -7,6 +7,7 @@ import { EDITOR_TYPE_CODE, EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN, + EXTENSION_CI_SCHEMA_FILE_NAME_MATCH, } from '~/editor/constants'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; @@ -26,6 +27,7 @@ import { performanceMarkAndMeasure } from '~/performance/utils'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { leftSidebarViews, viewerTypes, @@ -53,6 +55,7 @@ export default { DiffViewer, FileTemplatesBar, }, + mixins: [glFeatureFlagMixin()], props: { file: { type: Object, @@ -145,6 +148,12 @@ export default { showTabs() { return !this.shouldHideEditor && this.isEditModeActive && this.previewMode; }, + isCiConfigFile() { + return ( + this.file.path === EXTENSION_CI_SCHEMA_FILE_NAME_MATCH && + this.editor?.getEditorType() === EDITOR_TYPE_CODE + ); + }, }, watch: { 'file.name': { @@ -232,8 +241,6 @@ export default { return; } - this.registerSchemaForFile(); - Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()]) .then(() => { this.createEditorInstance(); @@ -357,6 +364,8 @@ export default { this.model.updateOptions(this.rules); + this.registerSchemaForFile(); + this.model.onChange((model) => { const { file } = model; if (!file.active) return; @@ -446,8 +455,33 @@ export default { return Promise.resolve(); }, registerSchemaForFile() { - const schema = this.getJsonSchemaForPath(this.file.path); - registerSchema(schema); + const registerExternalSchema = () => { + const schema = this.getJsonSchemaForPath(this.file.path); + return registerSchema(schema); + }; + const registerLocalSchema = async () => { + if (!this.CiSchemaExtension) { + const { CiSchemaExtension } = await import( + '~/editor/extensions/source_editor_ci_schema_ext' + ).catch((e) => + createAlert({ + message: e, + }), + ); + this.CiSchemaExtension = CiSchemaExtension; + } + this.editor.use({ definition: this.CiSchemaExtension }); + this.editor.registerCiSchema(); + }; + + if (this.isCiConfigFile && this.glFeatures.schemaLinting) { + registerLocalSchema(); + } else { + if (this.CiSchemaExtension) { + this.editor.unuse(this.CiSchemaExtension); + } + registerExternalSchema(); + } }, updateEditor(data) { // Looks like our model wrapper `.dispose` causes the monaco editor to emit some position changes after diff --git a/app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue b/app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue deleted file mode 100644 index 00164f65e33..00000000000 --- a/app/assets/javascripts/ide/components/switch_editors/switch_editors_view.vue +++ /dev/null @@ -1,103 +0,0 @@ -<script> -import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { createAlert } from '~/flash'; -import { logError } from '~/lib/logger'; -import axios from '~/lib/utils/axios_utils'; -import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; -import { s__, __ } from '~/locale'; -import eventHub from '../../eventhub'; - -export const MSG_DESCRIPTION = s__('WebIDE|You are invited to experience the new Web IDE.'); -export const MSG_BUTTON_TEXT = s__('WebIDE|Switch to new Web IDE'); -export const MSG_LEARN_MORE = __('Learn more'); -export const MSG_TITLE = s__('WebIDE|Ready for something new?'); - -export const MSG_CONFIRM = s__( - 'WebIDE|Are you sure you want to switch editors? You will lose any unsaved changes.', -); -export const MSG_ERROR_ALERT = s__( - 'WebIDE|Something went wrong while updating the user preferences. Please see developer console for details.', -); - -export default { - components: { - GlButton, - GlEmptyState, - GlLink, - }, - data() { - return { - loading: false, - }; - }, - computed: { - ...mapState(['switchEditorSvgPath', 'links', 'userPreferencesPath']), - }, - methods: { - async submitSwitch() { - const confirmed = await confirmAction(MSG_CONFIRM, { - primaryBtnText: __('Switch editors'), - cancelBtnText: __('Cancel'), - }); - - if (!confirmed) { - return; - } - - try { - await axios.put(this.userPreferencesPath, { - user: { use_legacy_web_ide: false }, - }); - } catch (e) { - // why: We do not want to translate console logs - // eslint-disable-next-line @gitlab/require-i18n-strings - logError('Error while updating user preferences', e); - createAlert({ - message: MSG_ERROR_ALERT, - }); - return; - } - - eventHub.$emit('skip-beforeunload'); - window.location.reload(); - }, - // what: ignoreWhilePending prevents double confirmation boxes - onSwitchClicked: ignoreWhilePending(async function onSwitchClicked() { - this.loading = true; - - try { - await this.submitSwitch(); - } finally { - this.loading = false; - } - }), - }, - MSG_TITLE, - MSG_DESCRIPTION, - MSG_BUTTON_TEXT, - MSG_LEARN_MORE, -}; -</script> - -<template> - <div class="gl-h-full gl-display-flex gl-flex-direction-column gl-justify-content-center"> - <gl-empty-state :svg-path="switchEditorSvgPath" :svg-height="150" :title="$options.MSG_TITLE"> - <template #description> - <span>{{ $options.MSG_DESCRIPTION }}</span> - <gl-link :href="links.newWebIDEHelpPagePath">{{ $options.MSG_LEARN_MORE }}</gl-link - >. - </template> - <template #actions> - <gl-button - category="primary" - variant="confirm" - :loading="loading" - @click="onSwitchClicked" - >{{ $options.MSG_BUTTON_TEXT }}</gl-button - > - </template> - </gl-empty-state> - </div> -</template> diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue index 623ba719b28..fa93f6d42a5 100644 --- a/app/assets/javascripts/ide/components/terminal/empty_state.vue +++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue @@ -1,5 +1,6 @@ <script> -import { GlLoadingIcon, GlButton, GlAlert, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { components: { @@ -8,7 +9,7 @@ export default { GlAlert, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { isLoading: { diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index c8e737fa6f5..01ce5fa07ee 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -61,7 +61,6 @@ export const leftSidebarViews = { }; export const rightSidebarViews = { - switchEditors: { name: 'switch-editors', keepAlive: true }, pipelines: { name: 'pipelines-list', keepAlive: true }, jobsDetail: { name: 'jobs-detail', keepAlive: false }, mergeRequestInfo: { name: 'merge-request-info', keepAlive: true }, @@ -119,3 +118,5 @@ export const DEFAULT_BRANCH = 'main'; // Ping Usage Metrics Keys export const PING_USAGE_PREVIEW_KEY = 'web_ide_clientside_preview'; export const PING_USAGE_PREVIEW_SUCCESS_KEY = 'web_ide_clientside_preview_success'; + +export const GITLAB_WEB_IDE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/377367'; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index dec282239d9..1347d92b3b7 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -8,7 +8,6 @@ import { parseBoolean } from '../lib/utils/common_utils'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; import ide from './components/ide.vue'; import { createRouter } from './ide_router'; -import { initGitlabWebIDE } from './init_gitlab_web_ide'; import { DEFAULT_THEME } from './lib/themes'; import { createStore } from './stores'; @@ -74,7 +73,6 @@ export const initLegacyWebIDE = (el, options = {}) => { codesandboxBundlerUrl: el.dataset.codesandboxBundlerUrl, environmentsGuidanceAlertDismissed: !parseBoolean(el.dataset.enableEnvironmentsGuidance), previewMarkdownPath: el.dataset.previewMarkdownPath, - canUseNewWebIde: parseBoolean(el.dataset.canUseNewWebIde), userPreferencesPath: el.dataset.userPreferencesPath, }); }, @@ -96,7 +94,7 @@ export const initLegacyWebIDE = (el, options = {}) => { * * @param {Objects} options - Extra options for the IDE (Used by EE). */ -export function startIde(options) { +export async function startIde(options) { const ideElement = document.getElementById('ide'); if (!ideElement) { @@ -106,6 +104,7 @@ export function startIde(options) { const useNewWebIde = parseBoolean(ideElement.dataset.useNewWebIde); if (useNewWebIde) { + const { initGitlabWebIDE } = await import('./init_gitlab_web_ide'); initGitlabWebIDE(ideElement); } else { resetServiceWorkersPublicPath(); diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index 140f2895a29..d3c64754e8a 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -1,29 +1,89 @@ -import { cleanTrailingSlash } from './stores/utils'; +import { start } from '@gitlab/web-ide'; +import { __ } from '~/locale'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_action'; +import { createAndSubmitForm } from '~/lib/utils/create_and_submit_form'; +import csrf from '~/lib/utils/csrf'; +import { getBaseConfig } from './lib/gitlab_web_ide/get_base_config'; +import { setupRootElement } from './lib/gitlab_web_ide/setup_root_element'; +import { GITLAB_WEB_IDE_FEEDBACK_ISSUE } from './constants'; -export const initGitlabWebIDE = async (el) => { - const { start } = await import('@gitlab/web-ide'); +const buildRemoteIdeURL = (ideRemotePath, remoteHost, remotePathArg) => { + const remotePath = cleanLeadingSeparator(remotePathArg); - const { gitlab_url: gitlabUrl } = window.gon; - const baseUrl = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin); + const replacers = { + ':remote_host': encodeURIComponent(remoteHost), + ':remote_path': encodeURIComponent(remotePath).replaceAll('%2F', '/'), + }; - // what: Pull what we need from the element. We will replace it soon. - const { cspNonce: nonce, branchName: ref, projectPath } = el.dataset; + // why: Use the function callback of "replace" so we replace both keys at once + return ideRemotePath.replace(/(:remote_host|:remote_path)/g, (key) => { + return replacers[key]; + }); +}; + +const getMRTargetProject = () => { + const url = new URL(window.location.href); + + return url.searchParams.get('target_project') || ''; +}; - // what: Clean up the element, but preserve id. - // why: This way we don't inherit any `ide-loading` side-effects. This - // mirrors the behavior of Vue when it mounts to an element. - const newEl = document.createElement(el.tagName); - newEl.id = el.id; - newEl.classList.add('gl--flex-center', 'gl-relative', 'gl-h-full'); +export const initGitlabWebIDE = async (el) => { + // what: Pull what we need from the element. We will replace it soon. + const { + cspNonce: nonce, + branchName: ref, + projectPath, + ideRemotePath, + filePath, + mergeRequest: mrId, + forkInfo: forkInfoJSON, + } = el.dataset; - el.replaceWith(newEl); + const rootEl = setupRootElement(el); + const forkInfo = forkInfoJSON ? JSON.parse(forkInfoJSON) : null; - // what: Trigger start on our new mounting element - await start(newEl, { - baseUrl: cleanTrailingSlash(baseUrl.href), + // See ClientOnlyConfig https://gitlab.com/gitlab-org/gitlab-web-ide/-/blob/main/packages/web-ide-types/src/config.ts#L17 + start(rootEl, { + ...getBaseConfig(), + nonce, + // Use same headers as defined in axios_utils + httpHeaders: { + [csrf.headerKey]: csrf.token, + 'X-Requested-With': 'XMLHttpRequest', + }, projectPath, - gitlabUrl, ref, - nonce, + filePath, + mrId, + mrTargetProject: getMRTargetProject(), + // note: At the time of writing this, forkInfo isn't expected by `@gitlab/web-ide`, + // but it will be soon. + forkInfo, + links: { + feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, + userPreferences: el.dataset.userPreferencesPath, + }, + async handleStartRemote({ remoteHost, remotePath, connectionToken }) { + const confirmed = await confirmAction( + __('Are you sure you want to leave the Web IDE? All unsaved changes will be lost.'), + { + primaryBtnText: __('Start remote connection'), + cancelBtnText: __('Continue editing'), + }, + ); + + if (!confirmed) { + return; + } + + createAndSubmitForm({ + url: buildRemoteIdeURL(ideRemotePath, remoteHost, remotePath), + data: { + connection_token: connectionToken, + return_url: window.location.href, + }, + }); + }, }); }; diff --git a/app/assets/javascripts/ide/lib/diff/controller.js b/app/assets/javascripts/ide/lib/diff/controller.js index 682914df9ec..7595a1cedf1 100644 --- a/app/assets/javascripts/ide/lib/diff/controller.js +++ b/app/assets/javascripts/ide/lib/diff/controller.js @@ -2,7 +2,7 @@ import { throttle } from 'lodash'; import { Range } from 'monaco-editor'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import Disposable from '../common/disposable'; -import DirtyDiffWorker from './diff_worker'; +import DirtyDiffWorker from './diff_worker?worker'; export const getDiffChangeType = (change) => { if (change.modified) { diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index 525afcb2083..289027c3054 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -1,3 +1,12 @@ +import { useNewFonts } from '~/lib/utils/common_utils'; +import { getCssVariable } from '~/lib/utils/css_utils'; + +const fontOptions = {}; + +if (useNewFonts()) { + fontOptions.fontFamily = getCssVariable('--code-editor-font'); +} + export const defaultEditorOptions = { model: null, readOnly: false, @@ -9,6 +18,7 @@ export const defaultEditorOptions = { wordWrap: 'on', glyphMargin: true, automaticLayout: true, + ...fontOptions, }; export const defaultDiffOptions = { @@ -27,7 +37,6 @@ export const defaultDiffEditorOptions = { }; export const defaultModelOptions = { - endOfLine: 0, insertFinalNewline: true, trimTrailingWhitespace: false, }; diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js new file mode 100644 index 00000000000..fbd2ce4ce69 --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/get_base_config.js @@ -0,0 +1,12 @@ +import { cleanEndingSeparator } from '~/lib/utils/url_utility'; + +const getBaseUrl = () => { + const baseUrlObj = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin); + + return cleanEndingSeparator(baseUrlObj.href); +}; + +export const getBaseConfig = () => ({ + baseUrl: getBaseUrl(), + gitlabUrl: window.gon.gitlab_url, +}); diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js new file mode 100644 index 00000000000..8311e11672e --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/index.js @@ -0,0 +1,2 @@ +export * from './get_base_config'; +export * from './setup_root_element'; diff --git a/app/assets/javascripts/ide/lib/gitlab_web_ide/setup_root_element.js b/app/assets/javascripts/ide/lib/gitlab_web_ide/setup_root_element.js new file mode 100644 index 00000000000..b0e06c88d26 --- /dev/null +++ b/app/assets/javascripts/ide/lib/gitlab_web_ide/setup_root_element.js @@ -0,0 +1,14 @@ +/** + * Cleans up the given element and prepares it for mounting to `@gitlab/web-ide` + * + * @param {Element} root The original root element + * @returns {Element} A new element ready to be used by `@gitlab/web-ide` + */ +export const setupRootElement = (el) => { + const newEl = document.createElement(el.tagName); + newEl.id = el.id; + newEl.classList.add('gl--flex-center', 'gl-relative', 'gl-h-full'); + el.replaceWith(newEl); + + return newEl; +}; diff --git a/app/assets/javascripts/ide/remote/index.js b/app/assets/javascripts/ide/remote/index.js new file mode 100644 index 00000000000..fb8db20c0c1 --- /dev/null +++ b/app/assets/javascripts/ide/remote/index.js @@ -0,0 +1,40 @@ +import { startRemote } from '@gitlab/web-ide'; +import { getBaseConfig, setupRootElement } from '~/ide/lib/gitlab_web_ide'; +import { isSameOriginUrl, joinPaths } from '~/lib/utils/url_utility'; + +/** + * @param {Element} rootEl + */ +export const mountRemoteIDE = async (el) => { + const { + remoteHost: remoteAuthority, + remotePath: hostPath, + cspNonce, + connectionToken, + returnUrl, + } = el.dataset; + + const rootEl = setupRootElement(el); + + const visitReturnUrl = () => { + // security: Only change `href` if of the same origin as current page + if (returnUrl && isSameOriginUrl(returnUrl)) { + window.location.href = returnUrl; + } else { + window.location.reload(); + } + }; + + startRemote(rootEl, { + ...getBaseConfig(), + nonce: cspNonce, + connectionToken, + // remoteAuthority must start with "/" + remoteAuthority: joinPaths('/', remoteAuthority), + // hostPath must start with "/" + hostPath: joinPaths('/', hostPath), + // TODO Handle error better + handleError: visitReturnUrl, + handleClose: visitReturnUrl, + }); +}; diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index 805476c71bc..1f9bc834140 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -4,7 +4,7 @@ import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout. import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; -import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; +import ciConfig from '~/ci/pipeline_editor/graphql/queries/ci_config.query.graphql'; import { query, mutate } from './gql'; export default { diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js index 91868132a5a..a510ec0847b 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js @@ -1,6 +1,6 @@ import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import * as terminalService from '../../../../services/terminals'; import { STARTING, STOPPING, STOPPED } from '../constants'; import * as messages from '../messages'; @@ -108,7 +108,7 @@ export const restartSession = ({ state, dispatch, rootState }) => { // We may have removed the build, in this case we'll just create a new session if ( responseStatus === httpStatus.NOT_FOUND || - responseStatus === httpStatus.UNPROCESSABLE_ENTITY + responseStatus === HTTP_STATUS_UNPROCESSABLE_ENTITY ) { dispatch('startSession'); } else { diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js index ec05ca84754..fa1c7f23677 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/messages.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js @@ -1,5 +1,5 @@ import { escape } from 'lodash'; -import httpStatus from '~/lib/utils/http_status'; +import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { __, sprintf } from '~/locale'; export const UNEXPECTED_ERROR_CONFIG = __( @@ -28,7 +28,7 @@ export const ERROR_PERMISSION = __( ); export const configCheckError = (status, helpUrl) => { - if (status === httpStatus.UNPROCESSABLE_ENTITY) { + if (status === HTTP_STATUS_UNPROCESSABLE_ENTITY) { return sprintf( ERROR_CONFIG, { diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index 70efda970bf..b89d9d38a1a 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -34,5 +34,4 @@ export default () => ({ environmentsGuidanceAlertDetected: false, previewMarkdownPath: '', userPreferencesPath: '', - canUseNewWebIde: false, }); diff --git a/app/assets/javascripts/import_entities/components/group_dropdown.vue b/app/assets/javascripts/import_entities/components/group_dropdown.vue index 25d4037bbe5..f351a9a392f 100644 --- a/app/assets/javascripts/import_entities/components/group_dropdown.vue +++ b/app/assets/javascripts/import_entities/components/group_dropdown.vue @@ -1,5 +1,21 @@ <script> import { GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import { s__ } from '~/locale'; +import { createAlert } from '~/flash'; +import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +const reportNamespaceLoadError = debounce( + () => + createAlert({ + message: s__('ImportProjects|Requesting namespaces failed'), + }), + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, +); export default { components: { @@ -7,18 +23,32 @@ export default { GlSearchBoxByType, }, inheritAttrs: false, - props: { - namespaces: { - type: Array, - required: true, - }, - }, data() { return { searchTerm: '' }; }, + apollo: { + namespaces: { + query: searchNamespacesWhereUserCanCreateProjectsQuery, + variables() { + return { + search: this.searchTerm, + }; + }, + skip() { + const hasNotEnoughSearchCharacters = + this.searchTerm.length > 0 && this.searchTerm.length < MINIMUM_SEARCH_LENGTH; + return hasNotEnoughSearchCharacters; + }, + update(data) { + return data.currentUser.groups.nodes; + }, + error: reportNamespaceLoadError, + debounce: DEBOUNCE_DELAY, + }, + }, computed: { filteredNamespaces() { - return this.namespaces.filter((ns) => + return (this.namespaces ?? []).filter((ns) => ns.fullPath.toLowerCase().includes(this.searchTerm.toLowerCase()), ); }, diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index 5455a034106..bd69165f0ca 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -49,7 +49,7 @@ const STATUS_MAP = { text: __('Timeout'), variant: 'danger', }, - [STATUSES.CANCELLED]: { + [STATUSES.CANCELED]: { icon: 'status-stopped', text: __('Cancelled'), variant: 'neutral', diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js index c470da21765..48b7febca4b 100644 --- a/app/assets/javascripts/import_entities/constants.js +++ b/app/assets/javascripts/import_entities/constants.js @@ -9,6 +9,10 @@ export const STATUSES = { STARTED: 'started', NONE: 'none', SCHEDULING: 'scheduling', - CANCELLED: 'cancelled', + CANCELED: 'canceled', TIMEOUT: 'timeout', }; + +export const PROVIDERS = { + GITHUB: 'github', +}; diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 66dff77eef8..6412f26fde7 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -21,12 +21,13 @@ import { getGroupPathAvailability } from '~/rest_api'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; +import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { STATUSES } from '../../constants'; import ImportStatusCell from '../../components/import_status.vue'; import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql'; import updateImportStatusMutation from '../graphql/mutations/update_import_status.mutation.graphql'; -import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql'; import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; import { NEW_NAME_FIELD, ROOT_NAMESPACE, i18n } from '../constants'; import { StatusPoller } from '../services/status_poller'; @@ -107,7 +108,12 @@ export default { return { page: this.page, filter: this.filter, perPage: this.perPage }; }, }, - availableNamespaces: availableNamespacesQuery, + availableNamespaces: { + query: searchNamespacesWhereUserCanCreateProjectsQuery, + update(data) { + return data.currentUser.groups.nodes; + }, + }, }, fields: [ @@ -158,7 +164,7 @@ export default { } return this.groups.map((group) => { - const importTarget = this.getImportTarget(group); + const importTarget = this.importTargets[group.id]; const status = this.getStatus(group); const flags = { @@ -250,10 +256,14 @@ export default { this.page = 1; }, - groupsTableData() { + groups() { const table = this.getTableRef(); const matches = new Set(); - this.groupsTableData.forEach((g, idx) => { + this.groups.forEach((g, idx) => { + if (!this.importGroups[g.id]) { + this.setDefaultImportTarget(g); + } + if (this.selectedGroupsIds.includes(g.id)) { matches.add(g.id); this.$nextTick(() => { @@ -421,7 +431,7 @@ export default { data: { exists }, } = await getGroupPathAvailability( importTarget.newName, - importTarget.targetNamespace.id, + getIdFromGraphQLId(importTarget.targetNamespace.id), { cancelToken: importTarget.cancellationToken?.token, }, @@ -444,11 +454,7 @@ export default { importTarget.validationErrors = newValidationErrors; }, VALIDATION_DEBOUNCE_TIME), - getImportTarget(group) { - if (this.importTargets[group.id]) { - return this.importTargets[group.id]; - } - + setDefaultImportTarget(group) { // If we've reached this Vue application we have at least one potential import destination const defaultTargetNamespace = // first option: namespace id was explicitly provided @@ -482,9 +488,13 @@ export default { validationErrors: [], }); - getGroupPathAvailability(importTarget.newName, importTarget.targetNamespace.id, { - cancelToken: cancellationToken.token, - }) + getGroupPathAvailability( + importTarget.newName, + getIdFromGraphQLId(importTarget.targetNamespace.id), + { + cancelToken: cancellationToken.token, + }, + ) .then(({ data: { exists, suggests: suggestions } }) => { if (!exists) return; @@ -505,7 +515,6 @@ export default { .catch(() => { // empty catch intended }); - return this.importTargets[group.id]; }, }, @@ -692,7 +701,6 @@ export default { <template #cell(importTarget)="{ item: group }"> <import-target-cell :group="group" - :available-namespaces="availableNamespaces" :group-path-regex="groupPathRegex" @update-target-namespace="updateImportTarget(group, { targetNamespace: $event })" @update-new-name="updateImportTarget(group, { newName: $event })" diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue index 4fbbd5b239c..04a90d9c20c 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue @@ -22,10 +22,6 @@ export default { type: Object, required: true, }, - availableNamespaces: { - type: Array, - required: true, - }, }, computed: { @@ -53,7 +49,6 @@ export default { #default="{ namespaces }" :text="fullPath" :disabled="!group.flags.isAvailableForImport" - :namespaces="availableNamespaces" toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" class="gl-h-7 gl-flex-grow-1" data-qa-selector="target_namespace_selector_dropdown" diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js index 36da996ea17..913a5a659b3 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -10,7 +10,6 @@ import typeDefs from './typedefs.graphql'; export const clientTypenames = { BulkImportSourceGroupConnection: 'ClientBulkImportSourceGroupConnection', BulkImportSourceGroup: 'ClientBulkImportSourceGroup', - AvailableNamespace: 'ClientAvailableNamespace', BulkImportPageInfo: 'ClientBulkImportPageInfo', BulkImportTarget: 'ClientBulkImportTarget', BulkImportProgress: 'ClientBulkImportProgress', @@ -110,15 +109,6 @@ export function createResolvers({ endpoints }) { }; return response; }, - - availableNamespaces: () => - axios.get(endpoints.availableNamespaces).then(({ data }) => - data.map((namespace) => ({ - __typename: clientTypenames.AvailableNamespace, - id: namespace.id, - fullPath: namespace.full_path, - })), - ), }, Mutation: { async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) { diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql deleted file mode 100644 index b0741dfbe5c..00000000000 --- a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql +++ /dev/null @@ -1,6 +0,0 @@ -query availableNamespaces { - availableNamespaces @client { - id - fullPath - } -} diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index 5d7e7911f5a..494a845b1f9 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -12,7 +12,6 @@ export function mountImportGroupsApp(mountElement) { const { statusPath, - availableNamespacesPath, createBulkImportPath, jobsPath, historyPath, @@ -25,7 +24,6 @@ export function mountImportGroupsApp(mountElement) { sourceUrl, endpoints: { status: statusPath, - availableNamespaces: availableNamespacesPath, createBulkImport: createBulkImportPath, }, }), diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index 97a7ed4bf55..63a36f1a79f 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -37,6 +37,11 @@ export default { required: false, default: false, }, + cancelable: { + type: Boolean, + required: false, + default: false, + }, optionalStages: { type: Array, required: false, @@ -58,9 +63,8 @@ export default { }, computed: { - ...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace', 'pageInfo']), + ...mapState(['filter', 'repositories', 'defaultTargetNamespace', 'pageInfo', 'isLoadingRepos']), ...mapGetters([ - 'isLoading', 'isImportingAnyRepo', 'importingRepoCount', 'hasImportableRepos', @@ -98,7 +102,6 @@ export default { }, mounted() { - this.fetchNamespaces(); this.fetchJobs(); if (!this.paginatable) { @@ -115,7 +118,6 @@ export default { ...mapActions([ 'fetchRepos', 'fetchJobs', - 'fetchNamespaces', 'stopJobsPolling', 'clearJobsEtagPoll', 'setFilter', @@ -196,22 +198,22 @@ export default { <provider-repo-table-row :key="repo.importSource.providerLink" :repo="repo" - :available-namespaces="namespaces" :user-namespace="defaultTargetNamespace" :optional-stages="optionalStagesSelection" + :cancelable="cancelable" /> </template> </tbody> </table> </div> <gl-intersection-observer - v-if="paginatable" + v-if="paginatable && pageInfo.hasNextPage" :key="pagePaginationStateKey" @appear="fetchRepos" /> - <gl-loading-icon v-if="isLoading" class="gl-mt-7" size="lg" /> + <gl-loading-icon v-if="isLoadingRepos" class="gl-mt-7" size="lg" /> - <div v-if="!isLoading && repositories.length === 0" class="gl-text-center"> + <div v-if="!isLoadingRepos && repositories.length === 0" class="gl-text-center"> <strong>{{ emptyStateText }}</strong> </div> </div> diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index 458e0fb1cb1..b8faf349375 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -8,13 +8,15 @@ import { GlDropdownItem, GlDropdownDivider, GlDropdownSectionHeader, + GlTooltip, } from '@gitlab/ui'; import { mapState, mapGetters, mapActions } from 'vuex'; import { __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; import ImportGroupDropdown from '../../components/group_dropdown.vue'; import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; -import { isProjectImportable, isIncompatible, getImportStatus } from '../utils'; +import { isProjectImportable, isImporting, isIncompatible, getImportStatus } from '../utils'; export default { name: 'ProviderRepoTableRow', @@ -29,6 +31,7 @@ export default { GlIcon, GlBadge, GlLink, + GlTooltip, }, props: { repo: { @@ -39,14 +42,15 @@ export default { type: String, required: true, }, - availableNamespaces: { - type: Array, - required: true, - }, optionalStages: { type: Object, required: true, }, + cancelable: { + type: Boolean, + required: false, + default: false, + }, }, computed: { @@ -73,6 +77,14 @@ export default { return getImportStatus(this.repo); }, + isImporting() { + return isImporting(this.repo); + }, + + isCancelable() { + return this.cancelable && this.isImporting && this.importStatus !== STATUSES.SCHEDULING; + }, + stats() { return this.repo.importedProject?.stats; }, @@ -96,7 +108,7 @@ export default { }, methods: { - ...mapActions(['fetchImport', 'setImportTarget']), + ...mapActions(['fetchImport', 'cancelImport', 'setImportTarget']), updateImportTarget(changedValues) { this.setImportTarget({ repoId: this.repo.importSource.id, @@ -104,6 +116,8 @@ export default { }); }, }, + + helpUrl: helpPagePath('/user/project/import/github.md'), }; </script> @@ -127,11 +141,7 @@ export default { <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> <template v-else-if="isImportNotStarted"> <div class="import-entities-target-select gl-display-flex gl-align-items-stretch gl-w-full"> - <import-group-dropdown - #default="{ namespaces }" - :text="importTarget.targetNamespace" - :namespaces="availableNamespaces" - > + <import-group-dropdown #default="{ namespaces }" :text="importTarget.targetNamespace"> <template v-if="namespaces.length"> <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header> <gl-dropdown-item @@ -168,6 +178,26 @@ export default { <import-status :status="importStatus" :stats="stats" /> </td> <td data-testid="actions" class="gl-vertical-align-top gl-pt-4"> + <gl-tooltip :target="() => $refs.cancelButton.$el"> + <div class="gl-text-left"> + <p class="gl-mb-5 gl-font-weight-bold">{{ s__('ImportProjects|Cancel import') }}</p> + {{ + s__( + 'ImportProjects|Imported files will be kept. You can import this repository again later.', + ) + }} + <gl-link :href="$options.helpUrl" target="_blank">{{ __('Learn more.') }}</gl-link> + </div> + </gl-tooltip> + <gl-button + v-show="isCancelable" + ref="cancelButton" + variant="danger" + category="secondary" + icon="cancel" + :aria-label="__('Cancel')" + @click="cancelImport({ repoId: repo.importSource.id })" + /> <gl-button v-if="isFinished" class="btn btn-default" diff --git a/app/assets/javascripts/import_entities/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js index df26d6ac4f6..197fb03af2c 100644 --- a/app/assets/javascripts/import_entities/import_projects/index.js +++ b/app/assets/javascripts/import_entities/import_projects/index.js @@ -1,10 +1,14 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { parseBoolean } from '~/lib/utils/common_utils'; import Translate from '~/vue_shared/translate'; +import createDefaultClient from '~/lib/graphql'; import ImportProjectsTable from './components/import_projects_table.vue'; + import createStore from './store'; Vue.use(Translate); +Vue.use(VueApollo); export function initStoreFromElement(element) { const { @@ -15,7 +19,7 @@ export function initStoreFromElement(element) { reposPath, jobsPath, importPath, - namespacesPath, + cancelPath, defaultTargetNamespace, paginatable, } = element.dataset; @@ -31,7 +35,7 @@ export function initStoreFromElement(element) { reposPath, jobsPath, importPath, - namespacesPath, + cancelPath, }, hasPagination: parseBoolean(paginatable), }); @@ -43,9 +47,16 @@ export function initPropsFromElement(element) { filterable: parseBoolean(element.dataset.filterable), paginatable: parseBoolean(element.dataset.paginatable), optionalStages: JSON.parse(element.dataset.optionalStages), + cancelable: Boolean(element.dataset.cancelPath), }; } +const defaultClient = createDefaultClient(); + +const apolloProvider = new VueApollo({ + defaultClient, +}); + export default function mountImportProjectsTable(mountElement) { if (!mountElement) return undefined; @@ -55,6 +66,7 @@ export default function mountImportProjectsTable(mountElement) { return new Vue({ el: mountElement, store, + apolloProvider, render(createElement) { return createElement(ImportProjectsTable, { props }); }, diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js index a30c14f9d28..e0db585eb3e 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/actions.js +++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js @@ -1,20 +1,22 @@ import Visibility from 'visibilityjs'; +import _ from 'lodash'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_TOO_MANY_REQUESTS } from '~/lib/utils/http_status'; import Poll from '~/lib/utils/poll'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { visitUrl, objectToQuery } from '~/lib/utils/url_utility'; import { s__, sprintf } from '~/locale'; import { isProjectImportable } from '../utils'; +import { PROVIDERS } from '../../constants'; import * as types from './mutation_types'; let eTagPoll; const hasRedirectInError = (e) => e?.response?.data?.error?.redirect; const redirectToUrlInError = (e) => visitUrl(e.response.data.error.redirect); -const tooManyRequests = (e) => e.response.status === httpStatusCodes.TOO_MANY_REQUESTS; +const tooManyRequests = (e) => e.response.status === HTTP_STATUS_TOO_MANY_REQUESTS; const pathWithParams = ({ path, ...params }) => { const filteredParams = Object.fromEntries( Object.entries(params).filter(([, value]) => value !== ''), @@ -22,6 +24,24 @@ const pathWithParams = ({ path, ...params }) => { const queryString = objectToQuery(filteredParams); return queryString ? `${path}?${queryString}` : path; }; +const commitPaginationData = ({ state, commit, data }) => { + const cursorsGitHubResponse = !_.isEmpty(data.pageInfo || {}); + + if (state.provider === PROVIDERS.GITHUB && cursorsGitHubResponse) { + commit(types.SET_PAGE_CURSORS, data.pageInfo); + } else { + const nextPage = state.pageInfo.page + 1; + commit(types.SET_PAGE, nextPage); + } +}; +const paginationParams = ({ state }) => { + if (state.provider === PROVIDERS.GITHUB && state.pageInfo.endCursor) { + return { after: state.pageInfo.endCursor }; + } + + const nextPage = state.pageInfo.page + 1; + return { page: nextPage === 1 ? '' : nextPage.toString() }; +}; const isRequired = () => { // eslint-disable-next-line @gitlab/require-i18n-strings @@ -55,7 +75,6 @@ const importAll = ({ state, dispatch }, config = {}) => { }; const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) => { - const nextPage = state.pageInfo.page + 1; commit(types.REQUEST_REPOS); const { provider, filter } = state; @@ -65,12 +84,13 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) pathWithParams({ path: reposPath, filter: filter ?? '', - page: nextPage === 1 ? '' : nextPage.toString(), + ...paginationParams({ state }), }), ) .then(({ data }) => { - commit(types.SET_PAGE, nextPage); - commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })); + const camelData = convertObjectPropsToCamelCase(data, { deep: true }); + commitPaginationData({ state, commit, data: camelData }); + commit(types.RECEIVE_REPOS_SUCCESS, camelData); }) .catch((e) => { if (hasRedirectInError(e)) { @@ -139,6 +159,42 @@ const fetchImportFactory = (importPath = isRequired()) => ( }); }; +export const cancelImportFactory = (cancelImportPath) => ({ state, commit }, { repoId }) => { + const existingRepo = state.repositories.find((r) => r.importSource.id === repoId); + + if (!existingRepo?.importedProject) { + throw new Error(`Attempting to cancel project which is not started: ${repoId}`); + } + + const { id } = existingRepo.importedProject; + + return axios + .post(cancelImportPath, { + project_id: id, + }) + .then(() => { + commit(types.CANCEL_IMPORT_SUCCESS, { + repoId, + }); + }) + .catch((e) => { + const serverErrorMessage = e?.response?.data?.errors; + const flashMessage = serverErrorMessage + ? sprintf( + s__('ImportProjects|Cancelling project import failed: %{reason}'), + { + reason: serverErrorMessage, + }, + false, + ) + : s__('ImportProjects|Cancelling project import failed'); + + createAlert({ + message: flashMessage, + }); + }); +}; + export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, dispatch }) => { if (eTagPoll) { stopJobsPolling(); @@ -176,22 +232,6 @@ export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, d }); }; -const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) => { - commit(types.REQUEST_NAMESPACES); - axios - .get(namespacesPath) - .then(({ data }) => - commit(types.RECEIVE_NAMESPACES_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), - ) - .catch(() => { - createAlert({ - message: s__('ImportProjects|Requesting namespaces failed'), - }); - - commit(types.RECEIVE_NAMESPACES_ERROR); - }); -}; - const setFilter = ({ commit, dispatch }, filter) => { commit(types.SET_FILTER, filter); @@ -207,6 +247,6 @@ export default ({ endpoints = isRequired() }) => ({ importAll, fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath }), fetchImport: fetchImportFactory(endpoints.importPath), + cancelImport: cancelImportFactory(endpoints.cancelPath), fetchJobs: fetchJobsFactory(endpoints.jobsPath), - fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath), }); diff --git a/app/assets/javascripts/import_entities/import_projects/store/getters.js b/app/assets/javascripts/import_entities/import_projects/store/getters.js index ef01a67ec94..31ddffd4eb4 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/getters.js +++ b/app/assets/javascripts/import_entities/import_projects/store/getters.js @@ -1,7 +1,5 @@ import { isProjectImportable, isIncompatible, isImporting } from '../utils'; -export const isLoading = (state) => state.isLoadingRepos || state.isLoadingNamespaces; - export const importingRepoCount = (state) => state.repositories.filter(isImporting).length; export const isImportingAnyRepo = (state) => state.repositories.some(isImporting); diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js index 6adf5e59cff..74832a03ac1 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js @@ -2,14 +2,12 @@ export const REQUEST_REPOS = 'REQUEST_REPOS'; export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS'; export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR'; -export const REQUEST_NAMESPACES = 'REQUEST_NAMESPACES'; -export const RECEIVE_NAMESPACES_SUCCESS = 'RECEIVE_NAMESPACES_SUCCESS'; -export const RECEIVE_NAMESPACES_ERROR = 'RECEIVE_NAMESPACES_ERROR'; - export const REQUEST_IMPORT = 'REQUEST_IMPORT'; export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS'; export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR'; +export const CANCEL_IMPORT_SUCCESS = 'CANCEL_IMPORT_SUCCESS'; + export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; export const SET_FILTER = 'SET_FILTER'; @@ -18,4 +16,4 @@ export const SET_IMPORT_TARGET = 'SET_IMPORT_TARGET'; export const SET_PAGE = 'SET_PAGE'; -export const SET_PAGE_INFO = 'SET_PAGE_INFO'; +export const SET_PAGE_CURSORS = 'SET_PAGE_CURSORS'; diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js index 163a19976de..8b2e0364d7a 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js @@ -36,7 +36,12 @@ export default { [types.SET_FILTER](state, filter) { state.filter = filter; state.repositories = []; - state.pageInfo.page = 0; + state.pageInfo = { + page: 0, + startCursor: null, + endCursor: null, + hasNextPage: true, + }; }, [types.REQUEST_REPOS](state) { @@ -51,7 +56,9 @@ export default { // https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091 const newImportedProjects = processLegacyEntries({ - newRepositories: repositories.importedProjects, + newRepositories: repositories.importedProjects.filter( + (p) => p.importStatus !== STATUSES.CANCELED, + ), existingRepositories: state.repositories, factory: makeNewImportedProject, }); @@ -122,17 +129,9 @@ export default { }); }, - [types.REQUEST_NAMESPACES](state) { - state.isLoadingNamespaces = true; - }, - - [types.RECEIVE_NAMESPACES_SUCCESS](state, namespaces) { - state.isLoadingNamespaces = false; - state.namespaces = namespaces; - }, - - [types.RECEIVE_NAMESPACES_ERROR](state) { - state.isLoadingNamespaces = false; + [types.CANCEL_IMPORT_SUCCESS](state, { repoId }) { + const existingRepo = state.repositories.find((r) => r.importSource.id === repoId); + existingRepo.importedProject.importStatus = STATUSES.CANCELED; }, [types.SET_IMPORT_TARGET](state, { repoId, importTarget }) { @@ -151,4 +150,9 @@ export default { [types.SET_PAGE](state, page) { state.pageInfo.page = page; }, + + [types.SET_PAGE_CURSORS](state, pageInfo) { + const { startCursor, endCursor, hasNextPage } = pageInfo; + state.pageInfo = { ...state.pageInfo, startCursor, endCursor, hasNextPage }; + }, }; diff --git a/app/assets/javascripts/import_entities/import_projects/store/state.js b/app/assets/javascripts/import_entities/import_projects/store/state.js index ecd93561d52..c384848f0a0 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/state.js +++ b/app/assets/javascripts/import_entities/import_projects/store/state.js @@ -1,13 +1,14 @@ export default () => ({ provider: '', repositories: [], - namespaces: [], customImportTargets: {}, isLoadingRepos: false, - isLoadingNamespaces: false, ciCdOnly: false, filter: '', pageInfo: { page: 0, + startCursor: null, + endCursor: null, + hasNextPage: true, }, }); diff --git a/app/assets/javascripts/import_entities/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js index 38bd529321a..c4c9e544c1e 100644 --- a/app/assets/javascripts/import_entities/import_projects/utils.js +++ b/app/assets/javascripts/import_entities/import_projects/utils.js @@ -9,7 +9,10 @@ export function getImportStatus(project) { } export function isProjectImportable(project) { - return !isIncompatible(project) && getImportStatus(project) === STATUSES.NONE; + return ( + !isIncompatible(project) && + [STATUSES.NONE, STATUSES.CANCELED].includes(getImportStatus(project)) + ); } export function isImporting(repo) { diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index dbd2225167a..14ab7b2dc1e 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -14,7 +14,7 @@ import { import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { s__, n__ } from '~/locale'; -import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; +import { INCIDENT_SEVERITY } from '~/sidebar/constants'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; import Tracking from '~/tracking'; import { diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js index 93baa54956a..d3850114350 100644 --- a/app/assets/javascripts/incidents_settings/incidents_settings_service.js +++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import { ERROR_MSG } from './constants'; @@ -22,7 +22,7 @@ export default class IncidentsSettingsService { .catch(({ response }) => { const message = response?.data?.message || ''; - createFlash({ + createAlert({ message: `${ERROR_MSG} ${message}`, }); }); diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index fe687ea9767..904e5639cac 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -1,14 +1,8 @@ <script> -import { - GlFormGroup, - GlFormCheckbox, - GlFormInput, - GlFormSelect, - GlFormTextarea, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; import { capitalize, lowerCase, isEmpty } from 'lodash'; import { mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { name: 'DynamicField', @@ -80,7 +74,7 @@ export default { }; }, computed: { - ...mapGetters(['isInheriting']), + ...mapGetters(['isInheriting', 'propsSource']), isCheckbox() { return this.type === 'checkbox'; }, @@ -122,11 +116,18 @@ export default { name: this.fieldName, state: this.valid, readonly: this.isInheriting, + disabled: this.isDisabled, }; }, valid() { return !this.required || !isEmpty(this.model) || this.isNonEmptyPassword || !this.isValidated; }, + isInheritingOrDisabled() { + return this.isInheriting || this.isDisabled; + }, + isDisabled() { + return !this.propsSource.editable; + }, }, created() { if (this.isNonEmptyPassword) { @@ -149,7 +150,7 @@ export default { <template v-if="isCheckbox"> <input :name="fieldName" type="hidden" :value="model || false" /> - <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheriting"> + <gl-form-checkbox :id="fieldId" v-model="model" :disabled="isInheritingOrDisabled"> {{ checkboxLabel || humanizedTitle }} <template #help> <span v-safe-html="help"></span> @@ -158,7 +159,12 @@ export default { </template> <template v-else-if="isSelect"> <input type="hidden" :name="fieldName" :value="model" /> - <gl-form-select :id="fieldId" v-model="model" :options="options" :disabled="isInheriting" /> + <gl-form-select + :id="fieldId" + v-model="model" + :options="options" + :disabled="isInheritingOrDisabled" + /> </template> <gl-form-textarea v-else-if="isTextarea" diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 4bf2b8d4468..d86e6326f64 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,22 +1,15 @@ <script> -import { - GlAlert, - GlBadge, - GlButton, - GlModalDirective, - GlSafeHtmlDirective as SafeHtml, - GlForm, -} from '@gitlab/ui'; +import { GlAlert, GlBadge, GlButton, GlForm } from '@gitlab/ui'; import axios from 'axios'; import * as Sentry from '@sentry/browser'; import { mapState, mapActions, mapGetters } from 'vuex'; import { s__ } from '~/locale'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { I18N_FETCH_TEST_SETTINGS_DEFAULT_ERROR_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, I18N_SUCCESSFUL_CONNECTION_MESSAGE, INTEGRATION_FORM_TYPE_SLACK, - integrationLevels, integrationFormSectionComponents, billingPlanNames, } from '~/integrations/constants'; @@ -25,11 +18,10 @@ import csrf from '~/lib/utils/csrf'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { testIntegrationSettings } from '../api'; import ActiveCheckbox from './active_checkbox.vue'; -import ConfirmationModal from './confirmation_modal.vue'; import DynamicField from './dynamic_field.vue'; import OverrideDropdown from './override_dropdown.vue'; -import ResetConfirmationModal from './reset_confirmation_modal.vue'; import TriggerFields from './trigger_fields.vue'; +import IntegrationFormActions from './integration_form_actions.vue'; export default { name: 'IntegrationForm', @@ -38,8 +30,7 @@ export default { ActiveCheckbox, TriggerFields, DynamicField, - ConfirmationModal, - ResetConfirmationModal, + IntegrationFormActions, IntegrationSectionConfiguration: () => import( /* webpackChunkName: 'integrationSectionConfiguration' */ '~/integrations/edit/components/sections/configuration.vue' @@ -66,7 +57,6 @@ export default { GlForm, }, directives: { - GlModal: GlModalDirective, SafeHtml, }, mixins: [glFeatureFlagsMixin()], @@ -78,10 +68,10 @@ export default { data() { return { integrationActive: false, - isTesting: false, + isValidated: false, isSaving: false, + isTesting: false, isResetting: false, - isValidated: false, }; }, computed: { @@ -90,21 +80,6 @@ export default { isEditable() { return this.propsSource.editable; }, - isInstanceOrGroupLevel() { - return ( - this.customState.integrationLevel === integrationLevels.INSTANCE || - this.customState.integrationLevel === integrationLevels.GROUP - ); - }, - showResetButton() { - return this.isInstanceOrGroupLevel && this.propsSource.resetPath; - }, - showTestButton() { - return this.propsSource.canTest; - }, - disableButtons() { - return Boolean(this.isSaving || this.isResetting || this.isTesting); - }, hasSections() { if (this.hasSlackNotificationsDisabled) { return false; @@ -134,6 +109,14 @@ export default { } return !this.hasSections && this.helpHtml; }, + shouldUpgradeSlack() { + return ( + this.isSlackIntegration && + this.glFeatures.integrationSlackAppNotifications && + this.customState.shouldUpgradeSlack && + (this.hasFieldsWithoutSection || this.hasSections) + ); + }, }, methods: { ...mapActions(['setOverride', 'requestJiraIssueTypes']), @@ -148,7 +131,6 @@ export default { }, onSaveClick() { this.isSaving = true; - if (this.integrationActive && !this.form().checkValidity()) { this.isSaving = false; this.setIsValidated(); @@ -194,7 +176,6 @@ export default { }, onResetClick() { this.isResetting = true; - return axios .post(this.propsSource.resetPath) .then(() => { @@ -227,7 +208,10 @@ export default { billingPlanNames, slackUpgradeInfo: { title: s__( - `SlackIntegration|Notifications only work if you're on the latest version of the GitLab for Slack app`, + `SlackIntegration|Update to the latest version of GitLab for Slack to get notifications`, + ), + text: s__( + `SlackIntegration|Update to the latest version to receive notifications from GitLab.`, ), btnText: s__('SlackIntegration|Update to the latest version'), }, @@ -284,16 +268,18 @@ export default { </div> </section> + <div v-if="shouldUpgradeSlack" class="gl-border-t"> + <gl-alert + :dismissible="false" + :title="$options.slackUpgradeInfo.title" + :primary-button-link="customState.upgradeSlackUrl" + :primary-button-text="$options.slackUpgradeInfo.btnText" + class="gl-mb-8 gl-mt-5" + >{{ $options.slackUpgradeInfo.text }}</gl-alert + > + </div> + <template v-if="hasSections"> - <div v-if="customState.shouldUpgradeSlack && isSlackIntegration" class="gl-border-t"> - <gl-alert - :title="$options.slackUpgradeInfo.title" - variant="warning" - :primary-button-link="customState.upgradeSlackUrl" - :primary-button-text="$options.slackUpgradeInfo.btnText" - class="gl-mb-8 gl-mt-5" - /> - </div> <div v-for="(section, index) in customState.sections" :key="section.type" @@ -344,71 +330,16 @@ export default { </div> </section> - <section v-if="isEditable" :class="!hasSections && 'gl-lg-display-flex gl-justify-content-end'"> - <div :class="!hasSections && 'gl-flex-basis-two-thirds'"> - <div - class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between" - > - <div> - <template v-if="isInstanceOrGroupLevel"> - <gl-button - v-gl-modal.confirmSaveIntegration - category="primary" - variant="confirm" - :loading="isSaving" - :disabled="disableButtons" - data-testid="save-button-instance-group" - data-qa-selector="save_changes_button" - > - {{ __('Save changes') }} - </gl-button> - <confirmation-modal @submit="onSaveClick" /> - </template> - <gl-button - v-else - category="primary" - variant="confirm" - type="submit" - :loading="isSaving" - :disabled="disableButtons" - data-testid="save-button" - data-qa-selector="save_changes_button" - @click.prevent="onSaveClick" - > - {{ __('Save changes') }} - </gl-button> - - <gl-button - v-if="showTestButton" - category="secondary" - variant="confirm" - :loading="isTesting" - :disabled="disableButtons" - data-testid="test-button" - @click.prevent="onTestClick" - > - {{ __('Test settings') }} - </gl-button> - - <gl-button :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button> - </div> - - <template v-if="showResetButton"> - <gl-button - v-gl-modal.confirmResetIntegration - category="tertiary" - variant="danger" - :loading="isResetting" - :disabled="disableButtons" - data-testid="reset-button" - > - {{ __('Reset') }} - </gl-button> - - <reset-confirmation-modal @reset="onResetClick" /> - </template> - </div> - </div> - </section> + <integration-form-actions + v-if="isEditable" + :has-sections="hasSections" + :class="{ 'gl-lg-display-flex gl-justify-content-end': !hasSections }" + :is-saving="isSaving" + :is-testing="isTesting" + :is-resetting="isResetting" + @save="onSaveClick" + @test="onTestClick" + @reset="onResetClick" + /> </gl-form> </template> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue new file mode 100644 index 00000000000..e5ad5149cf7 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/integration_form_actions.vue @@ -0,0 +1,143 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { mapState, mapGetters } from 'vuex'; +import { integrationLevels } from '~/integrations/constants'; +import ConfirmationModal from './confirmation_modal.vue'; +import ResetConfirmationModal from './reset_confirmation_modal.vue'; + +export default { + name: 'IntegrationFormActions', + components: { + GlButton, + ConfirmationModal, + ResetConfirmationModal, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + hasSections: { + type: Boolean, + required: true, + }, + isSaving: { + type: Boolean, + required: false, + default: false, + }, + isTesting: { + type: Boolean, + required: false, + default: false, + }, + isResetting: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapGetters(['propsSource']), + ...mapState(['customState']), + isInstanceOrGroupLevel() { + return ( + this.customState.integrationLevel === integrationLevels.INSTANCE || + this.customState.integrationLevel === integrationLevels.GROUP + ); + }, + showResetButton() { + return this.isInstanceOrGroupLevel && this.propsSource.resetPath; + }, + showTestButton() { + return this.propsSource.canTest; + }, + disableButtons() { + return Boolean(this.isSaving || this.isResetting || this.isTesting); + }, + }, + methods: { + onSaveClick() { + this.$emit('save'); + }, + onTestClick() { + this.$emit('test'); + }, + onResetClick() { + this.$emit('reset'); + }, + }, +}; +</script> +<template> + <section> + <div :class="{ 'gl-flex-basis-two-thirds': !hasSections }"> + <div + class="footer-block row-content-block gl-lg-display-flex gl-justify-content-space-between" + > + <div> + <template v-if="isInstanceOrGroupLevel"> + <gl-button + v-gl-modal.confirmSaveIntegration + category="primary" + variant="confirm" + :loading="isSaving" + :disabled="disableButtons" + data-testid="save-button" + data-qa-selector="save_changes_button" + > + {{ __('Save changes') }} + </gl-button> + <confirmation-modal @submit="onSaveClick" /> + </template> + <gl-button + v-else + category="primary" + variant="confirm" + type="submit" + :loading="isSaving" + :disabled="disableButtons" + data-testid="save-button" + data-qa-selector="save_changes_button" + @click.prevent="onSaveClick" + > + {{ __('Save changes') }} + </gl-button> + + <gl-button + v-if="showTestButton" + category="secondary" + variant="confirm" + :loading="isTesting" + :disabled="disableButtons" + data-testid="test-button" + @click.prevent="onTestClick" + > + {{ __('Test settings') }} + </gl-button> + + <gl-button + :href="propsSource.cancelPath" + data-testid="cancel-button" + :disabled="disableButtons" + >{{ __('Cancel') }}</gl-button + > + </div> + + <template v-if="showResetButton"> + <gl-button + v-gl-modal.confirmResetIntegration + category="tertiary" + variant="danger" + :loading="isResetting" + :disabled="disableButtons" + data-testid="reset-button" + > + {{ __('Reset') }} + </gl-button> + + <reset-confirmation-modal @reset="onResetClick" /> + </template> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index f15ad5e052e..b53bcd50f16 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -108,6 +108,7 @@ export default function initIntegrationSettingsForm() { const initialState = { defaultState: null, customState: customSettingsProps, + editable: customSettingsProps.editable && !customSettingsProps.shouldUpgradeSlack, }; if (defaultSettingsEl) { initialState.defaultState = Object.freeze(parseDatasetToProps(defaultSettingsEl.dataset)); diff --git a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue index 31b7fd4cc42..b4e9a3a1559 100644 --- a/app/assets/javascripts/invite_members/components/import_project_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue @@ -5,6 +5,10 @@ import { importProjectMembers } from '~/api/projects_api'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __, sprintf } from '~/locale'; import eventHub from '../event_hub'; +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '../utils/trigger_successful_invite_alert'; import ProjectSelect from './project_select.vue'; export default { @@ -24,6 +28,11 @@ export default { type: String, required: true, }, + reloadPageOnSubmit: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -59,6 +68,10 @@ export default { }, }, mounted() { + if (this.reloadPageOnSubmit) { + displaySuccessfulInvitationAlert(); + } + eventHub.$on('openProjectMembersModal', () => { this.openModal(); }); @@ -74,16 +87,22 @@ export default { submitImport() { this.isLoading = true; return importProjectMembers(this.projectId, this.projectToBeImported.id) - .then(this.showToastMessage) + .then(this.onInviteSuccess) .catch(this.showErrorAlert) .finally(() => { this.isLoading = false; this.projectToBeImported = {}; }); }, + onInviteSuccess() { + if (this.reloadPageOnSubmit) { + reloadOnInvitationSuccess(); + } else { + this.showToastMessage(); + } + }, showToastMessage() { this.$toast.show(this.$options.i18n.successMessage, this.$options.toastOptions); - this.closeModal(); }, showErrorAlert() { diff --git a/app/assets/javascripts/invite_members/components/invite_group_notification.vue b/app/assets/javascripts/invite_members/components/invite_group_notification.vue new file mode 100644 index 00000000000..767675cc64c --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_group_notification.vue @@ -0,0 +1,37 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { GROUP_MODAL_ALERT_BODY } from '../constants'; + +const SHARE_GROUP_LINK = + 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group'; + +export default { + SHARE_GROUP_LINK, + name: 'InviteGroupNotification', + components: { GlAlert, GlSprintf, GlLink }, + inject: ['freeUsersLimit'], + props: { + name: { + type: String, + required: true, + }, + }, + i18n: { + body: GROUP_MODAL_ALERT_BODY, + }, +}; +</script> + +<template> + <gl-alert variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.i18n.body"> + <template #link="{ content }"> + <gl-link :href="$options.SHARE_GROUP_LINK" target="_blank" class="gl-label-link">{{ + content + }}</gl-link> + </template> + + <template #count>{{ freeUsersLimit }}</template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index 2ad4bb1a11a..3be3b9df747 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -6,13 +6,19 @@ import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_b import { GROUP_FILTERS, GROUP_MODAL_LABELS } from '../constants'; import eventHub from '../event_hub'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '../utils/trigger_successful_invite_alert'; import GroupSelect from './group_select.vue'; +import InviteGroupNotification from './invite_group_notification.vue'; export default { name: 'InviteMembersModal', components: { GroupSelect, InviteModalBase, + InviteGroupNotification, }, props: { id: { @@ -31,6 +37,10 @@ export default { type: String, required: true, }, + fullPath: { + type: String, + required: true, + }, accessLevels: { type: Object, required: true, @@ -57,6 +67,15 @@ export default { type: Array, required: true, }, + freeUserCapEnabled: { + type: Boolean, + required: true, + }, + reloadPageOnSubmit: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -85,6 +104,10 @@ export default { }, }, mounted() { + if (this.reloadPageOnSubmit) { + displaySuccessfulInvitationAlert(); + } + eventHub.$on('openGroupModal', () => { this.openModal(); }); @@ -114,7 +137,7 @@ export default { expires_at: expiresAt, }) .then(() => { - this.showSuccessMessage(); + this.onInviteSuccess(); }) .catch((e) => { this.showInvalidFeedbackMessage(e); @@ -128,6 +151,13 @@ export default { this.isLoading = false; this.groupToBeSharedWith = {}; }, + onInviteSuccess() { + if (this.reloadPageOnSubmit) { + reloadOnInvitationSuccess(); + } else { + this.showSuccessMessage(); + } + }, showSuccessMessage() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); this.closeModal(); @@ -155,9 +185,14 @@ export default { :root-group-id="rootId" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" + :full-path="fullPath" @reset="resetFields" @submit="sendInvite" > + <template #alert> + <invite-group-notification v-if="freeUserCapEnabled" :name="name" /> + </template> + <template #select> <group-select v-model="groupToBeSharedWith" diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index f61e822bf7e..fbb547c28ff 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -29,6 +29,10 @@ import eventHub from '../event_hub'; import { responseFromSuccess } from '../utils/response_message_parser'; import { memberName } from '../utils/member_utils'; import { getInvalidFeedbackMessage } from '../utils/get_invalid_feedback_message'; +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '../utils/trigger_successful_invite_alert'; import ModalConfetti from './confetti.vue'; import MembersTokenSelect from './members_token_select.vue'; import UserLimitNotification from './user_limit_notification.vue'; @@ -98,11 +102,20 @@ export default { type: Array, required: true, }, + fullPath: { + type: String, + required: true, + }, usersLimitDataset: { type: Object, required: false, default: () => ({}), }, + reloadPageOnSubmit: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -119,7 +132,7 @@ export default { selectedAccessLevel: undefined, errorsLimit: 2, isErrorsSectionExpanded: false, - emptyInvitesError: false, + shouldShowEmptyInvitesAlert: false, }; }, computed: { @@ -204,12 +217,15 @@ export default { count: this.errorsExpanded.length, }); }, + formGroupDescription() { + return this.invalidFeedbackMessage ? null : this.$options.labels.placeHolder; + }, }, watch: { isEmptyInvites: { handler(updatedValue) { // nothing to do if the invites are **still** empty and the emptyInvites were never set from submit - if (!updatedValue && !this.emptyInvitesError) { + if (!updatedValue && !this.shouldShowEmptyInvitesAlert) { return; } @@ -218,6 +234,10 @@ export default { }, }, mounted() { + if (this.reloadPageOnSubmit) { + displaySuccessfulInvitationAlert(); + } + eventHub.$on('openModal', (options) => { this.openModal(options); if (this.isOnLearnGitlab) { @@ -258,16 +278,17 @@ export default { const tracking = new ExperimentTracking(experimentName); tracking.event(eventName); }, - showEmptyInvitesError() { - this.invalidFeedbackMessage = this.$options.labels.emptyInvitesErrorText; - this.emptyInvitesError = true; + showEmptyInvitesAlert() { + this.invalidFeedbackMessage = this.$options.labels.placeHolder; + this.shouldShowEmptyInvitesAlert = true; + this.$refs.alerts.focus(); }, sendInvite({ accessLevel, expiresAt }) { this.isLoading = true; this.clearValidation(); if (!this.isEmptyInvites) { - this.showEmptyInvitesError(); + this.showEmptyInvitesAlert(); return; } @@ -298,7 +319,7 @@ export default { if (error) { this.showMemberErrors(message); } else { - this.showSuccessMessage(); + this.onInviteSuccess(); } }) .catch((e) => this.showInvalidFeedbackMessage(e)) @@ -308,6 +329,7 @@ export default { }, showMemberErrors(message) { this.invalidMembers = message; + this.$refs.alerts.focus(); }, tokenName(username) { // initial token creation hits this and nothing is found... so safe navigation @@ -322,6 +344,7 @@ export default { resetFields() { this.clearValidation(); this.isLoading = false; + this.shouldShowEmptyInvitesAlert = false; this.newUsersToInvite = []; this.selectedTasksToBeDone = []; [this.selectedTaskProject] = this.projects; @@ -329,6 +352,13 @@ export default { changeSelectedTaskProject(project) { this.selectedTaskProject = project; }, + onInviteSuccess() { + if (this.reloadPageOnSubmit) { + reloadOnInvitationSuccess(); + } else { + this.showSuccessMessage(); + } + }, showSuccessMessage() { if (this.isOnLearnGitlab) { eventHub.$emit('showSuccessfulInvitationsAlert'); @@ -347,7 +377,7 @@ export default { }, clearEmptyInviteError() { this.invalidFeedbackMessage = ''; - this.emptyInvitesError = false; + this.shouldShowEmptyInvitesAlert = false; }, removeToken(token) { delete this.invalidMembers[memberName(token)]; @@ -370,12 +400,13 @@ export default { :help-link="helpLink" :label-intro-text="labelIntroText" :label-search-field="$options.labels.searchField" - :form-group-description="$options.labels.placeHolder" + :form-group-description="formGroupDescription" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" :new-users-to-invite="newUsersToInvite" :root-group-id="rootId" :users-limit-dataset="usersLimitDataset" + :full-path="fullPath" @reset="resetFields" @submit="sendInvite" @access-level="onAccessLevelUpdate" @@ -390,59 +421,77 @@ export default { </template> <template #alert> - <gl-alert - v-if="hasInvalidMembers" - variant="danger" - :dismissible="false" - :title="memberErrorTitle" - data-testid="alert-member-error" - > - {{ $options.labels.memberErrorListText }} - <ul class="gl-pl-5 gl-mb-0"> - <li v-for="error in errorsLimited" :key="error.member" data-testid="errors-limited-item"> - <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} - </li> - </ul> - <template v-if="shouldErrorsSectionExpand"> - <gl-collapse v-model="isErrorsSectionExpanded"> - <ul class="gl-pl-5 gl-mb-0"> - <li - v-for="error in errorsExpanded" - :key="error.member" - data-testid="errors-expanded-item" - > - <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} - </li> - </ul> - </gl-collapse> - <gl-button - class="gl-text-decoration-none! gl-shadow-none! gl-mt-3" - data-testid="accordion-button" - variant="link" - @click="toggleErrorExpansion" - > - {{ errorCollapseText }} - <gl-icon - name="chevron-down" - class="gl-transition-medium" - :class="{ 'gl-rotate-180': isErrorsSectionExpanded }" - /> - </gl-button> - </template> - </gl-alert> - <user-limit-notification - v-else-if="showUserLimitNotification" - :limit-variant="limitVariant" - :users-limit-dataset="usersLimitDataset" - /> + <div ref="alerts" tabindex="-1"> + <gl-alert + v-if="shouldShowEmptyInvitesAlert" + id="empty-invites-alert" + class="gl-mb-4" + variant="danger" + :dismissible="false" + data-testid="empty-invites-alert" + > + {{ $options.labels.emptyInvitesAlertText }} + </gl-alert> + <gl-alert + v-if="hasInvalidMembers" + class="gl-mb-4" + variant="danger" + :dismissible="false" + :title="memberErrorTitle" + data-testid="alert-member-error" + > + {{ $options.labels.memberErrorListText }} + <ul class="gl-pl-5 gl-mb-0"> + <li + v-for="error in errorsLimited" + :key="error.member" + data-testid="errors-limited-item" + > + <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} + </li> + </ul> + <template v-if="shouldErrorsSectionExpand"> + <gl-collapse v-model="isErrorsSectionExpanded"> + <ul class="gl-pl-5 gl-mb-0"> + <li + v-for="error in errorsExpanded" + :key="error.member" + data-testid="errors-expanded-item" + > + <strong>{{ error.displayedMemberName }}:</strong> {{ error.message }} + </li> + </ul> + </gl-collapse> + <gl-button + class="gl-text-decoration-none! gl-shadow-none! gl-mt-3" + data-testid="accordion-button" + variant="link" + @click="toggleErrorExpansion" + > + {{ errorCollapseText }} + <gl-icon + name="chevron-down" + class="gl-transition-medium" + :class="{ 'gl-rotate-180': isErrorsSectionExpanded }" + /> + </gl-button> + </template> + </gl-alert> + <user-limit-notification + v-else-if="showUserLimitNotification" + :limit-variant="limitVariant" + :users-limit-dataset="usersLimitDataset" + /> + </div> </template> - <template #select="{ exceptionState, labelId }"> + <template #select="{ exceptionState, inputId }"> <members-token-select v-model="newUsersToInvite" class="gl-mb-2" + aria-labelledby="empty-invites-alert" + :input-id="inputId" :exception-state="exceptionState" - :aria-labelledby="labelId" :users-filter="usersFilter" :filter-id="filterId" :invalid-members="invalidMembers" diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue index e3511a49fc5..2cbd681c67d 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -1,14 +1,5 @@ <script> -import { - GlFormGroup, - GlModal, - GlDropdown, - GlDropdownItem, - GlDatepicker, - GlLink, - GlSprintf, - GlFormInput, -} from '@gitlab/ui'; +import { GlFormGroup, GlFormSelect, GlModal, GlDatepicker, GlLink, GlSprintf } from '@gitlab/ui'; import Tracking from '~/tracking'; import { sprintf } from '~/locale'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; @@ -37,13 +28,11 @@ const DEFAULT_SLOTS = [ export default { components: { GlFormGroup, + GlFormSelect, GlDatepicker, GlLink, GlModal, - GlDropdown, - GlDropdownItem, GlSprintf, - GlFormInput, ContentTransition, }, mixins: [Tracking.mixin()], @@ -141,14 +130,23 @@ export default { }; }, computed: { + accessLevelsOptions() { + return Object.entries(this.accessLevels).map(([text, value]) => ({ text, value })); + }, introText() { return sprintf(this.labelIntroText, { name: this.name }); }, exceptionState() { return this.invalidFeedbackMessage ? false : null; }, - selectLabelId() { - return `${this.modalId}_select`; + selectId() { + return `${this.modalId}_search`; + }, + dropdownId() { + return `${this.modalId}_dropdown`; + }, + datepickerId() { + return `${this.modalId}_expires_at`; }, selectedRoleName() { return Object.keys(this.accessLevels).find( @@ -218,9 +216,6 @@ export default { this.$emit('cancel'); }, - changeSelectedItem(item) { - this.selectedAccessLevel = item; - }, onSubmit(e) { // We never want to hide when submitting e.preventDefault(); @@ -279,64 +274,50 @@ export default { <slot name="alert"></slot> <gl-form-group + :label="labelSearchField" + :label-for="selectId" :invalid-feedback="invalidFeedbackMessage" :state="exceptionState" :description="formGroupDescription" data-testid="members-form-group" > - <label :id="selectLabelId" class="col-form-label">{{ labelSearchField }}</label> - <slot name="select" v-bind="{ exceptionState, labelId: selectLabelId }"></slot> + <slot name="select" v-bind="{ exceptionState, inputId: selectId }"></slot> </gl-form-group> - <label class="gl-font-weight-bold">{{ $options.ACCESS_LEVEL }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-dropdown - class="gl-shadow-none gl-w-full" + <gl-form-group + class="gl-w-half gl-xs-w-full" + :label="$options.ACCESS_LEVEL" + :label-for="dropdownId" + > + <template #description> + <gl-sprintf :message="$options.READ_MORE_TEXT"> + <template #link="{ content }"> + <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + <gl-form-select + :id="dropdownId" + v-model="selectedAccessLevel" data-qa-selector="access_level_dropdown" - v-bind="$attrs" - :text="selectedRoleName" - > - <template v-for="(key, item) in accessLevels"> - <gl-dropdown-item - :key="key" - active-class="is-active" - is-check-item - :is-checked="key === selectedAccessLevel" - @click="changeSelectedItem(key)" - > - <div>{{ item }}</div> - </gl-dropdown-item> - </template> - </gl-dropdown> - </div> - - <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-sprintf :message="$options.READ_MORE_TEXT"> - <template #link="{ content }"> - <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </div> + :options="accessLevelsOptions" + /> + </gl-form-group> - <label class="gl-mt-5 gl-display-block" for="expires_at">{{ - $options.ACCESS_EXPIRE_DATE - }}</label> - <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> + <gl-form-group + class="gl-w-half gl-xs-w-full" + :label="$options.ACCESS_EXPIRE_DATE" + :label-for="datepickerId" + > <gl-datepicker v-model="selectedDate" - class="gl-display-inline!" + :input-id="datepickerId" + class="gl-display-block!" :min-date="minDate" :target="null" - > - <template #default="{ formattedDate }"> - <gl-form-input - class="gl-w-full" - :value="formattedDate" - :placeholder="__(`YYYY-MM-DD`)" - /> - </template> - </gl-datepicker> - </div> + /> + </gl-form-group> + <slot name="form-after"></slot> </template> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 2ddb04e1eeb..68602068699 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -49,6 +49,11 @@ export default { type: Object, required: true, }, + inputId: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -84,6 +89,13 @@ export default { hasInvalidMembers() { return !isEmpty(this.invalidMembers); }, + textInputAttrs() { + return { + 'data-testid': 'members-token-select-input', + 'data-qa-selector': 'members_token_select_input', + id: this.inputId, + }; + }, }, watch: { // We might not really want this to be *reactive* since we want the "class" state to be @@ -183,10 +195,7 @@ export default { :hide-dropdown-with-no-items="hideDropdownWithNoItems" :placeholder="placeholderText" :aria-labelledby="ariaLabelledby" - :text-input-attrs="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - 'data-testid': 'members-token-select-input', - 'data-qa-selector': 'members_token_select_input', - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :text-input-attrs="textInputAttrs" @blur="handleBlur" @text-input="handleTextInput" @input="handleInput" diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index de7b1019782..a894eb24d38 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -9,6 +9,7 @@ export const INVITE_MEMBERS_FOR_TASK = { view: 'modal_opened_from_email', submit: 'submit', }; +export const TOAST_MESSAGE_LOCALSTORAGE_KEY = 'members_invited_successfully'; export const GROUP_FILTERS = { ALL: 'all', @@ -57,6 +58,10 @@ export const GROUP_MODAL_TO_PROJECT_DEFAULT_INTRO_TEXT = s__( "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.", ); +export const GROUP_MODAL_ALERT_BODY = s__( + 'InviteMembersModal| Inviting a group %{linkStart}adds its members to your group%{linkEnd}, including members who join after the invite. This might put your group over the free %{count} user limit.', +); + export const GROUP_SEARCH_FIELD = s__('InviteMembersModal|Select a group to invite'); export const GROUP_PLACEHOLDER = s__('InviteMembersModal|Search for a group to invite'); @@ -77,9 +82,7 @@ export const MEMBER_ERROR_LIST_TEXT = s__( ); export const COLLAPSED_ERRORS = s__('InviteMembersModal|Show more (%{count})'); export const EXPANDED_ERRORS = s__('InviteMembersModal|Show less'); -export const EMPTY_INVITES_ERROR_TEXT = s__( - 'InviteMembersModal|Please select members or type email addresses to invite', -); +export const EMPTY_INVITES_ALERT_TEXT = s__('InviteMembersModal|Please add members to invite'); export const MEMBER_MODAL_LABELS = { modal: { @@ -117,7 +120,7 @@ export const MEMBER_MODAL_LABELS = { memberErrorListText: MEMBER_ERROR_LIST_TEXT, collapsedErrors: COLLAPSED_ERRORS, expandedErrors: EXPANDED_ERRORS, - emptyInvitesErrorText: EMPTY_INVITES_ERROR_TEXT, + emptyInvitesAlertText: EMPTY_INVITES_ALERT_TEXT, }; export const GROUP_MODAL_LABELS = { diff --git a/app/assets/javascripts/invite_members/init_import_project_members_modal.js b/app/assets/javascripts/invite_members/init_import_project_members_modal.js index daaa1315884..227d8395250 100644 --- a/app/assets/javascripts/invite_members/init_import_project_members_modal.js +++ b/app/assets/javascripts/invite_members/init_import_project_members_modal.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; export default function initImportProjectMembersModal() { const el = document.querySelector('.js-import-project-members-modal'); @@ -8,7 +9,7 @@ export default function initImportProjectMembersModal() { return false; } - const { projectId, projectName } = el.dataset; + const { projectId, projectName, reloadPageOnSubmit } = el.dataset; return new Vue({ el, @@ -17,6 +18,7 @@ export default function initImportProjectMembersModal() { props: { projectId, projectName, + reloadPageOnSubmit: parseBoolean(reloadPageOnSubmit), }, }), }); diff --git a/app/assets/javascripts/invite_members/init_invite_groups_modal.js b/app/assets/javascripts/invite_members/init_invite_groups_modal.js index be1576ad0b0..53b756b610f 100644 --- a/app/assets/javascripts/invite_members/init_invite_groups_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_groups_modal.js @@ -28,6 +28,9 @@ export default function initInviteGroupsModal() { return new Vue({ el, + provide: { + freeUsersLimit: parseInt(el.dataset.freeUsersLimit, 10), + }, render: (createElement) => createElement(InviteGroupsModal, { props: { @@ -38,6 +41,8 @@ export default function initInviteGroupsModal() { groupSelectFilter: el.dataset.groupsFilter, groupSelectParentId: parseInt(el.dataset.parentId, 10), invalidGroups: JSON.parse(el.dataset.invalidGroups || '[]'), + freeUserCapEnabled: parseBoolean(el.dataset.freeUserCapEnabled), + reloadPageOnSubmit: parseBoolean(el.dataset.reloadPageOnSubmit), }, }), }); diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index a4be3f205a3..842ab07f368 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -41,6 +41,7 @@ export default (function initInviteMembersModal() { usersLimitDataset: convertObjectPropsToCamelCase( JSON.parse(el.dataset.usersLimitDataset || '{}'), ), + reloadPageOnSubmit: parseBoolean(el.dataset.reloadPageOnSubmit), }, }), }); diff --git a/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js b/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js new file mode 100644 index 00000000000..4d3a7951265 --- /dev/null +++ b/app/assets/javascripts/invite_members/utils/trigger_successful_invite_alert.js @@ -0,0 +1,23 @@ +import { createAlert } from '~/flash'; +import AccessorUtilities from '~/lib/utils/accessor'; + +import { TOAST_MESSAGE_LOCALSTORAGE_KEY, TOAST_MESSAGE_SUCCESSFUL } from '../constants'; + +export function displaySuccessfulInvitationAlert() { + if (!AccessorUtilities.canUseLocalStorage()) { + return; + } + + const showAlert = Boolean(localStorage.getItem(TOAST_MESSAGE_LOCALSTORAGE_KEY)); + if (showAlert) { + localStorage.removeItem(TOAST_MESSAGE_LOCALSTORAGE_KEY); + createAlert({ message: TOAST_MESSAGE_SUCCESSFUL, variant: 'info' }); + } +} + +export function reloadOnInvitationSuccess() { + if (AccessorUtilities.canUseLocalStorage()) { + localStorage.setItem(TOAST_MESSAGE_LOCALSTORAGE_KEY, 'true'); + } + window.location.reload(); +} diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js b/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js deleted file mode 100644 index 68133ceb3c7..00000000000 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/constants.js +++ /dev/null @@ -1,23 +0,0 @@ -import { __ } from '~/locale'; - -export const statusDropdownOptions = [ - { - text: __('Open'), - value: 'reopen', - }, - { - text: __('Closed'), - value: 'close', - }, -]; - -export const subscriptionsDropdownOptions = [ - { - text: __('Subscribe'), - value: 'subscribe', - }, - { - text: __('Unsubscribe'), - value: 'unsubscribe', - }, -]; diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js b/app/assets/javascripts/issuable/bulk_update_sidebar/index.js deleted file mode 100644 index b7cb805ee37..00000000000 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/index.js +++ /dev/null @@ -1,75 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { gqlClient } from '../../issues/list/graphql'; -import StatusDropdown from './components/status_dropdown.vue'; -import SubscriptionsDropdown from './components/subscriptions_dropdown.vue'; -import MoveIssuesButton from './components/move_issues_button.vue'; -import issuableBulkUpdateActions from './issuable_bulk_update_actions'; -import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; - -export function initBulkUpdateSidebar(prefixId) { - const el = document.querySelector('.issues-bulk-update'); - - if (!el) { - return; - } - - issuableBulkUpdateActions.init({ prefixId }); - new IssuableBulkUpdateSidebar(); // eslint-disable-line no-new -} - -export function initStatusDropdown() { - const el = document.querySelector('.js-status-dropdown'); - - if (!el) { - return null; - } - - return new Vue({ - el, - name: 'StatusDropdownRoot', - render: (createElement) => createElement(StatusDropdown), - }); -} - -export function initSubscriptionsDropdown() { - const el = document.querySelector('.js-subscriptions-dropdown'); - - if (!el) { - return null; - } - - return new Vue({ - el, - name: 'SubscriptionsDropdownRoot', - render: (createElement) => createElement(SubscriptionsDropdown), - }); -} - -export function initMoveIssuesButton() { - const el = document.querySelector('.js-move-issues'); - - if (!el) { - return null; - } - - const { dataset } = el; - - Vue.use(VueApollo); - const apolloProvider = new VueApollo({ - defaultClient: gqlClient, - }); - - return new Vue({ - el, - name: 'MoveIssuesRoot', - apolloProvider, - render: (createElement) => - createElement(MoveIssuesButton, { - props: { - projectFullPath: dataset.projectFullPath, - projectsFetchPath: dataset.projectsFetchPath, - }, - }), - }); -} diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 254248ef1d4..fd55f05e955 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -1,13 +1,7 @@ <script> import '~/commons/bootstrap'; -import { - GlIcon, - GlLink, - GlTooltip, - GlTooltipDirective, - GlButton, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlIcon, GlLink, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; diff --git a/app/assets/javascripts/issuable/index.js b/app/assets/javascripts/issuable/index.js index 10dbefce503..ed336deb2ed 100644 --- a/app/assets/javascripts/issuable/index.js +++ b/app/assets/javascripts/issuable/index.js @@ -1,12 +1,25 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; -import IssuableContext from '~/issuable/issuable_context'; import { parseBoolean } from '~/lib/utils/common_utils'; import Sidebar from '~/right_sidebar'; import { getSidebarOptions } from '~/sidebar/mount_sidebar'; import CsvImportExportButtons from './components/csv_import_export_buttons.vue'; import IssuableByEmail from './components/issuable_by_email.vue'; import IssuableHeaderWarnings from './components/issuable_header_warnings.vue'; +import issuableBulkUpdateActions from './issuable_bulk_update_actions'; +import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar'; +import IssuableContext from './issuable_context'; + +export function initBulkUpdateSidebar(prefixId) { + const el = document.querySelector('.issues-bulk-update'); + + if (!el) { + return; + } + + issuableBulkUpdateActions.init({ prefixId }); + new IssuableBulkUpdateSidebar(); // eslint-disable-line no-new +} export function initCsvImportExportButtons() { const el = document.querySelector('.js-csv-import-export-buttons'); diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js index 14824820c0d..c386267501a 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_actions.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { difference, intersection, union } from 'lodash'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -32,7 +32,7 @@ export default { onFormSubmitFailure() { this.form.find('[type="submit"]').enable(); - return createFlash({ + return createAlert({ message: __('Issue update failed'), }); }, diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js index b46a95c7dfa..095da60a583 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable/issuable_bulk_update_sidebar.js @@ -3,7 +3,12 @@ import $ from 'jquery'; import issuableEventHub from '~/issues/list/eventhub'; import LabelsSelect from '~/labels/labels_select'; -import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; +import { + mountMilestoneDropdown, + mountMoveIssuesButton, + mountStatusDropdown, + mountSubscriptionsDropdown, +} from '~/sidebar/mount_sidebar'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; const HIDDEN_CLASS = 'hidden'; @@ -56,6 +61,9 @@ export default class IssuableBulkUpdateSidebar { initDropdowns() { new LabelsSelect(); mountMilestoneDropdown(); + mountMoveIssuesButton(); + mountStatusDropdown(); + mountSubscriptionsDropdown(); // Checking IS_EE and using ee_else_ce is odd, but we do it here to satisfy // the import/no-unresolved lint rule when FOSS_ONLY=1, even though at diff --git a/app/assets/javascripts/issuable/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js new file mode 100644 index 00000000000..ad8bbf04d6f --- /dev/null +++ b/app/assets/javascripts/issuable/issuable_label_selector.js @@ -0,0 +1,56 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { + DropdownVariant, + LabelType, +} from '~/sidebar/components/labels/labels_select_widget/constants'; +import { WorkspaceType } from '~/issues/constants'; +import IssuableLabelSelector from '~/vue_shared/issuable/create/components/issuable_label_selector.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default () => { + const el = document.querySelector('.js-issuable-form-label-selector'); + + if (!el) { + return false; + } + + const { + fieldName, + fullPath, + initialLabels, + issuableType, + labelsFilterBasePath, + labelsManagePath, + } = el.dataset; + + return new Vue({ + el, + apolloProvider, + provide: { + allowLabelCreate: true, + allowLabelEdit: true, + allowLabelRemove: true, + allowScopedLabels: true, + attrWorkspacePath: fullPath, + fieldName, + fullPath, + initialLabels: JSON.parse(initialLabels), + issuableType, + labelType: LabelType.project, + labelsFilterBasePath, + labelsManagePath, + variant: DropdownVariant.Embedded, + workspaceType: WorkspaceType.project, + }, + render(createElement) { + return createElement(IssuableLabelSelector); + }, + }); +}; diff --git a/app/assets/javascripts/issues/create_merge_request_dropdown.js b/app/assets/javascripts/issues/create_merge_request_dropdown.js index 92ff7f21eff..977a505437d 100644 --- a/app/assets/javascripts/issues/create_merge_request_dropdown.js +++ b/app/assets/javascripts/issues/create_merge_request_dropdown.js @@ -7,7 +7,7 @@ import { import confidentialMergeRequestState from '~/confidential_merge_request/state'; import DropLab from '~/filtered_search/droplab/drop_lab_deprecated'; import ISetter from '~/filtered_search/droplab/plugins/input_setter'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -141,7 +141,7 @@ export default class CreateMergeRequestDropdown { .catch(() => { this.unavailable(); this.disable(); - createFlash({ + createAlert({ message: __('Failed to check related branches.'), }); }); @@ -162,7 +162,7 @@ export default class CreateMergeRequestDropdown { } }) .catch(() => - createFlash({ + createAlert({ message: __('Failed to create a branch for this issue. Please try again.'), }), ); @@ -293,7 +293,7 @@ export default class CreateMergeRequestDropdown { } this.unavailable(); this.disable(); - createFlash({ + createAlert({ message: __('Failed to get ref.'), }); diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index 29f6aecca03..b9d876ef72f 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -1,13 +1,50 @@ <script> -import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlTooltipDirective } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql'; +import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; +import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; +import { IssuableStatus } from '~/issues/constants'; +import { + CREATED_DESC, + PAGE_SIZE, + PARAM_STATE, + UPDATED_DESC, + urlSortParams, +} from '~/issues/list/constants'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; +import { + convertToApiParams, + convertToSearchQuery, + convertToUrlParams, + getFilterTokens, + getInitialPageParams, + getSortKey, + getSortOptions, + isSortKey, +} from '~/issues/list/utils'; +import axios from '~/lib/utils/axios_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_AUTHOR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); + export default { i18n: { calendarButtonText: __('Subscribe to calendar'), + closed: __('CLOSED'), + closedMoved: __('CLOSED (MOVED)'), emptyStateTitle: __('Please select at least one filter to see results'), + errorFetchingIssues: __('An error occurred while loading issues'), rssButtonText: __('Subscribe to RSS feed'), searchInputPlaceholder: __('Search or filter results...'), }, @@ -16,29 +53,237 @@ export default { GlButton, GlEmptyState, IssuableList, + IssueCardStatistics, + IssueCardTimeInfo, + }, + directives: { + GlTooltip: GlTooltipDirective, }, - inject: ['calendarPath', 'emptyStateSvgPath', 'isSignedIn', 'rssPath'], + inject: [ + 'calendarPath', + 'emptyStateSvgPath', + 'hasBlockedIssuesFeature', + 'hasIssuableHealthStatusFeature', + 'hasIssueWeightsFeature', + 'hasScopedLabelsFeature', + 'initialSort', + 'isPublicVisibilityRestricted', + 'isSignedIn', + 'rssPath', + ], data() { + const state = getParameterByName(PARAM_STATE); + + const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; + const dashboardSortKey = getSortKey(this.initialSort); + const graphQLSortKey = + isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase(); + + // The initial sort is an old enum value when it is saved on the dashboard issues page. + // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. + const sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; + return { + filterTokens: getFilterTokens(window.location.search), issues: [], - searchTokens: [], - sortOptions: [], - state: IssuableStates.Opened, + issuesError: null, + pageInfo: {}, + pageParams: getInitialPageParams(), + sortKey, + state: state || IssuableStates.Opened, }; }, + apollo: { + issues: { + query: getIssuesQuery, + variables() { + return { + hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn, + isSignedIn: this.isSignedIn, + search: this.searchQuery, + sort: this.sortKey, + state: this.state, + ...this.pageParams, + ...this.apiFilterParams, + }; + }, + update(data) { + return data.issues.nodes ?? []; + }, + result({ data }) { + this.pageInfo = data?.issues.pageInfo ?? {}; + }, + error(error) { + this.issuesError = this.$options.i18n.errorFetchingIssues; + Sentry.captureException(error); + }, + debounce: 200, + }, + }, + computed: { + apiFilterParams() { + return convertToApiParams(this.filterTokens); + }, + searchQuery() { + return convertToSearchQuery(this.filterTokens); + }, + searchTokens() { + const preloadedUsers = []; + + if (gon.current_user_id) { + preloadedUsers.push({ + id: gon.current_user_id, + name: gon.current_user_fullname, + username: gon.current_username, + avatar_url: gon.current_user_avatar_url, + }); + } + + const tokens = [ + { + type: TOKEN_TYPE_ASSIGNEE, + title: TOKEN_TITLE_ASSIGNEE, + icon: 'user', + token: UserToken, + fetchUsers: this.fetchUsers, + preloadedUsers, + recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-assignee', + }, + { + type: TOKEN_TYPE_AUTHOR, + title: TOKEN_TITLE_AUTHOR, + icon: 'pencil', + token: UserToken, + fetchUsers: this.fetchUsers, + defaultUsers: [], + preloadedUsers, + recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-author', + }, + ]; + + return tokens; + }, + showPaginationControls() { + return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); + }, + sortOptions() { + return getSortOptions({ + hasBlockedIssuesFeature: this.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: this.hasIssueWeightsFeature, + }); + }, + urlFilterParams() { + return convertToUrlParams(this.filterTokens); + }, + urlParams() { + return { + search: this.searchQuery, + sort: urlSortParams[this.sortKey], + state: this.state, + ...this.urlFilterParams, + }; + }, + }, + methods: { + fetchUsers(search) { + return axios.get('/-/autocomplete/users.json', { params: { active: true, search } }); + }, + getStatus(issue) { + if (issue.state === IssuableStatus.Closed && issue.moved) { + return this.$options.i18n.closedMoved; + } + if (issue.state === IssuableStatus.Closed) { + return this.$options.i18n.closed; + } + return undefined; + }, + handleClickTab(state) { + if (this.state === state) { + return; + } + this.state = state; + this.pageParams = getInitialPageParams(); + }, + handleDismissAlert() { + this.issuesError = null; + }, + handleFilter(tokens) { + this.filterTokens = tokens; + this.pageParams = getInitialPageParams(); + }, + handleNextPage() { + this.pageParams = { + afterCursor: this.pageInfo.endCursor, + firstPageSize: PAGE_SIZE, + }; + scrollUp(); + }, + handlePreviousPage() { + this.pageParams = { + beforeCursor: this.pageInfo.startCursor, + lastPageSize: PAGE_SIZE, + }; + scrollUp(); + }, + handleSort(sortKey) { + if (this.sortKey === sortKey) { + return; + } + + this.sortKey = sortKey; + this.pageParams = getInitialPageParams(); + + if (this.isSignedIn) { + this.saveSortPreference(sortKey); + } + }, + saveSortPreference(sortKey) { + this.$apollo + .mutate({ + mutation: setSortPreferenceMutation, + variables: { input: { issuesSort: sortKey } }, + }) + .then(({ data }) => { + if (data.userPreferencesUpdate.errors.length) { + throw new Error(data.userPreferencesUpdate.errors); + } + }) + .catch((error) => { + Sentry.captureException(error); + }); + }, + }, }; </script> <template> <issuable-list + :current-tab="state" + :error="issuesError" + :has-next-page="pageInfo.hasNextPage" + :has-previous-page="pageInfo.hasPreviousPage" + :has-scoped-labels-feature="hasScopedLabelsFeature" + :initial-filter-value="filterTokens" + :initial-sort-by="sortKey" + :issuables="issues" + :issuables-loading="$apollo.queries.issues.loading" namespace="dashboard" recent-searches-storage-key="issues" :search-input-placeholder="$options.i18n.searchInputPlaceholder" :search-tokens="searchTokens" + :show-pagination-controls="showPaginationControls" + show-work-item-type-icon :sort-options="sortOptions" - :issuables="issues" :tabs="$options.IssuableListTabs" - :current-tab="state" + :url-params="urlParams" + use-keyset-pagination + @click-tab="handleClickTab" + @dismiss-alert="handleDismissAlert" + @filter="handleFilter" + @next-page="handleNextPage" + @previous-page="handlePreviousPage" + @sort="handleSort" > <template #nav-actions> <gl-button :href="rssPath" icon="rss"> @@ -49,6 +294,18 @@ export default { </gl-button> </template> + <template #timeframe="{ issuable = {} }"> + <issue-card-time-info :issue="issuable" /> + </template> + + <template #status="{ issuable = {} }"> + {{ getStatus(issuable) }} + </template> + + <template #statistics="{ issuable = {} }"> + <issue-card-statistics :issue="issuable" /> + </template> + <template #empty-state> <gl-empty-state :svg-path="emptyStateSvgPath" :title="$options.i18n.emptyStateTitle" /> </template> diff --git a/app/assets/javascripts/issues/dashboard/index.js b/app/assets/javascripts/issues/dashboard/index.js index a1ae3b93f7d..e3e5cc614cb 100644 --- a/app/assets/javascripts/issues/dashboard/index.js +++ b/app/assets/javascripts/issues/dashboard/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import IssuesDashboardApp from './components/issues_dashboard_app.vue'; @@ -9,14 +11,36 @@ export function mountIssuesDashboardApp() { return null; } - const { calendarPath, emptyStateSvgPath, isSignedIn, rssPath } = el.dataset; + Vue.use(VueApollo); + + const { + calendarPath, + emptyStateSvgPath, + hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature, + hasIssueWeightsFeature, + hasScopedLabelsFeature, + initialSort, + isPublicVisibilityRestricted, + isSignedIn, + rssPath, + } = el.dataset; return new Vue({ el, name: 'IssuesDashboardRoot', + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), provide: { calendarPath, emptyStateSvgPath, + hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), + hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), + hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), + initialSort, + isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted), isSignedIn: parseBoolean(isSignedIn), rssPath, }, diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql new file mode 100644 index 00000000000..8ffcb456755 --- /dev/null +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql @@ -0,0 +1,36 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/issues/list/queries/issue.fragment.graphql" + +query getDashboardIssues( + $hideUsers: Boolean = false + $isSignedIn: Boolean = false + $search: String + $sort: IssueSort + $state: IssuableState + $assigneeUsernames: [String!] + $authorUsername: String + $afterCursor: String + $beforeCursor: String + $firstPageSize: Int + $lastPageSize: Int +) { + issues( + search: $search + sort: $sort + state: $state + assigneeUsernames: $assigneeUsernames + authorUsername: $authorUsername + after: $afterCursor + before: $beforeCursor + first: $firstPageSize + last: $lastPageSize + ) { + nodes { + ...IssueFragment + reference(full: true) + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index a785790169d..e3716d0e111 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; +import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; @@ -39,6 +40,7 @@ export function initFilteredSearchServiceDesk() { export function initForm() { new GLForm($('.issue-form')); // eslint-disable-line no-new new IssuableForm($('.issue-form')); // eslint-disable-line no-new + IssuableLabelSelector(); new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new new LabelsSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js index a9321cf200d..de1c689e590 100644 --- a/app/assets/javascripts/issues/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; @@ -68,7 +68,7 @@ export default class Issue { this.createMergeRequestDropdown.checkAbilityToCreateBranch(); } } else { - createFlash({ + createAlert({ message: issueFailMessage, }); } @@ -105,7 +105,7 @@ export default class Issue { } }) .catch(() => - createFlash({ + createAlert({ message: __('Failed to load related branches'), }), ); diff --git a/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue new file mode 100644 index 00000000000..8aece24de0c --- /dev/null +++ b/app/assets/javascripts/issues/list/components/empty_state_with_any_issues.vue @@ -0,0 +1,53 @@ +<script> +import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { i18n } from '../constants'; + +export default { + i18n, + components: { + GlButton, + GlEmptyState, + }, + inject: ['emptyStateSvgPath', 'newIssuePath', 'showNewIssueLink'], + props: { + hasSearch: { + type: Boolean, + required: true, + }, + isOpenTab: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <gl-empty-state + v-if="hasSearch" + :description="$options.i18n.noSearchResultsDescription" + :title="$options.i18n.noSearchResultsTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> + + <gl-empty-state + v-else-if="isOpenTab" + :description="$options.i18n.noOpenIssuesDescription" + :title="$options.i18n.noOpenIssuesTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + </gl-empty-state> + + <gl-empty-state v-else :title="$options.i18n.noClosedIssuesTitle" :svg-path="emptyStateSvgPath" /> +</template> diff --git a/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue new file mode 100644 index 00000000000..5a37751410a --- /dev/null +++ b/app/assets/javascripts/issues/list/components/empty_state_without_any_issues.vue @@ -0,0 +1,110 @@ +<script> +import { GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import { i18n } from '../constants'; +import NewIssueDropdown from './new_issue_dropdown.vue'; + +export default { + i18n, + issuesHelpPagePath: helpPagePath('user/project/issues/index'), + components: { + CsvImportExportButtons, + GlButton, + GlEmptyState, + GlLink, + GlSprintf, + NewIssueDropdown, + }, + inject: [ + 'canCreateProjects', + 'emptyStateSvgPath', + 'isSignedIn', + 'jiraIntegrationPath', + 'newIssuePath', + 'newProjectPath', + 'showNewIssueLink', + 'signInPath', + ], + props: { + currentTabCount: { + type: Number, + required: false, + default: undefined, + }, + exportCsvPathWithQuery: { + type: String, + required: false, + default: '', + }, + showCsvButtons: { + type: Boolean, + required: false, + default: false, + }, + showNewIssueDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, +}; +</script> + +<template> + <div v-if="isSignedIn"> + <gl-empty-state :title="$options.i18n.noIssuesTitle" :svg-path="emptyStateSvgPath"> + <template #description> + <gl-link :href="$options.issuesHelpPagePath"> + {{ $options.i18n.noIssuesDescription }} + </gl-link> + <p v-if="canCreateProjects"> + <strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong> + </p> + </template> + <template #actions> + <gl-button v-if="canCreateProjects" :href="newProjectPath" variant="confirm"> + {{ $options.i18n.newProjectLabel }} + </gl-button> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + <csv-import-export-buttons + v-if="showCsvButtons" + class="gl-w-full gl-sm-w-auto gl-sm-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="currentTabCount" + /> + <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" /> + </template> + </gl-empty-state> + <hr /> + <p class="gl-text-center gl-font-weight-bold gl-mb-0"> + {{ $options.i18n.jiraIntegrationTitle }} + </p> + <p class="gl-text-center gl-mb-0"> + <gl-sprintf :message="$options.i18n.jiraIntegrationMessage"> + <template #jiraDocsLink="{ content }"> + <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p class="gl-text-center gl-text-secondary"> + {{ $options.i18n.jiraIntegrationSecondaryMessage }} + </p> + </div> + + <gl-empty-state + v-else + :title="$options.i18n.noIssuesTitle" + :svg-path="emptyStateSvgPath" + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + > + <template #description> + <gl-link :href="$options.issuesHelpPagePath"> + {{ $options.i18n.noIssuesDescription }} + </gl-link> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/issues/list/components/issue_card_statistics.vue b/app/assets/javascripts/issues/list/components/issue_card_statistics.vue new file mode 100644 index 00000000000..2d00c3e549d --- /dev/null +++ b/app/assets/javascripts/issues/list/components/issue_card_statistics.vue @@ -0,0 +1,56 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { i18n } from '../constants'; + +export default { + i18n, + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + issue: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <ul class="gl-display-contents"> + <li + v-if="issue.mergeRequestsCount" + v-gl-tooltip + class="gl-display-none gl-sm-display-block gl-mr-3" + :title="$options.i18n.relatedMergeRequests" + data-testid="merge-requests" + > + <gl-icon name="merge-request" /> + {{ issue.mergeRequestsCount }} + </li> + <li + v-if="issue.upvotes" + v-gl-tooltip + class="gl-display-none gl-sm-display-block gl-mr-3" + :title="$options.i18n.upvotes" + data-testid="issuable-upvotes" + > + <gl-icon name="thumb-up" /> + {{ issue.upvotes }} + </li> + <li + v-if="issue.downvotes" + v-gl-tooltip + class="gl-display-none gl-sm-display-block gl-mr-3" + :title="$options.i18n.downvotes" + data-testid="issuable-downvotes" + > + <gl-icon name="thumb-down" /> + {{ issue.downvotes }} + </li> + <slot></slot> + </ul> +</template> diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index 64de4b1947b..12a83f06453 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -1,19 +1,12 @@ <script> -import { - GlButton, - GlEmptyState, - GlFilteredSearchToken, - GlIcon, - GlLink, - GlSprintf, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlButton, GlFilteredSearchToken, GlTooltipDirective } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { TYPE_USER } from '~/graphql_shared/constants'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ITEM_TYPE } from '~/groups/constants'; @@ -24,11 +17,11 @@ import axios from '~/lib/utils/axios_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; -import { helpPagePath } from '~/helpers/help_page_helper'; import { - DEFAULT_NONE_ANY, FILTERED_SEARCH_TERM, - OPERATOR_IS_ONLY, + OPERATORS_IS, + OPERATORS_IS_NOT, + OPERATORS_IS_NOT_OR, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, @@ -38,9 +31,8 @@ import { TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_ORGANIZATION, TOKEN_TITLE_RELEASE, + TOKEN_TITLE_SEARCH_WITHIN, TOKEN_TITLE_TYPE, - OPERATOR_IS_NOT_OR, - OPERATOR_IS_AND_IS_NOT, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -50,6 +42,7 @@ import { TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_ORGANIZATION, TOKEN_TYPE_RELEASE, + TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; @@ -70,11 +63,9 @@ import { PARAM_SORT, PARAM_STATE, RELATIVE_POSITION_ASC, - TYPE_TOKEN_TASK_OPTION, UPDATED_DESC, urlSortParams, } from '../constants'; - import eventHub from '../eventhub'; import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql'; import searchLabelsQuery from '../queries/search_labels.query.graphql'; @@ -91,10 +82,11 @@ import { getSortOptions, isSortKey, } from '../utils'; +import EmptyStateWithAnyIssues from './empty_state_with_any_issues.vue'; +import EmptyStateWithoutAnyIssues from './empty_state_without_any_issues.vue'; import NewIssueDropdown from './new_issue_dropdown.vue'; -const AuthorToken = () => - import('~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'); +const UserToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'); const EmojiToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); const LabelToken = () => @@ -113,13 +105,12 @@ export default { IssuableListTabs, components: { CsvImportExportButtons, + EmptyStateWithAnyIssues, + EmptyStateWithoutAnyIssues, GlButton, - GlEmptyState, - GlIcon, - GlLink, - GlSprintf, IssuableByEmail, IssuableList, + IssueCardStatistics, IssueCardTimeInfo, NewIssueDropdown, }, @@ -131,15 +122,14 @@ export default { 'autocompleteAwardEmojisPath', 'calendarPath', 'canBulkUpdate', - 'canCreateProjects', 'canReadCrmContact', 'canReadCrmOrganization', - 'emptyStateSvgPath', 'exportCsvPath', 'fullPath', 'hasAnyIssues', 'hasAnyProjects', 'hasBlockedIssuesFeature', + 'hasIssuableHealthStatusFeature', 'hasIssueWeightsFeature', 'hasScopedLabelsFeature', 'initialEmail', @@ -149,13 +139,10 @@ export default { 'isProject', 'isPublicVisibilityRestricted', 'isSignedIn', - 'jiraIntegrationPath', 'newIssuePath', - 'newProjectPath', 'releasesPath', 'rssPath', 'showNewIssueLink', - 'signInPath', ], props: { eeSearchTokens: { @@ -163,6 +150,21 @@ export default { required: false, default: () => [], }, + eeTypeTokenOptions: { + type: Array, + required: false, + default: () => [], + }, + eeWorkItemTypes: { + type: Array, + required: false, + default: () => [], + }, + eeIsOkrsEnabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -189,10 +191,7 @@ export default { return data[this.namespace]?.issues.nodes ?? []; }, result({ data }) { - if (!data) { - return; - } - this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {}; + this.pageInfo = data?.[this.namespace]?.issues.pageInfo ?? {}; this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }, error(error) { @@ -239,24 +238,27 @@ export default { state: this.state, ...this.pageParams, ...this.apiFilterParams, - types: this.apiFilterParams.types || defaultWorkItemTypes, + types: this.apiFilterParams.types || this.defaultWorkItemTypes, }; }, namespace() { return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; }, + defaultWorkItemTypes() { + return [...defaultWorkItemTypes, ...this.eeWorkItemTypes]; + }, typeTokenOptions() { - return defaultTypeTokenOptions.concat(TYPE_TOKEN_TASK_OPTION); + return [...defaultTypeTokenOptions, ...this.eeTypeTokenOptions]; }, hasOrFeature() { return this.glFeatures.orIssuableQueries; }, hasSearch() { - return ( + return Boolean( this.searchQuery || - Object.keys(this.urlFilterParams).length || - this.pageParams.afterCursor || - this.pageParams.beforeCursor + Object.keys(this.urlFilterParams).length || + this.pageParams.afterCursor || + this.pageParams.beforeCursor, ); }, isBulkEditButtonDisabled() { @@ -284,13 +286,13 @@ export default { return convertToUrlParams(this.filterTokens); }, searchQuery() { - return convertToSearchQuery(this.filterTokens) || undefined; + return convertToSearchQuery(this.filterTokens); }, searchTokens() { - const preloadedAuthors = []; + const preloadedUsers = []; if (gon.current_user_id) { - preloadedAuthors.push({ + preloadedUsers.push({ id: convertToGraphQLId(TYPE_USER, gon.current_user_id), name: gon.current_user_fullname, username: gon.current_username, @@ -300,28 +302,41 @@ export default { const tokens = [ { + type: TOKEN_TYPE_SEARCH_WITHIN, + title: TOKEN_TITLE_SEARCH_WITHIN, + icon: 'search', + token: GlFilteredSearchToken, + unique: true, + operators: OPERATORS_IS, + options: [ + { icon: 'title', value: 'TITLE', title: this.$options.i18n.titles }, + { + icon: 'text-description', + value: 'DESCRIPTION', + title: this.$options.i18n.descriptions, + }, + ], + }, + { type: TOKEN_TYPE_AUTHOR, title: TOKEN_TITLE_AUTHOR, icon: 'pencil', - token: AuthorToken, - dataType: 'user', - unique: true, - defaultAuthors: [], - fetchAuthors: this.fetchUsers, + token: UserToken, + defaultUsers: [], + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchUsers: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-author`, - preloadedAuthors, + preloadedUsers, }, { type: TOKEN_TYPE_ASSIGNEE, title: TOKEN_TITLE_ASSIGNEE, icon: 'user', - token: AuthorToken, - dataType: 'user', - defaultAuthors: DEFAULT_NONE_ANY, - operators: this.hasOrFeature ? OPERATOR_IS_NOT_OR : OPERATOR_IS_AND_IS_NOT, - fetchAuthors: this.fetchUsers, + token: UserToken, + operators: this.hasOrFeature ? OPERATORS_IS_NOT_OR : OPERATORS_IS_NOT, + fetchUsers: this.fetchUsers, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-assignee`, - preloadedAuthors, + preloadedUsers, }, { type: TOKEN_TYPE_MILESTONE, @@ -337,7 +352,6 @@ export default { title: TOKEN_TITLE_LABEL, icon: 'labels', token: LabelToken, - defaultLabels: DEFAULT_NONE_ANY, fetchLabels: this.fetchLabels, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-label`, }, @@ -378,7 +392,7 @@ export default { icon: 'eye-slash', token: GlFilteredSearchToken, unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes }, { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, @@ -394,9 +408,8 @@ export default { token: CrmContactToken, fullPath: this.fullPath, isProject: this.isProject, - defaultContacts: DEFAULT_NONE_ANY, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-contacts`, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, unique: true, }); } @@ -409,9 +422,8 @@ export default { token: CrmOrganizationToken, fullPath: this.fullPath, isProject: this.isProject, - defaultOrganizations: DEFAULT_NONE_ANY, recentSuggestionsStorageKey: `${this.fullPath}-issues-recent-tokens-crm-organizations`, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, unique: true, }); } @@ -428,11 +440,14 @@ export default { return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage); }, showPageSizeControls() { - /** only show page size controls when the tab count is greater than the default/minimum page size control i.e 20 in this case */ return this.currentTabCount > PAGE_SIZE; }, sortOptions() { - return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature); + return getSortOptions({ + hasBlockedIssuesFeature: this.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: this.hasIssueWeightsFeature, + }); }, tabCounts() { const { openedIssues, closedIssues, allIssues } = this.issuesCounts; @@ -457,10 +472,7 @@ export default { page_before: this.pageParams.beforeCursor ?? undefined, }; }, - issuesHelpPagePath() { - return helpPagePath('user/project/issues/index'); - }, - shouldDisableSomeFilters() { + shouldDisableTextSearch() { return this.isAnonymousSearchDisabled && !this.isSignedIn; }, }, @@ -482,18 +494,17 @@ export default { eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar); }, methods: { - fetchWithCache(path, cacheName, searchKey, search, wrapData = false) { + fetchWithCache(path, cacheName, searchKey, search) { if (this.cache[cacheName]) { const data = search ? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey }) : this.cache[cacheName].slice(0, MAX_LIST_SIZE); - return wrapData ? Promise.resolve({ data }) : Promise.resolve(data); + return Promise.resolve(data); } return axios.get(path).then(({ data }) => { this.cache[cacheName] = data; - const result = data.slice(0, MAX_LIST_SIZE); - return wrapData ? { data: result } : result; + return data.slice(0, MAX_LIST_SIZE); }); }, fetchEmojis(search) { @@ -554,14 +565,10 @@ export default { }, async handleBulkUpdateClick() { if (!this.hasInitBulkEdit) { - const bulkUpdateSidebar = await import('~/issuable/bulk_update_sidebar'); + const bulkUpdateSidebar = await import('~/issuable'); bulkUpdateSidebar.initBulkUpdateSidebar('issuable_'); - bulkUpdateSidebar.initStatusDropdown(); - bulkUpdateSidebar.initSubscriptionsDropdown(); - bulkUpdateSidebar.initMoveIssuesButton(); - const usersSelect = await import('~/users_select'); - const UsersSelect = usersSelect.default; + const UsersSelect = (await import('~/users_select')).default; new UsersSelect(); // eslint-disable-line no-new this.hasInitBulkEdit = true; @@ -570,19 +577,20 @@ export default { eventHub.$emit('issuables:enableBulkEdit'); }, handleClickTab(state) { - if (this.state !== state) { - this.pageParams = getInitialPageParams(this.pageSize); + if (this.state === state) { + return; } + this.state = state; + this.pageParams = getInitialPageParams(this.pageSize); this.$router.push({ query: this.urlParams }); }, handleDismissAlert() { this.issuesError = null; }, - handleFilter(filter) { - this.setFilterTokens(filter); - + handleFilter(tokens) { + this.setFilterTokens(tokens); this.pageParams = getInitialPageParams(this.pageSize); this.$router.push({ query: this.urlParams }); @@ -642,15 +650,17 @@ export default { }); }, handleSort(sortKey) { + if (this.sortKey === sortKey) { + return; + } + if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { this.showIssueRepositioningMessage(); return; } - if (this.sortKey !== sortKey) { - this.pageParams = getInitialPageParams(this.pageSize); - } this.sortKey = sortKey; + this.pageParams = getInitialPageParams(this.pageSize); if (this.isSignedIn) { this.saveSortPreference(sortKey); @@ -673,49 +683,36 @@ export default { Sentry.captureException(error); }); }, - setFilterTokens(filtersArg) { - const filters = this.removeDisabledSearchTerms(filtersArg); + setFilterTokens(tokens) { + this.filterTokens = this.removeDisabledSearchTerms(tokens); - this.filterTokens = filters; - - // If we filtered something out, let's show a warning message - if (filters.length < filtersArg.length) { + if (this.filterTokens.length < tokens.length) { this.showAnonymousSearchingMessage(); } }, removeDisabledSearchTerms(filters) { - // If we shouldn't disable anything, let's return the same thing - if (!this.shouldDisableSomeFilters) { - return filters; - } - - const filtersWithoutSearchTerms = filters.filter( - (token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data), - ); - - return filtersWithoutSearchTerms; + return this.shouldDisableTextSearch + ? filters.filter((token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data)) + : filters; }, showAnonymousSearchingMessage() { - createFlash({ + createAlert({ message: this.$options.i18n.anonymousSearchingMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }, showIssueRepositioningMessage() { - createFlash({ + createAlert({ message: this.$options.i18n.issueRepositioningMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }, toggleBulkEditSidebar(showBulkEditSidebar) { this.showBulkEditSidebar = showBulkEditSidebar; }, handlePageSizeChange(newPageSize) { - /** make sure the page number is preserved so that the current context is not lost* */ - const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); - const pageNumberSize = lastPageSize ? 'lastPageSize' : 'firstPageSize'; - /** depending upon what page or page size we are dynamically set pageParams * */ - this.pageParams[pageNumberSize] = newPageSize; + const pageParam = getParameterByName(PARAM_LAST_PAGE_SIZE) ? 'lastPageSize' : 'firstPageSize'; + this.pageParams[pageParam] = newPageSize; this.pageSize = newPageSize; scrollUp(); @@ -724,16 +721,14 @@ export default { updateData(sortValue) { const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE); const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE); - const pageAfter = getParameterByName(PARAM_PAGE_AFTER); - const pageBefore = getParameterByName(PARAM_PAGE_BEFORE); const state = getParameterByName(PARAM_STATE); const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC; const dashboardSortKey = getSortKey(sortValue); const graphQLSortKey = isSortKey(sortValue?.toUpperCase()) && sortValue.toUpperCase(); - // The initial sort is an old enum value when it is saved on the dashboard issues page. - // The initial sort is a GraphQL enum value when it is saved on the Vue issues list page. + // The initial sort is an old enum value when it is saved on the Haml dashboard issues page. + // The initial sort is a GraphQL enum value when it is saved on the Vue group/project issues page. let sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey; if (this.isIssueRepositioningDisabled && sortKey === RELATIVE_POSITION_ASC) { @@ -741,15 +736,15 @@ export default { sortKey = defaultSortKey; } - this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.setFilterTokens(getFilterTokens(window.location.search)); + this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); this.pageParams = getInitialPageParams( this.pageSize, isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined, isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined, - pageAfter, - pageBefore, + getParameterByName(PARAM_PAGE_AFTER), + getParameterByName(PARAM_PAGE_BEFORE), ); this.sortKey = sortKey; this.state = state || IssuableStates.Opened; @@ -827,9 +822,14 @@ export default { > {{ $options.i18n.editIssues }} </gl-button> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + <gl-button + v-if="showNewIssueLink && !eeIsOkrsEnabled" + :href="newIssuePath" + variant="confirm" + > {{ $options.i18n.newIssueLabel }} </gl-button> + <slot name="new-objective-button"></slot> <new-issue-dropdown v-if="showNewIssueDropdown" /> </template> @@ -842,129 +842,25 @@ export default { </template> <template #statistics="{ issuable = {} }"> - <li - v-if="issuable.mergeRequestsCount" - v-gl-tooltip - class="gl-display-none gl-sm-display-block" - :title="$options.i18n.relatedMergeRequests" - data-testid="merge-requests" - > - <gl-icon name="merge-request" /> - {{ issuable.mergeRequestsCount }} - </li> - <li - v-if="issuable.upvotes" - v-gl-tooltip - class="issuable-upvotes gl-display-none gl-sm-display-block" - :title="$options.i18n.upvotes" - data-testid="issuable-upvotes" - > - <gl-icon name="thumb-up" /> - {{ issuable.upvotes }} - </li> - <li - v-if="issuable.downvotes" - v-gl-tooltip - class="issuable-downvotes gl-display-none gl-sm-display-block" - :title="$options.i18n.downvotes" - data-testid="issuable-downvotes" - > - <gl-icon name="thumb-down" /> - {{ issuable.downvotes }} - </li> - <slot :issuable="issuable"></slot> + <issue-card-statistics :issue="issuable" /> </template> <template #empty-state> - <gl-empty-state - v-if="hasSearch" - :description="$options.i18n.noSearchResultsDescription" - :title="$options.i18n.noSearchResultsTitle" - :svg-path="emptyStateSvgPath" - > - <template #actions> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - </template> - </gl-empty-state> - - <gl-empty-state - v-else-if="isOpenTab" - :description="$options.i18n.noOpenIssuesDescription" - :title="$options.i18n.noOpenIssuesTitle" - :svg-path="emptyStateSvgPath" - > - <template #actions> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - </template> - </gl-empty-state> - - <gl-empty-state - v-else - :title="$options.i18n.noClosedIssuesTitle" - :svg-path="emptyStateSvgPath" - /> + <empty-state-with-any-issues :has-search="hasSearch" :is-open-tab="isOpenTab" /> + </template> + + <template #list-body> + <slot name="list-body"></slot> </template> </issuable-list> - <template v-else-if="isSignedIn"> - <gl-empty-state :title="$options.i18n.noIssuesSignedInTitle" :svg-path="emptyStateSvgPath"> - <template #description> - <gl-link :href="issuesHelpPagePath" target="_blank">{{ - $options.i18n.noIssuesSignedInDescription - }}</gl-link> - <p v-if="canCreateProjects"> - <strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong> - </p> - </template> - <template #actions> - <gl-button v-if="canCreateProjects" :href="newProjectPath" variant="confirm"> - {{ $options.i18n.newProjectLabel }} - </gl-button> - <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> - {{ $options.i18n.newIssueLabel }} - </gl-button> - <csv-import-export-buttons - v-if="showCsvButtons" - class="gl-w-full gl-sm-w-auto gl-sm-mr-3" - :export-csv-path="exportCsvPathWithQuery" - :issuable-count="currentTabCount" - /> - <new-issue-dropdown v-if="showNewIssueDropdown" class="gl-align-self-center" /> - </template> - </gl-empty-state> - <hr /> - <p class="gl-text-center gl-font-weight-bold gl-mb-0"> - {{ $options.i18n.jiraIntegrationTitle }} - </p> - <p class="gl-text-center gl-mb-0"> - <gl-sprintf :message="$options.i18n.jiraIntegrationMessage"> - <template #jiraDocsLink="{ content }"> - <gl-link :href="jiraIntegrationPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - <p class="gl-text-center gl-text-gray-500"> - {{ $options.i18n.jiraIntegrationSecondaryMessage }} - </p> - </template> - - <gl-empty-state + <empty-state-without-any-issues v-else - :title="$options.i18n.noIssuesSignedOutTitle" - :svg-path="emptyStateSvgPath" - :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" - :primary-button-link="signInPath" - > - <template #description> - <gl-link :href="issuesHelpPagePath" target="_blank">{{ - $options.i18n.noIssuesSignedOutDescription - }}</gl-link> - </template> - </gl-empty-state> + :current-tab-count="currentTabCount" + :export-csv-path-with-query="exportCsvPathWithQuery" + :show-csv-buttons="showCsvButtons" + :show-new-issue-dropdown="showNewIssueDropdown" + /> <issuable-by-email v-if="showIssuableByEmail" class="gl-text-center gl-pt-5 gl-pb-7" /> </div> diff --git a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue index 666e80dfd4b..e420c21a11f 100644 --- a/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue +++ b/app/assets/javascripts/issues/list/components/new_issue_dropdown.vue @@ -6,7 +6,7 @@ import { GlLoadingIcon, GlSearchBoxByType, } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility'; import { __, sprintf } from '~/locale'; import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; @@ -45,7 +45,7 @@ export default { }, update: ({ group }) => group.projects.nodes ?? [], error(error) { - createFlash({ + createAlert({ message: __('An error occurred while loading projects.'), captureError: true, error, diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 5ed9ceea856..49a953cad43 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -6,7 +6,7 @@ import { FILTER_STARTED, FILTER_UPCOMING, OPERATOR_IS, - OPERATOR_IS_NOT, + OPERATOR_NOT, OPERATOR_OR, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, @@ -22,6 +22,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, + TOKEN_TYPE_SEARCH_WITHIN, } from '~/vue_shared/components/filtered_search_bar/constants'; import { WORK_ITEM_TYPE_ENUM_INCIDENT, @@ -30,6 +31,50 @@ import { WORK_ITEM_TYPE_ENUM_TASK, } from '~/work_items/constants'; +export const ISSUE_REFERENCE = /^#\d+$/; +export const MAX_LIST_SIZE = 10; +export const PAGE_SIZE = 20; +export const PARAM_ASSIGNEE_ID = 'assignee_id'; +export const PARAM_FIRST_PAGE_SIZE = 'first_page_size'; +export const PARAM_LAST_PAGE_SIZE = 'last_page_size'; +export const PARAM_PAGE_AFTER = 'page_after'; +export const PARAM_PAGE_BEFORE = 'page_before'; +export const PARAM_SORT = 'sort'; +export const PARAM_STATE = 'state'; +export const RELATIVE_POSITION = 'relative_position'; + +export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; +export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; +export const CLOSED_AT_ASC = 'CLOSED_AT_ASC'; +export const CLOSED_AT_DESC = 'CLOSED_AT_DESC'; +export const CREATED_ASC = 'CREATED_ASC'; +export const CREATED_DESC = 'CREATED_DESC'; +export const DUE_DATE_ASC = 'DUE_DATE_ASC'; +export const DUE_DATE_DESC = 'DUE_DATE_DESC'; +export const HEALTH_STATUS_ASC = 'HEALTH_STATUS_ASC'; +export const HEALTH_STATUS_DESC = 'HEALTH_STATUS_DESC'; +export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC'; +export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC'; +export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC'; +export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC'; +export const POPULARITY_ASC = 'POPULARITY_ASC'; +export const POPULARITY_DESC = 'POPULARITY_DESC'; +export const PRIORITY_ASC = 'PRIORITY_ASC'; +export const PRIORITY_DESC = 'PRIORITY_DESC'; +export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC'; +export const TITLE_ASC = 'TITLE_ASC'; +export const TITLE_DESC = 'TITLE_DESC'; +export const UPDATED_ASC = 'UPDATED_ASC'; +export const UPDATED_DESC = 'UPDATED_DESC'; +export const WEIGHT_ASC = 'WEIGHT_ASC'; +export const WEIGHT_DESC = 'WEIGHT_DESC'; + +export const API_PARAM = 'apiParam'; +export const URL_PARAM = 'urlParam'; +export const NORMAL_FILTER = 'normalFilter'; +export const SPECIAL_FILTER = 'specialFilter'; +export const ALTERNATIVE_FILTER = 'alternativeFilter'; + export const i18n = { anonymousSearchingMessage: __('You must sign in to search for specific terms.'), calendarLabel: __('Subscribe to calendar'), @@ -57,11 +102,9 @@ export const i18n = { ), noOpenIssuesDescription: __('To keep this project going, create a new issue'), noOpenIssuesTitle: __('There are no open issues'), - noIssuesSignedInDescription: __('Learn more about issues.'), - noIssuesSignedInTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), + noIssuesDescription: __('Learn more about issues.'), + noIssuesTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noIssuesSignedOutButtonText: __('Register / Sign In'), - noIssuesSignedOutDescription: __('Learn more about issues.'), - noIssuesSignedOutTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noSearchResultsDescription: __('To widen your search, change or remove filters above'), noSearchResultsTitle: __('Sorry, your filter produced no results'), relatedMergeRequests: __('Related merge requests'), @@ -69,45 +112,10 @@ export const i18n = { rssLabel: __('Subscribe to RSS feed'), searchPlaceholder: __('Search or filter results...'), upvotes: __('Upvotes'), + titles: __('Titles'), + descriptions: __('Descriptions'), }; -export const ISSUE_REFERENCE = /^#\d+$/; -export const MAX_LIST_SIZE = 10; -export const PAGE_SIZE = 20; -export const PAGE_SIZE_MANUAL = 100; -export const PARAM_ASSIGNEE_ID = 'assignee_id'; -export const PARAM_FIRST_PAGE_SIZE = 'first_page_size'; -export const PARAM_LAST_PAGE_SIZE = 'last_page_size'; -export const PARAM_PAGE_AFTER = 'page_after'; -export const PARAM_PAGE_BEFORE = 'page_before'; -export const PARAM_SORT = 'sort'; -export const PARAM_STATE = 'state'; -export const RELATIVE_POSITION = 'relative_position'; - -export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC'; -export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC'; -export const CREATED_ASC = 'CREATED_ASC'; -export const CREATED_DESC = 'CREATED_DESC'; -export const DUE_DATE_ASC = 'DUE_DATE_ASC'; -export const DUE_DATE_DESC = 'DUE_DATE_DESC'; -export const LABEL_PRIORITY_ASC = 'LABEL_PRIORITY_ASC'; -export const LABEL_PRIORITY_DESC = 'LABEL_PRIORITY_DESC'; -export const MILESTONE_DUE_ASC = 'MILESTONE_DUE_ASC'; -export const MILESTONE_DUE_DESC = 'MILESTONE_DUE_DESC'; -export const POPULARITY_ASC = 'POPULARITY_ASC'; -export const POPULARITY_DESC = 'POPULARITY_DESC'; -export const PRIORITY_ASC = 'PRIORITY_ASC'; -export const PRIORITY_DESC = 'PRIORITY_DESC'; -export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC'; -export const TITLE_ASC = 'TITLE_ASC'; -export const TITLE_DESC = 'TITLE_DESC'; -export const UPDATED_ASC = 'UPDATED_ASC'; -export const UPDATED_DESC = 'UPDATED_DESC'; -export const WEIGHT_ASC = 'WEIGHT_ASC'; -export const WEIGHT_DESC = 'WEIGHT_DESC'; -export const CLOSED_ASC = 'CLOSED_AT_ASC'; -export const CLOSED_DESC = 'CLOSED_AT_DESC'; - export const urlSortParams = { [PRIORITY_ASC]: 'priority', [PRIORITY_DESC]: 'priority_desc', @@ -115,8 +123,8 @@ export const urlSortParams = { [CREATED_DESC]: 'created_date', [UPDATED_ASC]: 'updated_asc', [UPDATED_DESC]: 'updated_desc', - [CLOSED_ASC]: 'closed_asc', - [CLOSED_DESC]: 'closed_desc', + [CLOSED_AT_ASC]: 'closed_at', + [CLOSED_AT_DESC]: 'closed_at_desc', [MILESTONE_DUE_ASC]: 'milestone', [MILESTONE_DUE_DESC]: 'milestone_due_desc', [DUE_DATE_ASC]: 'due_date', @@ -126,20 +134,16 @@ export const urlSortParams = { [LABEL_PRIORITY_ASC]: 'label_priority', [LABEL_PRIORITY_DESC]: 'label_priority_desc', [RELATIVE_POSITION_ASC]: RELATIVE_POSITION, + [TITLE_ASC]: 'title_asc', + [TITLE_DESC]: 'title_desc', + [HEALTH_STATUS_ASC]: 'health_status_asc', + [HEALTH_STATUS_DESC]: 'health_status_desc', [WEIGHT_ASC]: 'weight', [WEIGHT_DESC]: 'weight_desc', [BLOCKING_ISSUES_ASC]: 'blocking_issues_asc', [BLOCKING_ISSUES_DESC]: 'blocking_issues_desc', - [TITLE_ASC]: 'title_asc', - [TITLE_DESC]: 'title_desc', }; -export const API_PARAM = 'apiParam'; -export const URL_PARAM = 'urlParam'; -export const NORMAL_FILTER = 'normalFilter'; -export const SPECIAL_FILTER = 'specialFilter'; -export const ALTERNATIVE_FILTER = 'alternativeFilter'; - export const specialFilterValues = [ FILTER_NONE, FILTER_ANY, @@ -148,7 +152,17 @@ export const specialFilterValues = [ FILTER_STARTED, ]; -export const TYPE_TOKEN_TASK_OPTION = { icon: 'issue-type-task', title: 'task', value: 'task' }; +export const TYPE_TOKEN_OBJECTIVE_OPTION = { + icon: 'issue-type-objective', + title: 'objective', + value: 'objective', +}; + +export const TYPE_TOKEN_KEY_RESULT_OPTION = { + icon: 'issue-type-key-result', + title: 'key_result', + value: 'key_result', +}; // This should be consistent with Issue::TYPES_FOR_LIST in the backend // https://gitlab.com/gitlab-org/gitlab/-/blob/1379c2d7bffe2a8d809f23ac5ef9b4114f789c07/app/models/issue.rb#L48 @@ -163,20 +177,35 @@ export const defaultTypeTokenOptions = [ { icon: 'issue-type-issue', title: 'issue', value: 'issue' }, { icon: 'issue-type-incident', title: 'incident', value: 'incident' }, { icon: 'issue-type-test-case', title: 'test_case', value: 'test_case' }, + { icon: 'issue-type-task', title: 'task', value: 'task' }, ]; export const filters = { [TOKEN_TYPE_AUTHOR]: { [API_PARAM]: { [NORMAL_FILTER]: 'authorUsername', + [ALTERNATIVE_FILTER]: 'authorUsernames', }, [URL_PARAM]: { [OPERATOR_IS]: { [NORMAL_FILTER]: 'author_username', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[author_username]', }, + [OPERATOR_OR]: { + [ALTERNATIVE_FILTER]: 'or[author_username]', + }, + }, + }, + [TOKEN_TYPE_SEARCH_WITHIN]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'in', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'in', + }, }, }, [TOKEN_TYPE_ASSIGNEE]: { @@ -190,7 +219,7 @@ export const filters = { [SPECIAL_FILTER]: 'assignee_id', [ALTERNATIVE_FILTER]: 'assignee_username', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[assignee_username][]', }, [OPERATOR_OR]: { @@ -208,7 +237,7 @@ export const filters = { [NORMAL_FILTER]: 'milestone_title', [SPECIAL_FILTER]: 'milestone_title', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[milestone_title]', [SPECIAL_FILTER]: 'not[milestone_title]', }, @@ -225,7 +254,7 @@ export const filters = { [SPECIAL_FILTER]: 'label_name[]', [ALTERNATIVE_FILTER]: 'label_name', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[label_name][]', }, }, @@ -238,7 +267,7 @@ export const filters = { [OPERATOR_IS]: { [NORMAL_FILTER]: 'type[]', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[type][]', }, }, @@ -253,7 +282,7 @@ export const filters = { [NORMAL_FILTER]: 'release_tag', [SPECIAL_FILTER]: 'release_tag', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[release_tag]', }, }, @@ -268,7 +297,7 @@ export const filters = { [NORMAL_FILTER]: 'my_reaction_emoji', [SPECIAL_FILTER]: 'my_reaction_emoji', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[my_reaction_emoji]', }, }, @@ -293,7 +322,7 @@ export const filters = { [NORMAL_FILTER]: 'iteration_id', [SPECIAL_FILTER]: 'iteration_id', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[iteration_id]', [SPECIAL_FILTER]: 'not[iteration_id]', }, @@ -309,7 +338,7 @@ export const filters = { [NORMAL_FILTER]: 'epic_id', [SPECIAL_FILTER]: 'epic_id', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[epic_id]', }, }, @@ -324,7 +353,7 @@ export const filters = { [NORMAL_FILTER]: 'weight', [SPECIAL_FILTER]: 'weight', }, - [OPERATOR_IS_NOT]: { + [OPERATOR_NOT]: { [NORMAL_FILTER]: 'not[weight]', }, }, diff --git a/app/assets/javascripts/issues/list/index.js b/app/assets/javascripts/issues/list/index.js index 5e04dd1971c..7b68b7432c9 100644 --- a/app/assets/javascripts/issues/list/index.js +++ b/app/assets/javascripts/issues/list/index.js @@ -2,16 +2,15 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import IssuesListApp from 'ee_else_ce/issues/list/components/issues_list_app.vue'; -import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; -import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue'; +import JiraIssuesImportStatusApp from './components/jira_issues_import_status_app.vue'; import { gqlClient } from './graphql'; export function mountJiraIssuesListApp() { - const el = document.querySelector('.js-jira-issues-import-status'); + const el = document.querySelector('.js-jira-issues-import-status-root'); if (!el) { - return false; + return null; } const { issuesPath, projectPath } = el.dataset; @@ -19,21 +18,19 @@ export function mountJiraIssuesListApp() { const isJiraConfigured = parseBoolean(el.dataset.isJiraConfigured); if (!isJiraConfigured || !canEdit) { - return false; + return null; } Vue.use(VueApollo); - const defaultClient = createDefaultClient(); - const apolloProvider = new VueApollo({ - defaultClient, - }); return new Vue({ el, name: 'JiraIssuesImportStatusRoot', - apolloProvider, + apolloProvider: new VueApollo({ + defaultClient: gqlClient, + }), render(createComponent) { - return createComponent(JiraIssuesImportStatusRoot, { + return createComponent(JiraIssuesImportStatusApp, { props: { canEdit, isJiraConfigured, @@ -46,10 +43,10 @@ export function mountJiraIssuesListApp() { } export function mountIssuesListApp() { - const el = document.querySelector('.js-issues-list'); + const el = document.querySelector('.js-issues-list-root'); if (!el) { - return false; + return null; } Vue.use(VueApollo); @@ -77,6 +74,7 @@ export function mountIssuesListApp() { hasIssueWeightsFeature, hasIterationsFeature, hasScopedLabelsFeature, + hasOkrsFeature, importCsvIssuesPath, initialEmail, initialSort, @@ -127,6 +125,7 @@ export function mountIssuesListApp() { hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), hasIterationsFeature: parseBoolean(hasIterationsFeature), hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature), + hasOkrsFeature: parseBoolean(hasOkrsFeature), initialSort, isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled), isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled), diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index b447289b425..ee97fb6edca 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -10,6 +10,7 @@ query getIssues( $search: String $sort: IssueSort $state: IssuableState + $in: [IssuableSearchableField!] $assigneeId: String $assigneeUsernames: [String!] $authorUsername: String @@ -38,6 +39,7 @@ query getIssues( search: $search sort: $sort state: $state + in: $in assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername @@ -72,6 +74,7 @@ query getIssues( search: $search sort: $sort state: $state + in: $in assigneeId: $assigneeId assigneeUsernames: $assigneeUsernames authorUsername: $authorUsername diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index 2f9ab9d62ee..b566e08731c 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -4,9 +4,10 @@ import { getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import { FILTERED_SEARCH_TERM, - OPERATOR_IS_NOT, + OPERATOR_NOT, OPERATOR_OR, TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, @@ -14,14 +15,19 @@ import { TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; import { + ALTERNATIVE_FILTER, API_PARAM, BLOCKING_ISSUES_ASC, BLOCKING_ISSUES_DESC, + CLOSED_AT_ASC, + CLOSED_AT_DESC, CREATED_ASC, CREATED_DESC, DUE_DATE_ASC, DUE_DATE_DESC, filters, + HEALTH_STATUS_ASC, + HEALTH_STATUS_DESC, LABEL_PRIORITY_ASC, LABEL_PRIORITY_DESC, MILESTONE_DUE_ASC, @@ -44,8 +50,6 @@ import { urlSortParams, WEIGHT_ASC, WEIGHT_DESC, - CLOSED_ASC, - CLOSED_DESC, } from './constants'; export const getInitialPageParams = ( @@ -66,7 +70,11 @@ export const getSortKey = (sort) => export const isSortKey = (sort) => Object.keys(urlSortParams).includes(sort); -export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) => { +export const getSortOptions = ({ + hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature, + hasIssueWeightsFeature, +}) => { const sortOptions = [ { id: 1, @@ -96,8 +104,8 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) id: 4, title: __('Closed date'), sortDirection: { - ascending: CLOSED_ASC, - descending: CLOSED_DESC, + ascending: CLOSED_AT_ASC, + descending: CLOSED_AT_DESC, }, }, { @@ -150,6 +158,17 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature) }, ]; + if (hasIssuableHealthStatusFeature) { + sortOptions.push({ + id: sortOptions.length + 1, + title: __('Health'), + sortDirection: { + ascending: HEALTH_STATUS_ASC, + descending: HEALTH_STATUS_DESC, + }, + }); + } + if (hasIssueWeightsFeature) { sortOptions.push({ id: sortOptions.length + 1, @@ -223,13 +242,24 @@ export const getFilterTokens = (locationSearch) => { return tokens.length ? tokens : [createTerm()]; }; -const getFilterType = (data, tokenType = '') => { +const isSpecialFilter = (type, data) => { const isAssigneeIdParam = - tokenType === TOKEN_TYPE_ASSIGNEE && + type === TOKEN_TYPE_ASSIGNEE && isPositiveInteger(data) && getParameterByName(PARAM_ASSIGNEE_ID) === data; + return specialFilterValues.includes(data) || isAssigneeIdParam; +}; + +const getFilterType = ({ type, value: { data, operator } }) => { + const isUnionedAuthor = type === TOKEN_TYPE_AUTHOR && operator === OPERATOR_OR; - return specialFilterValues.includes(data) || isAssigneeIdParam ? SPECIAL_FILTER : NORMAL_FILTER; + if (isUnionedAuthor) { + return ALTERNATIVE_FILTER; + } + if (isSpecialFilter(type, data)) { + return SPECIAL_FILTER; + } + return NORMAL_FILTER; }; const wildcardTokens = [TOKEN_TYPE_ITERATION, TOKEN_TYPE_MILESTONE, TOKEN_TYPE_RELEASE]; @@ -258,10 +288,10 @@ export const convertToApiParams = (filterTokens) => { filterTokens .filter((token) => token.type !== FILTERED_SEARCH_TERM) .forEach((token) => { - const filterType = getFilterType(token.value.data, token.type); - const field = filters[token.type][API_PARAM][filterType]; + const filterType = getFilterType(token); + const apiField = filters[token.type][API_PARAM][filterType]; let obj; - if (token.value.operator === OPERATOR_IS_NOT) { + if (token.value.operator === OPERATOR_NOT) { obj = not; } else if (token.value.operator === OPERATOR_OR) { obj = or; @@ -270,7 +300,7 @@ export const convertToApiParams = (filterTokens) => { } const data = formatData(token); Object.assign(obj, { - [field]: obj[field] ? [obj[field], data].flat() : data, + [apiField]: obj[apiField] ? [obj[apiField], data].flat() : data, }); }); @@ -289,10 +319,10 @@ export const convertToUrlParams = (filterTokens) => filterTokens .filter((token) => token.type !== FILTERED_SEARCH_TERM) .reduce((acc, token) => { - const filterType = getFilterType(token.value.data, token.type); - const param = filters[token.type][URL_PARAM][token.value.operator]?.[filterType]; + const filterType = getFilterType(token); + const urlParam = filters[token.type][URL_PARAM][token.value.operator]?.[filterType]; return Object.assign(acc, { - [param]: acc[param] ? [acc[param], token.value.data].flat() : token.value.data, + [urlParam]: acc[urlParam] ? [acc[urlParam], token.value.data].flat() : token.value.data, }); }, {}); @@ -300,4 +330,4 @@ export const convertToSearchQuery = (filterTokens) => filterTokens .filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data) .map((token) => token.value.data) - .join(' '); + .join(' ') || undefined; diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js index bc1cffef943..1bb53dfd50d 100644 --- a/app/assets/javascripts/issues/manual_ordering.js +++ b/app/assets/javascripts/issues/manual_ordering.js @@ -1,5 +1,5 @@ import Sortable from 'sortablejs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; import { getSortableDefaultOptions, sortableStart } from '~/sortable/utils'; @@ -11,7 +11,7 @@ const updateIssue = (url, { move_before_id, move_after_id }) => move_after_id, }) .catch(() => { - createFlash({ + createAlert({ message: s__("ManualOrdering|Couldn't save the order of the issues"), }); }); diff --git a/app/assets/javascripts/issues/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js index 94abb50de89..4c81f1d9bc1 100644 --- a/app/assets/javascripts/issues/related_merge_requests/store/actions.js +++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js @@ -1,4 +1,4 @@ -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -29,7 +29,7 @@ export const fetchMergeRequests = ({ state, dispatch }) => { }) .catch(() => { dispatch('receiveDataError'); - createFlash({ + createAlert({ message: __('Something went wrong while fetching related merge requests.'), }); }); diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index 0daf77e03dc..e5428f87095 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -1,7 +1,7 @@ <script> import { GlIcon, GlBadge, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableStatus, IssuableStatusText, @@ -327,7 +327,7 @@ export default { this.store.updateState(data); }) .catch(() => { - createFlash({ + createAlert({ message: this.defaultErrorMessage, }); }); @@ -362,7 +362,7 @@ export default { this.updateAndShowForm(res.data); }) .catch(() => { - createFlash({ + createAlert({ message: this.defaultErrorMessage, }); this.updateAndShowForm(); @@ -429,7 +429,7 @@ export default { errMsg += `. ${message}`; } - this.flashContainer = createFlash({ + this.flashContainer = createAlert({ message: errMsg, }); }) diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index 5c2a154362f..78e729b97da 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -1,11 +1,12 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui'; +import { GlToast, GlTooltip, GlModalDirective } from '@gitlab/ui'; import $ from 'jquery'; import Sortable from 'sortablejs'; import Vue from 'vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { isMetaKey } from '~/lib/utils/common_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; @@ -27,6 +28,7 @@ import { TASK_TYPE_NAME, WIDGET_TYPE_DESCRIPTION, } from '~/work_items/constants'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import animateMixin from '../mixins/animate'; import { convertDescriptionWithNewSort } from '../utils'; @@ -165,7 +167,7 @@ export default { this.renderGFM(); this.updateTaskStatusText(); - if (this.workItemId) { + if (this.workItemId && this.workItemsEnabled) { const taskLink = this.$el.querySelector( `.gfm-issue[data-issue="${getIdFromGraphQLId(this.workItemId)}"]`, ); @@ -177,7 +179,7 @@ export default { }, methods: { renderGFM() { - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); if (this.canUpdate) { // eslint-disable-next-line no-new @@ -283,7 +285,7 @@ export default { }, taskListUpdateError() { - createFlash({ + createAlert({ message: sprintf( __( 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.', @@ -467,7 +469,7 @@ export default { this.workItemId = newWorkItem.id; this.openWorkItemDetailModal(el); } catch (error) { - createFlash({ + createAlert({ message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK), error, captureError: true, diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 180dea77003..04c5007dbec 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -67,6 +67,7 @@ export default { :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" supports-quick-actions + use-bottom-toolbar autofocus @input="$emit('input', $event)" @keydown.meta.enter="updateIssuable" diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index 0c6b61fb893..b56c91d7983 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -164,7 +164,7 @@ export default { <template> <form data-testid="issuable-form"> - <locked-warning v-if="showLockedWarning" /> + <locked-warning v-if="showLockedWarning" :issuable-type="issuableType" /> <gl-alert v-if="showOutdatedDescriptionWarning" class="gl-mb-5" diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index c01de63ced9..983e2e6530e 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -10,7 +10,7 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import { IssuableStatus, IssueType } from '~/issues/constants'; import { ISSUE_STATE_EVENT_CLOSE, ISSUE_STATE_EVENT_REOPEN } from '~/issues/show/constants'; @@ -40,6 +40,7 @@ export default { promoteSuccessMessage: __( 'The issue was successfully promoted to an epic. Redirecting to epic...', ), + reportAbuse: __('Report abuse to administrator'), }, components: { DeleteIssueModal, @@ -191,7 +192,7 @@ export default { // Dispatch event which updates open/close state, shared among the issue show page document.dispatchEvent(new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, payload)); }) - .catch(() => createFlash({ message: __('Error occurred while updating the issue status') })) + .catch(() => createAlert({ message: __('Error occurred while updating the issue status') })) .finally(() => { this.toggleStateButtonLoading(false); }); @@ -214,14 +215,14 @@ export default { throw new Error(); } - createFlash({ + createAlert({ message: this.$options.i18n.promoteSuccessMessage, - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); visitUrl(data.promoteToEpic.epic.webPath); }) - .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage })) + .catch(() => createAlert({ message: this.$options.i18n.promoteErrorMessage })) .finally(() => { this.toggleStateButtonLoading(false); }); @@ -255,7 +256,7 @@ export default { {{ __('Promote to epic') }} </gl-dropdown-item> <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> - {{ __('Report abuse') }} + {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item v-if="canReportSpam" @@ -314,7 +315,7 @@ export default { {{ __('Promote to epic') }} </gl-dropdown-item> <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> - {{ __('Report abuse') }} + {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item v-if="canReportSpam" diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index db846009409..22db19610c1 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -14,6 +14,7 @@ export const timelineFormI18n = Object.freeze({ areaPlaceholder: s__('Incident|Timeline text...'), save: __('Save'), cancel: __('Cancel'), + delete: __('Delete'), description: __('Description'), hint: __('You can enter up to 280 characters'), textRemaining: (count) => n__('%d character remaining', '%d characters remaining', count), diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue index 60fa8cb949b..8cdd62ca9ef 100644 --- a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue @@ -40,8 +40,10 @@ export default { :is-event-processed="editTimelineEventActive" :previous-occurred-at="event.occurredAt" :previous-note="event.note" + show-delete @save-event="saveEvent" @cancel="$emit('hide-edit')" + @delete="$emit('delete')" /> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql index f1fc27dcb2a..4a8786b04b1 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql @@ -7,6 +7,12 @@ mutation CreateTimelineEvent($input: TimelineEventCreateInput!) { action occurredAt createdAt + timelineEventTags { + nodes { + id + name + } + } } errors } diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql index d88633f2ae9..e057267b006 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql @@ -4,6 +4,7 @@ query getAlert($iid: String!, $fullPath: ID!) { issue(iid: $iid) { id alertManagementAlert { + id iid title detailsUrl diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql index bc4e8414bfc..baeb81745ab 100644 --- a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql @@ -9,6 +9,12 @@ query GetTimelineEvents($fullPath: ID!, $incidentId: IssueID!) { action occurredAt createdAt + timelineEventTags { + nodes { + id + name + } + } } } } diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue index 5725d0f8d6a..53956fcb4b2 100644 --- a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -1,16 +1,29 @@ <script> import { GlTab, GlTabs } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DescriptionComponent from '../description.vue'; import getAlert from './graphql/queries/get_alert.graphql'; import HighlightBar from './highlight_bar.vue'; import TimelineTab from './timeline_events_tab.vue'; +export const incidentTabsI18n = Object.freeze({ + summaryTitle: s__('Incident|Summary'), + metricsTitle: s__('Incident|Metrics'), + alertsTitle: s__('Incident|Alert details'), + timelineTitle: s__('Incident|Timeline'), +}); + +export const TAB_NAMES = Object.freeze({ + SUMMARY: '', + ALERTS: 'alerts', + METRICS: 'metrics', + TIMELINE: 'timeline', +}); + export default { components: { AlertDetailsTable, @@ -22,8 +35,8 @@ export default { IncidentMetricTab: () => import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'), }, - mixins: [glFeatureFlagsMixin()], - inject: ['fullPath', 'iid'], + inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'], + i18n: incidentTabsI18n, apollo: { alert: { query: getAlert, @@ -37,7 +50,7 @@ export default { return data?.project?.issue?.alertManagementAlert; }, error() { - createFlash({ + createAlert({ message: s__('Incident|There was an issue loading alert data. Please try again.'), }); }, @@ -46,12 +59,44 @@ export default { data() { return { alert: null, + activeTabIndex: 0, }; }, computed: { loading() { return this.$apollo.queries.alert.loading; }, + tabMapping() { + const availableTabs = [TAB_NAMES.SUMMARY]; + + if (this.uploadMetricsFeatureAvailable) { + availableTabs.push(TAB_NAMES.METRICS); + } + if (this.alert) { + availableTabs.push(TAB_NAMES.ALERTS); + } + + availableTabs.push(TAB_NAMES.TIMELINE); + + const tabNamesToIndex = {}; + const tabIndexToName = {}; + + availableTabs.forEach((item, index) => { + tabNamesToIndex[item] = index; + tabIndexToName[index] = item; + }); + + return { tabNamesToIndex, tabIndexToName }; + }, + currentTabIndex: { + get() { + return this.activeTabIndex; + }, + set(index) { + this.handleTabChange(index); + this.activeTabIndex = index; + }, + }, }, mounted() { this.trackPageViews(); @@ -91,25 +136,33 @@ export default { <template> <div> <gl-tabs + v-model="currentTabIndex" content-class="gl-reset-line-height" class="gl-mt-n3" data-testid="incident-tabs" - @input="handleTabChange" > - <gl-tab :title="s__('Incident|Summary')"> + <gl-tab :title="$options.i18n.summaryTitle" data-testid="summary-tab"> <highlight-bar :alert="alert" /> <description-component v-bind="$attrs" v-on="$listeners" /> </gl-tab> - <incident-metric-tab /> + <gl-tab + v-if="uploadMetricsFeatureAvailable" + :title="$options.i18n.metricsTitle" + data-testid="metrics-tab" + > + <incident-metric-tab /> + </gl-tab> <gl-tab v-if="alert" class="alert-management-details" - :title="s__('Incident|Alert details')" + :title="$options.i18n.alertsTitle" data-testid="alert-details-tab" > <alert-details-table :alert="alert" :loading="loading" /> </gl-tab> - <timeline-tab /> + <gl-tab :title="$options.i18n.timelineTitle" data-testid="timeline-tab"> + <timeline-tab /> + </gl-tab> </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 72dfccca467..f1a3aebc990 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,7 +1,6 @@ <script> import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { MAX_TEXT_LENGTH, timelineFormI18n } from './constants'; import { getUtcShiftedDate } from './utils'; @@ -27,15 +26,17 @@ export default { }, i18n: timelineFormI18n, MAX_TEXT_LENGTH, - directives: { - autofocusonshow, - }, props: { showSaveAndAdd: { type: Boolean, required: false, default: false, }, + showDelete: { + type: Boolean, + required: false, + default: false, + }, isEventProcessed: { type: Boolean, required: true, @@ -97,7 +98,7 @@ export default { this.timelineText = ''; }, focusDate() { - this.$refs.datepicker.$el.querySelector('input').focus(); + this.$refs.datepicker.$el.querySelector('input')?.focus(); }, handleSave(addAnotherEvent) { const event = { @@ -185,32 +186,42 @@ export default { </gl-form-group> </div> <gl-form-group class="gl-mb-0"> - <gl-button - variant="confirm" - category="primary" - class="gl-mr-3" - data-testid="save-button" - :disabled="!isTimelineTextValid" - :loading="isEventProcessed" - @click="handleSave(false)" - > - {{ $options.i18n.save }} - </gl-button> - <gl-button - v-if="showSaveAndAdd" - variant="confirm" - category="secondary" - class="gl-mr-3 gl-ml-n2" - data-testid="save-and-add-button" - :disabled="!isTimelineTextValid" - :loading="isEventProcessed" - @click="handleSave(true)" - > - {{ $options.i18n.saveAndAdd }} - </gl-button> - <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> - {{ $options.i18n.cancel }} - </gl-button> + <div class="gl-display-flex"> + <gl-button + variant="confirm" + category="primary" + class="gl-mr-3" + data-testid="save-button" + :disabled="!isTimelineTextValid" + :loading="isEventProcessed" + @click="handleSave(false)" + > + {{ $options.i18n.save }} + </gl-button> + <gl-button + v-if="showSaveAndAdd" + variant="confirm" + category="secondary" + class="gl-mr-3 gl-ml-n2" + data-testid="save-and-add-button" + :disabled="!isTimelineTextValid" + :loading="isEventProcessed" + @click="handleSave(true)" + > + {{ $options.i18n.saveAndAdd }} + </gl-button> + <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> + {{ $options.i18n.cancel }} + </gl-button> + <gl-button + v-if="showDelete" + class="gl-ml-auto btn-danger" + :disabled="isEventProcessed" + @click="$emit('delete')" + > + {{ $options.i18n.delete }} + </gl-button> + </div> <div class="timeline-event-bottom-border"></div> </gl-form-group> </form> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index cbf3c387fa3..90ee4351e39 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -1,5 +1,6 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf, GlBadge } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { formatDate } from '~/lib/utils/datetime_utility'; import { timelineItemI18n } from './constants'; import { getEventIcon } from './utils'; @@ -12,9 +13,10 @@ export default { GlDropdownItem, GlIcon, GlSprintf, + GlBadge, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, inject: ['canUpdateTimelineEvent'], props: { @@ -30,6 +32,11 @@ export default { type: String, required: true, }, + eventTag: { + type: String, + required: false, + default: null, + }, }, computed: { time() { @@ -42,41 +49,41 @@ export default { }; </script> <template> - <div class="gl-display-flex gl-align-items-start"> + <div class="timeline-event gl-display-grid"> <div class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" > <gl-icon :name="getEventIcon(action)" class="note-icon" /> </div> - <div - class="timeline-event-note timeline-event-border gl-w-full gl-display-flex gl-flex-direction-row" - data-testid="event-text-container" - > - <div> + <div class="timeline-event-note timeline-event-border" data-testid="event-text-container"> + <div class="gl-display-flex gl-align-items-center gl-mb-3"> <strong class="gl-font-lg" data-testid="event-time"> <gl-sprintf :message="$options.i18n.timeUTC"> <template #time>{{ time }}</template> </gl-sprintf> </strong> - <div v-safe-html="noteHtml"></div> + <gl-badge v-if="eventTag" variant="muted" icon="tag" class="gl-ml-3"> + {{ eventTag }} + </gl-badge> </div> - <gl-dropdown - v-if="canUpdateTimelineEvent" - right - class="event-note-actions gl-ml-auto gl-align-self-start" - icon="ellipsis_v" - text-sr-only - :text="$options.i18n.moreActions" - category="tertiary" - no-caret - > - <gl-dropdown-item @click="$emit('edit')"> - {{ $options.i18n.edit }} - </gl-dropdown-item> - <gl-dropdown-item @click="$emit('delete')"> - {{ $options.i18n.delete }} - </gl-dropdown-item> - </gl-dropdown> + <div v-safe-html="noteHtml" class="md"></div> </div> + <gl-dropdown + v-if="canUpdateTimelineEvent" + right + class="event-note-actions gl-ml-auto gl-align-self-start" + icon="ellipsis_v" + text-sr-only + :text="$options.i18n.moreActions" + category="tertiary" + no-caret + > + <gl-dropdown-item @click="$emit('edit')"> + {{ $options.i18n.edit }} + </gl-dropdown-item> + <gl-dropdown-item @click="$emit('delete')"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index 321b7ccc14a..c6b93201c97 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -50,6 +50,9 @@ export default { }, }, methods: { + getFirstTag(eventTag) { + return eventTag.nodes?.[0]?.name; + }, handleEditSelection(event) { this.eventToEdit = event.id; this.$emit('hide-new-incident-timeline-event-form'); @@ -153,6 +156,7 @@ export default { :edit-timeline-event-active="editTimelineEventActive" @handle-save-edit="handleSaveEdit" @hide-edit="hideEdit()" + @delete="handleDelete(event)" /> <incident-timeline-event-item v-else @@ -160,6 +164,7 @@ export default { :action="event.action" :occurred-at="event.occurredAt" :note-html="event.noteHtml" + :event-tag="getFirstTag(event.timelineEventTags)" @delete="handleDelete(event)" @edit="handleEditSelection(event)" /> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index 5f70d9acac9..c8237766505 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; @@ -15,7 +15,6 @@ export default { GlButton, GlEmptyState, GlLoadingIcon, - GlTab, CreateTimelineEvent, IncidentTimelineEventsList, }, @@ -77,7 +76,7 @@ export default { </script> <template> - <gl-tab :title="$options.i18n.title"> + <div> <gl-loading-icon v-if="timelineEventLoading" size="lg" color="dark" class="gl-mt-5" /> <gl-empty-state v-else-if="showEmptyState" @@ -106,5 +105,5 @@ export default { > {{ $options.i18n.addEventButton }} </gl-button> - </gl-tab> + </div> </template> diff --git a/app/assets/javascripts/issues/show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue index 12feacb027b..4414e693ed0 100644 --- a/app/assets/javascripts/issues/show/components/locked_warning.vue +++ b/app/assets/javascripts/issues/show/components/locked_warning.vue @@ -1,29 +1,44 @@ <script> import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; +import { IssuableType } from '~/issues/constants'; -const alertMessage = __( - 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.', -); +export const i18n = Object.freeze({ + alertMessage: __( + "Someone edited the %{issuableType} at the same time you did. Review %{linkStart}the %{issuableType}%{linkEnd} and make sure you don't unintentionally overwrite their changes.", + ), +}); export default { - alertMessage, components: { GlSprintf, GlLink, GlAlert, }, + props: { + issuableType: { + type: String, + required: true, + validator(value) { + return Object.values(IssuableType).includes(value); + }, + }, + }, computed: { currentPath() { return window.location.pathname; }, + alertMessage() { + return sprintf(this.$options.i18n.alertMessage, { issuableType: this.issuableType }); + }, }, + i18n, }; </script> <template> <gl-alert variant="danger" class="gl-mb-5" :dismissible="false"> - <gl-sprintf :message="$options.alertMessage"> + <gl-sprintf :message="alertMessage"> <template #link="{ content }"> <gl-link :href="currentPath" target="_blank" rel="nofollow"> {{ content }} diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue index 307d9f9f69a..6978f730e1d 100644 --- a/app/assets/javascripts/issues/show/components/title.vue +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import eventHub from '../event_hub'; import animateMixin from '../mixins/animate'; diff --git a/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue index 0e2d8821f36..dac807dceb0 100644 --- a/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue +++ b/app/assets/javascripts/jira_connect/branches/components/source_branch_dropdown.vue @@ -1,5 +1,6 @@ <script> -import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; import { __ } from '~/locale'; import { BRANCHES_PER_PAGE } from '../constants'; import getProjectQuery from '../graphql/queries/get_project.query.graphql'; @@ -7,10 +8,7 @@ import getProjectQuery from '../graphql/queries/get_project.query.graphql'; export default { BRANCHES_PER_PAGE, components: { - GlDropdown, - GlDropdownItem, - GlSearchBoxByType, - GlLoadingIcon, + GlCollapsibleListbox, }, props: { selectedProject: { @@ -26,7 +24,6 @@ export default { }, data() { return { - sourceBranchSearchQuery: '', initialSourceBranchNamesLoading: false, sourceBranchNamesLoading: false, sourceBranchNames: [], @@ -59,6 +56,9 @@ export default { onSourceBranchSelect(branchName) { this.$emit('change', branchName); }, + onSearch: debounce(function debouncedSearch(branchSearchQuery) { + this.onSourceBranchSearchQuery(branchSearchQuery); + }, 250), onSourceBranchSearchQuery(branchSearchQuery) { this.branchSearchQuery = branchSearchQuery; this.fetchSourceBranchNames({ @@ -83,7 +83,10 @@ export default { }); const { branchNames, rootRef } = data?.project.repository || {}; - this.sourceBranchNames = branchNames || []; + this.sourceBranchNames = + branchNames.map((value) => { + return { text: value, value }; + }) || []; // Use root ref as the default selection if (rootRef && !this.hasSelectedSourceBranch) { @@ -102,33 +105,15 @@ export default { </script> <template> - <gl-dropdown - :text="branchDropdownText" - :loading="initialSourceBranchNamesLoading" - :disabled="!hasSelectedProject" + <gl-collapsible-listbox :class="{ 'gl-font-monospace': hasSelectedSourceBranch }" - > - <template #header> - <gl-search-box-by-type - :debounce="250" - :value="sourceBranchSearchQuery" - @input="onSourceBranchSearchQuery" - /> - </template> - - <gl-loading-icon v-show="sourceBranchNamesLoading" /> - <template v-if="!sourceBranchNamesLoading"> - <gl-dropdown-item - v-for="branchName in sourceBranchNames" - v-show="!sourceBranchNamesLoading" - :key="branchName" - :is-checked="branchName === selectedBranchName" - is-check-item - class="gl-font-monospace" - @click="onSourceBranchSelect(branchName)" - > - {{ branchName }} - </gl-dropdown-item> - </template> - </gl-dropdown> + :disabled="!hasSelectedProject" + :items="sourceBranchNames" + :loading="initialSourceBranchNamesLoading" + :searchable="true" + :searching="sourceBranchNamesLoading" + :toggle-text="branchDropdownText" + @search="onSearch" + @select="onSourceBranchSelect" + /> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index fc365746b54..01bc5dfc66b 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -38,7 +38,7 @@ export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_ anchor: 'use-the-integration', }); export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('integration/jira/connect-app', { - anchor: 'install-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances', + anchor: 'connect-the-gitlabcom-for-jira-cloud-app-for-self-managed-instances', }); export const GITLAB_COM_BASE_PATH = 'https://gitlab.com'; diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue index 5ff75e19425..7c6ff002014 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue @@ -5,10 +5,14 @@ import { s__ } from '~/locale'; import { reloadPage, persistBaseUrl, retrieveBaseUrl } from '~/jira_connect/subscriptions/utils'; import { updateInstallation, setApiBaseURL } from '~/jira_connect/subscriptions/api'; -import { I18N_UPDATE_INSTALLATION_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants'; +import { + GITLAB_COM_BASE_PATH, + I18N_UPDATE_INSTALLATION_ERROR_MESSAGE, +} from '~/jira_connect/subscriptions/constants'; import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types'; import SignInOauthButton from '../../../components/sign_in_oauth_button.vue'; +import SetupInstructions from './setup_instructions.vue'; import VersionSelectForm from './version_select_form.vue'; export default { @@ -16,12 +20,14 @@ export default { components: { GlButton, SignInOauthButton, + SetupInstructions, VersionSelectForm, }, data() { return { gitlabBasePath: null, loadingVersionSelect: false, + showSetupInstructions: false, }; }, computed: { @@ -37,6 +43,9 @@ export default { mounted() { this.gitlabBasePath = retrieveBaseUrl(); setApiBaseURL(this.gitlabBasePath); + if (this.gitlabBasePath !== GITLAB_COM_BASE_PATH) { + this.showSetupInstructions = true; + } }, methods: { ...mapMutations({ @@ -61,6 +70,9 @@ export default { this.loadingVersionSelect = false; }); }, + onSetupNext() { + this.showSetupInstructions = false; + }, onSignInError() { this.$emit('error'); }, @@ -88,19 +100,23 @@ export default { @submit="onVersionSelect" /> - <div v-else class="gl-text-center"> - <sign-in-oauth-button - class="gl-mb-5" - :gitlab-base-path="gitlabBasePath" - @sign-in="$emit('sign-in-oauth', $event)" - @error="onSignInError" - /> + <template v-else> + <setup-instructions v-if="showSetupInstructions" @next="onSetupNext" /> + + <div v-else class="gl-text-center"> + <sign-in-oauth-button + class="gl-mb-5" + :gitlab-base-path="gitlabBasePath" + @sign-in="$emit('sign-in-oauth', $event)" + @error="onSignInError" + /> - <div> - <gl-button category="tertiary" variant="confirm" @click="resetGitlabBasePath"> - {{ $options.i18n.changeVersionButtonText }} - </gl-button> + <div> + <gl-button category="tertiary" variant="confirm" @click="resetGitlabBasePath"> + {{ $options.i18n.changeVersionButtonText }} + </gl-button> + </div> </div> - </div> + </template> </div> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue new file mode 100644 index 00000000000..00fa739b518 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue @@ -0,0 +1,35 @@ +<script> +import { GlButton, GlLink } from '@gitlab/ui'; +import { OAUTH_SELF_MANAGED_DOC_LINK } from '~/jira_connect/subscriptions/constants'; + +export default { + components: { + GlButton, + GlLink, + }, + OAUTH_SELF_MANAGED_DOC_LINK, +}; +</script> + +<template> + <div class="gl-max-w-62 gl-mx-auto gl-mt-7"> + <h3>{{ s__('JiraService|Continue setup in GitLab') }}</h3> + <p> + {{ + s__( + 'JiraService|In order to complete the set up, you’ll need to complete a few steps in GitLab.', + ) + }} + <gl-link + class="gl-reset-font-size!" + :href="$options.OAUTH_SELF_MANAGED_DOC_LINK" + target="_blank" + >{{ __('Learn more') }}</gl-link + > + </p> + + <gl-button variant="confirm" @click="$emit('next')"> + {{ __('Next') }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue index 6b32225ed11..37a65946b3f 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue @@ -55,7 +55,6 @@ export default { }, radioOptions: RADIO_OPTIONS, i18n: { - title: s__('JiraService|Welcome to GitLab for Jira'), saasRadioLabel: __('GitLab.com (SaaS)'), saasRadioHelp: __('Most common'), selfManagedRadioLabel: __('GitLab (self-managed)'), diff --git a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue index e498a735898..67cdca6aa0a 100644 --- a/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue +++ b/app/assets/javascripts/jobs/components/filtered_search/jobs_filtered_search.vue @@ -1,13 +1,13 @@ <script> import { GlFilteredSearch } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + OPERATORS_IS, + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; import JobStatusToken from './tokens/job_status_token.vue'; export default { - tokenTypes: { - status: 'status', - }, components: { GlFilteredSearch, }, @@ -22,12 +22,12 @@ export default { tokens() { return [ { - type: this.$options.tokenTypes.status, + type: TOKEN_TYPE_STATUS, icon: 'status', - title: s__('Jobs|Status'), + title: TOKEN_TITLE_STATUS, unique: true, token: JobStatusToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, ]; }, @@ -35,7 +35,7 @@ export default { if (this.queryString?.statuses) { return [ { - type: 'status', + type: TOKEN_TYPE_STATUS, value: { data: this.queryString?.statuses, operator: '=', diff --git a/app/assets/javascripts/jobs/components/job/empty_state.vue b/app/assets/javascripts/jobs/components/job/empty_state.vue index 65b9600e664..d0a39025807 100644 --- a/app/assets/javascripts/jobs/components/job/empty_state.vue +++ b/app/assets/javascripts/jobs/components/job/empty_state.vue @@ -1,16 +1,12 @@ <script> import { GlLink } from '@gitlab/ui'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import LegacyManualVariablesForm from '~/jobs/components/job/legacy_manual_variables_form.vue'; import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; export default { components: { GlLink, - LegacyManualVariablesForm, ManualVariablesForm, }, - mixins: [glFeatureFlagsMixin()], props: { illustrationPath: { type: String, @@ -20,6 +16,14 @@ export default { type: String, required: true, }, + isRetryable: { + type: Boolean, + required: true, + }, + jobId: { + type: Number, + required: true, + }, title: { type: String, required: true, @@ -54,9 +58,6 @@ export default { }, }, computed: { - isGraphQL() { - return this.glFeatures?.graphqlJobApp; - }, shouldRenderManualVariables() { return this.playable && !this.scheduled; }, @@ -77,14 +78,14 @@ export default { <p v-if="content" data-testid="job-empty-state-content">{{ content }}</p> </div> - <template v-if="isGraphQL"> - <manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> - </template> - <template v-else> - <legacy-manual-variables-form v-if="shouldRenderManualVariables" :action="action" /> - </template> - <div class="text-content"> - <div v-if="action && !shouldRenderManualVariables" class="text-center"> + <manual-variables-form + v-if="shouldRenderManualVariables" + :is-retryable="isRetryable" + :job-id="jobId" + @hideManualVariablesForm="$emit('hideManualVariablesForm')" + /> + <div v-if="action && !shouldRenderManualVariables" class="text-content"> + <div class="text-center"> <gl-link :href="action.path" :data-method="action.method" diff --git a/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql new file mode 100644 index 00000000000..2b79892a072 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql @@ -0,0 +1,16 @@ +mutation retryJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { + jobRetry(input: { id: $id, variables: $variables }) { + job { + id + manualVariables { + nodes { + id + key + value + } + } + webPath + } + errors + } +} diff --git a/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql new file mode 100644 index 00000000000..aaf1dec8e0f --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/graphql/queries/get_job.query.graphql @@ -0,0 +1,17 @@ +query getJob($fullPath: ID!, $id: JobID!) { + project(fullPath: $fullPath) { + id + job(id: $id) { + id + manualJob + manualVariables { + nodes { + id + key + value + } + } + name + } + } +} diff --git a/app/assets/javascripts/jobs/components/job/job_app.vue b/app/assets/javascripts/jobs/components/job/job_app.vue index 81b65d175a7..c6d900ef13e 100644 --- a/app/assets/javascripts/jobs/components/job/job_app.vue +++ b/app/assets/javascripts/jobs/components/job/job_app.vue @@ -1,8 +1,9 @@ <script> -import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml, GlAlert } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon, GlAlert } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { throttle, isEmpty } from 'lodash'; import { mapGetters, mapState, mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { __, sprintf } from '~/locale'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; @@ -71,6 +72,7 @@ export default { data() { return { searchResults: [], + showUpdateVariablesState: false, }; }, computed: { @@ -121,6 +123,10 @@ export default { return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure; }, + isJobRetryable() { + return Boolean(this.job.retry_path); + }, + itemName() { return sprintf(__('Job %{jobName}'), { jobName: this.job.name }); }, @@ -168,10 +174,16 @@ export default { 'toggleScrollButtons', 'toggleScrollAnimation', ]), + onHideManualVariablesForm() { + this.showUpdateVariablesState = false; + }, onResize() { this.updateSidebar(); this.updateScroll(); }, + onUpdateVariables() { + this.showUpdateVariablesState = true; + }, updateSidebar() { const breakpoint = bp.getBreakpointSize(); if (breakpoint === 'xs' || breakpoint === 'sm') { @@ -271,14 +283,12 @@ export default { </div> <!-- job log --> <div - v-if="hasJobLog" + v-if="hasJobLog && !showUpdateVariablesState" class="build-log-container gl-relative" :class="{ 'gl-mt-3': !job.archived }" > <log-top-bar :class="{ - 'sidebar-expanded': isSidebarOpen, - 'sidebar-collapsed': !isSidebarOpen, 'has-archived-block': job.archived, }" :size="jobLogSize" @@ -299,14 +309,17 @@ export default { <!-- empty state --> <empty-state - v-if="!hasJobLog" + v-if="!hasJobLog || showUpdateVariablesState" :illustration-path="emptyStateIllustration.image" :illustration-size-class="emptyStateIllustration.size" + :is-retryable="isJobRetryable" + :job-id="job.id" :title="emptyStateTitle" :content="emptyStateIllustration.content" :action="emptyStateAction" :playable="job.playable" :scheduled="job.scheduled" + @hideManualVariablesForm="onHideManualVariablesForm()" /> <!-- EO empty state --> @@ -320,9 +333,9 @@ export default { 'right-sidebar-expanded': isSidebarOpen, 'right-sidebar-collapsed': !isSidebarOpen, }" - :erase-path="job.erase_path" :artifact-help-url="artifactHelpUrl" data-testid="job-sidebar" + @updateVariables="onUpdateVariables()" /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue deleted file mode 100644 index 1898e02c94e..00000000000 --- a/app/assets/javascripts/jobs/components/job/legacy_manual_variables_form.vue +++ /dev/null @@ -1,192 +0,0 @@ -<script> -import { - GlFormInputGroup, - GlInputGroupText, - GlFormInput, - GlButton, - GlLink, - GlSprintf, -} from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import { mapActions } from 'vuex'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__ } from '~/locale'; - -export default { - name: 'ManualVariablesForm', - components: { - GlFormInputGroup, - GlInputGroupText, - GlFormInput, - GlButton, - GlLink, - GlSprintf, - }, - props: { - action: { - type: Object, - required: false, - default: null, - validator(value) { - return ( - value === null || - (Object.prototype.hasOwnProperty.call(value, 'path') && - Object.prototype.hasOwnProperty.call(value, 'method') && - Object.prototype.hasOwnProperty.call(value, 'button_title')) - ); - }, - }, - }, - inputTypes: { - key: 'key', - value: 'value', - }, - i18n: { - header: s__('CiVariables|Variables'), - keyLabel: s__('CiVariables|Key'), - valueLabel: s__('CiVariables|Value'), - keyPlaceholder: s__('CiVariables|Input variable key'), - valuePlaceholder: s__('CiVariables|Input variable value'), - formHelpText: s__( - 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', - ), - }, - data() { - return { - variables: [ - { - key: '', - secretValue: '', - id: uniqueId(), - }, - ], - triggerBtnDisabled: false, - }; - }, - computed: { - variableSettings() { - return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); - }, - preparedVariables() { - // we need to ensure no empty variables are passed to the API - // and secretValue should be snake_case when passed to the API - return this.variables - .filter((variable) => variable.key !== '') - .map(({ key, secretValue }) => ({ key, secret_value: secretValue })); - }, - }, - methods: { - ...mapActions(['triggerManualJob']), - addEmptyVariable() { - const lastVar = this.variables[this.variables.length - 1]; - - if (lastVar.key === '') { - return; - } - - this.variables.push({ - key: '', - secret_value: '', - id: uniqueId(), - }); - }, - canRemove(index) { - return index < this.variables.length - 1; - }, - deleteVariable(id) { - this.variables.splice( - this.variables.findIndex((el) => el.id === id), - 1, - ); - }, - inputRef(type, id) { - return `${this.$options.inputTypes[type]}-${id}`; - }, - trigger() { - this.triggerBtnDisabled = true; - - this.triggerManualJob(this.preparedVariables); - }, - }, -}; -</script> -<template> - <div class="row gl-justify-content-center"> - <div class="col-10" data-testid="manual-vars-form"> - <label>{{ $options.i18n.header }}</label> - - <div - v-for="(variable, index) in variables" - :key="variable.id" - class="gl-display-flex gl-align-items-center gl-mb-4" - data-testid="ci-variable-row" - > - <gl-form-input-group class="gl-mr-4 gl-flex-grow-1"> - <template #prepend> - <gl-input-group-text> - {{ $options.i18n.keyLabel }} - </gl-input-group-text> - </template> - <gl-form-input - :ref="inputRef('key', variable.id)" - v-model="variable.key" - :placeholder="$options.i18n.keyPlaceholder" - data-testid="ci-variable-key" - @change="addEmptyVariable" - /> - </gl-form-input-group> - - <gl-form-input-group class="gl-flex-grow-2"> - <template #prepend> - <gl-input-group-text> - {{ $options.i18n.valueLabel }} - </gl-input-group-text> - </template> - <gl-form-input - :ref="inputRef('value', variable.id)" - v-model="variable.secretValue" - :placeholder="$options.i18n.valuePlaceholder" - data-testid="ci-variable-value" - /> - </gl-form-input-group> - - <gl-button - v-if="canRemove(index)" - class="gl-flex-grow-0 gl-flex-basis-0" - category="tertiary" - variant="danger" - icon="clear" - :aria-label="__('Delete variable')" - data-testid="delete-variable-btn" - @click="deleteVariable(variable.id)" - /> - - <!-- delete variable button placeholder to not break flex layout --> - <div v-else class="gl-w-7 gl-mr-3" data-testid="delete-variable-btn-placeholder"></div> - </div> - - <div class="gl-text-center gl-mt-5"> - <gl-sprintf :message="$options.i18n.formHelpText"> - <template #link="{ content }"> - <gl-link :href="variableSettings" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </div> - <div class="gl-display-flex gl-justify-content-center gl-mt-5"> - <gl-button - class="gl-mt-5" - variant="confirm" - category="primary" - :aria-label="__('Trigger manual job')" - :disabled="triggerBtnDisabled" - data-testid="trigger-manual-job-btn" - @click="trigger" - > - {{ action.button_title }} - </gl-button> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue index 2f97301979c..d7bbd6daed2 100644 --- a/app/assets/javascripts/jobs/components/job/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/job/manual_variables_form.vue @@ -5,15 +5,24 @@ import { GlFormInput, GlButton, GlLink, + GlLoadingIcon, GlSprintf, + GlTooltipDirective, } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; +import { cloneDeep, uniqueId } from 'lodash'; import { mapActions } from 'vuex'; +import { fetchPolicies } from '~/lib/graphql'; +import { createAlert } from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { JOB_GRAPHQL_ERRORS, GRAPHQL_ID_TYPES } from '~/jobs/constants'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; +import GetJob from './graphql/queries/get_job.query.graphql'; +import retryJobWithVariablesMutation from './graphql/mutations/job_retry_with_variables.mutation.graphql'; // This component is a port of ~/jobs/components/job/legacy_manual_variables_form.vue -// It is meant to fetch the job information via GraphQL instead of REST API. +// It is meant to fetch/update the job information via GraphQL instead of REST API. export default { name: 'ManualVariablesForm', @@ -23,59 +32,93 @@ export default { GlFormInput, GlButton, GlLink, + GlLoadingIcon, GlSprintf, }, - props: { - action: { - type: Object, - required: false, - default: null, - validator(value) { - return ( - value === null || - (Object.prototype.hasOwnProperty.call(value, 'path') && - Object.prototype.hasOwnProperty.call(value, 'method') && - Object.prototype.hasOwnProperty.call(value, 'button_title')) - ); + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['projectPath'], + apollo: { + variables: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + }; + }, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + update(data) { + const jobVariables = cloneDeep(data?.project?.job?.manualVariables?.nodes); + return [...jobVariables.reverse(), ...this.variables]; + }, + error() { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); }, }, }, + props: { + isRetryable: { + type: Boolean, + required: true, + }, + jobId: { + type: Number, + required: true, + }, + }, inputTypes: { key: 'key', value: 'value', }, i18n: { + clearInputs: s__('CiVariables|Clear inputs'), + formHelpText: s__( + 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', + ), header: s__('CiVariables|Variables'), keyLabel: s__('CiVariables|Key'), - valueLabel: s__('CiVariables|Value'), keyPlaceholder: s__('CiVariables|Input variable key'), + runAgainButtonText: s__('CiVariables|Run job again'), + triggerButtonText: s__('CiVariables|Trigger this manual action'), + valueLabel: s__('CiVariables|Value'), valuePlaceholder: s__('CiVariables|Input variable value'), - formHelpText: s__( - 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default', - ), + }, + variableValueKeys: { + rest: 'secret_value', + gql: 'value', }, data() { return { + job: {}, variables: [ { - key: '', - secretValue: '', id: uniqueId(), + key: '', + value: '', }, ], + runAgainBtnDisabled: false, triggerBtnDisabled: false, }; }, computed: { - variableSettings() { - return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); - }, preparedVariables() { - // we need to ensure no empty variables are passed to the API - // and secretValue should be snake_case when passed to the API + // filtering out 'id' along with empty variables to send only key, value in the mutation. + // This will be removed in: https://gitlab.com/gitlab-org/gitlab/-/issues/377268 + return this.variables .filter((variable) => variable.key !== '') - .map(({ key, secretValue }) => ({ key, secret_value: secretValue })); + .map(({ key, value }) => ({ key, [this.valueKey]: value })); + }, + valueKey() { + return this.isRetryable + ? this.$options.variableValueKeys.gql + : this.$options.variableValueKeys.rest; + }, + variableSettings() { + return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); }, }, methods: { @@ -88,9 +131,9 @@ export default { } this.variables.push({ - key: '', - secret_value: '', id: uniqueId(), + key: '', + value: '', }); }, canRemove(index) { @@ -105,7 +148,34 @@ export default { inputRef(type, id) { return `${this.$options.inputTypes[type]}-${id}`; }, - trigger() { + navigateToRetriedJob(retryPath) { + redirectTo(retryPath); + }, + async retryJob() { + try { + const { data } = await this.$apollo.mutate({ + mutation: retryJobWithVariablesMutation, + variables: { + id: convertToGraphQLId(GRAPHQL_ID_TYPES.ciBuild, this.jobId), + // we need to ensure no empty variables are passed to the API + variables: this.preparedVariables, + }, + }); + if (data.jobRetry?.errors?.length) { + createAlert({ message: data.jobRetry.errors[0] }); + } else { + this.navigateToRetriedJob(data.jobRetry?.job?.webPath); + } + } catch (error) { + createAlert({ message: JOB_GRAPHQL_ERRORS.retryMutationErrorText }); + } + }, + runAgain() { + this.runAgainBtnDisabled = true; + + this.retryJob(); + }, + triggerJob() { this.triggerBtnDisabled = true; this.triggerManualJob(this.preparedVariables); @@ -114,7 +184,8 @@ export default { }; </script> <template> - <div class="row gl-justify-content-center"> + <gl-loading-icon v-if="$apollo.queries.variables.loading" class="gl-mt-9" size="lg" /> + <div v-else class="row gl-justify-content-center"> <div class="col-10" data-testid="manual-vars-form"> <label>{{ $options.i18n.header }}</label> @@ -147,7 +218,7 @@ export default { </template> <gl-form-input :ref="inputRef('value', variable.id)" - v-model="variable.secretValue" + v-model="variable.value" :placeholder="$options.i18n.valuePlaceholder" data-testid="ci-variable-value" /> @@ -155,11 +226,13 @@ export default { <gl-button v-if="canRemove(index)" + v-gl-tooltip + :aria-label="$options.i18n.clearInputs" + :title="$options.i18n.clearInputs" class="gl-flex-grow-0 gl-flex-basis-0" category="tertiary" variant="danger" icon="clear" - :aria-label="__('Delete variable')" data-testid="delete-variable-btn" @click="deleteVariable(variable.id)" /> @@ -177,7 +250,27 @@ export default { </template> </gl-sprintf> </div> - <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <div v-if="isRetryable" class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-button + class="gl-mt-5" + :aria-label="__('Cancel')" + data-testid="cancel-btn" + @click="$emit('hideManualVariablesForm')" + >{{ __('Cancel') }}</gl-button + > + <gl-button + class="gl-mt-5" + variant="confirm" + category="primary" + :aria-label="__('Run manual job again')" + :disabled="runAgainBtnDisabled" + data-testid="run-manual-job-btn" + @click="runAgain" + > + {{ $options.i18n.runAgainButtonText }} + </gl-button> + </div> + <div v-else class="gl-display-flex gl-justify-content-center gl-mt-5"> <gl-button class="gl-mt-5" variant="confirm" @@ -185,9 +278,9 @@ export default { :aria-label="__('Trigger manual job')" :disabled="triggerBtnDisabled" data-testid="trigger-manual-job-btn" - @click="trigger" + @click="triggerJob" > - {{ action.button_title }} + {{ $options.i18n.triggerButtonText }} </gl-button> </div> </div> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue index dd620977f0c..7183a8b5d03 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue @@ -1,15 +1,17 @@ <script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlModalDirective } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import { JOB_SIDEBAR_COPY } from '~/jobs/constants'; export default { name: 'JobSidebarRetryButton', i18n: { - retryLabel: JOB_SIDEBAR_COPY.retry, + ...JOB_SIDEBAR_COPY, }, components: { GlButton, + GlDropdown, + GlDropdownItem, }, directives: { GlModal: GlModalDirective, @@ -23,6 +25,10 @@ export default { type: String, required: true, }, + isManualJob: { + type: Boolean, + required: true, + }, }, computed: { ...mapGetters(['hasForwardDeploymentFailure']), @@ -33,17 +39,30 @@ export default { <gl-button v-if="hasForwardDeploymentFailure" v-gl-modal="modalId" - :aria-label="$options.i18n.retryLabel" + :aria-label="$options.i18n.retryJobLabel" category="primary" variant="confirm" icon="retry" data-testid="retry-job-button" /> - + <gl-dropdown + v-else-if="isManualJob" + icon="retry" + category="primary" + :right="true" + variant="confirm" + > + <gl-dropdown-item :href="href" data-method="post"> + {{ $options.i18n.runAgainJobButtonLabel }} + </gl-dropdown-item> + <gl-dropdown-item @click="$emit('updateVariablesClicked')"> + {{ $options.i18n.updateVariables }} + </gl-dropdown-item> + </gl-dropdown> <gl-button v-else :href="href" - :aria-label="$options.i18n.retryLabel" + :aria-label="$options.i18n.retryJobLabel" category="primary" variant="confirm" icon="retry" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue deleted file mode 100644 index 64b497c3550..00000000000 --- a/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue +++ /dev/null @@ -1,104 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { mapActions } from 'vuex'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; -import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; - -export default { - name: 'LegacySidebarHeader', - i18n: { - ...JOB_SIDEBAR_COPY, - }, - forwardDeploymentFailureModalId, - directives: { - GlTooltip: GlTooltipDirective, - }, - components: { - GlButton, - JobSidebarRetryButton, - TooltipOnTruncate, - }, - props: { - job: { - type: Object, - required: true, - default: () => ({}), - }, - erasePath: { - type: String, - required: false, - default: null, - }, - }, - computed: { - retryButtonCategory() { - return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; - }, - buttonTitle() { - return this.job.status && this.job.status.text === 'passed' - ? this.$options.i18n.runAgainJobButtonLabel - : this.$options.i18n.retryJobButtonLabel; - }, - }, - methods: { - ...mapActions(['toggleSidebar']), - }, -}; -</script> - -<template> - <div class="gl-py-5 gl-display-flex gl-align-items-center"> - <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> - {{ job.name }} - </h4> - </tooltip-on-truncate> - <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> - <gl-button - v-if="erasePath" - v-gl-tooltip.left - :title="$options.i18n.eraseLogButtonLabel" - :aria-label="$options.i18n.eraseLogButtonLabel" - :href="erasePath" - :data-confirm="$options.i18n.eraseLogConfirmText" - class="gl-mr-2" - data-testid="job-log-erase-link" - data-confirm-btn-variant="danger" - data-method="post" - icon="remove" - /> - <job-sidebar-retry-button - v-if="job.retry_path" - v-gl-tooltip.left - :title="buttonTitle" - :aria-label="buttonTitle" - :category="retryButtonCategory" - :href="job.retry_path" - :modal-id="$options.forwardDeploymentFailureModalId" - variant="confirm" - data-qa-selector="retry_button" - data-testid="retry-button" - /> - <gl-button - v-if="job.cancel_path" - v-gl-tooltip.left - :title="$options.i18n.cancelJobButtonLabel" - :aria-label="$options.i18n.cancelJobButtonLabel" - :href="job.cancel_path" - variant="danger" - icon="cancel" - data-method="post" - data-testid="cancel-button" - rel="nofollow" - /> - <gl-button - :aria-label="$options.i18n.toggleSidebar" - category="tertiary" - class="gl-md-display-none gl-ml-2" - icon="chevron-double-lg-right" - @click="toggleSidebar" - /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue index aac6a0ad6d3..69271cc9022 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue @@ -2,14 +2,12 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import ArtifactsBlock from './artifacts_block.vue'; import CommitBlock from './commit_block.vue'; import JobsContainer from './jobs_container.vue'; import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; -import ArtifactsBlock from './artifacts_block.vue'; -import LegacySidebarHeader from './legacy_sidebar_header.vue'; import SidebarHeader from './sidebar_header.vue'; import StagesDropdown from './stages_dropdown.vue'; import TriggerBlock from './trigger_block.vue'; @@ -29,23 +27,16 @@ export default { JobsContainer, JobRetryForwardDeploymentModal, JobSidebarDetailsContainer, - LegacySidebarHeader, SidebarHeader, StagesDropdown, TriggerBlock, }, - mixins: [glFeatureFlagsMixin()], props: { artifactHelpUrl: { type: String, required: false, default: '', }, - erasePath: { - type: String, - required: false, - default: null, - }, }, computed: { ...mapGetters(['hasForwardDeploymentFailure']), @@ -57,9 +48,6 @@ export default { hasTriggers() { return !isEmpty(this.job.trigger); }, - isGraphQL() { - return this.glFeatures?.graphqlJobApp; - }, commit() { return this.job?.pipeline?.commit || {}; }, @@ -89,8 +77,11 @@ export default { <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> <div class="blocks-container"> - <sidebar-header v-if="isGraphQL" :erase-path="erasePath" :job="job" /> - <legacy-sidebar-header v-else :erase-path="erasePath" :job="job" /> + <sidebar-header + :rest-job="job" + :job-id="job.id" + @updateVariables="$emit('updateVariables')" + /> <div v-if="job.terminal_path || job.new_issue_path" class="gl-py-5" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue index 523710598bf..40aec0b0536 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue @@ -1,13 +1,19 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import { createAlert } from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; -import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import { + JOB_GRAPHQL_ERRORS, + GRAPHQL_ID_TYPES, + JOB_SIDEBAR_COPY, + forwardDeploymentFailureModalId, + PASSED_STATUS, +} from '~/jobs/constants'; +import GetJob from '../graphql/queries/get_job.query.graphql'; import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; -// This component is a port of ~/jobs/components/job/sidebar/legacy_sidebar_header.vue -// It is meant to fetch the job information via GraphQL instead of REST API. - export default { name: 'SidebarHeader', i18n: { @@ -22,21 +28,58 @@ export default { JobSidebarRetryButton, TooltipOnTruncate, }, - props: { + inject: ['projectPath'], + apollo: { job: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(GRAPHQL_ID_TYPES.commitStatus, this.jobId), + }; + }, + update(data) { + const { name, manualJob } = data?.project?.job || {}; + return { + name, + manualJob, + }; + }, + error() { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); + }, + }, + }, + props: { + jobId: { + type: Number, + required: true, + }, + restJob: { type: Object, required: true, default: () => ({}), }, - erasePath: { - type: String, - required: false, - default: null, - }, + }, + data() { + return { + job: {}, + }; }, computed: { + buttonTitle() { + return this.restJob.status?.text === PASSED_STATUS + ? this.$options.i18n.runAgainJobButtonLabel + : this.$options.i18n.retryJobLabel; + }, + canShowJobRetryButton() { + return this.restJob.retry_path && !this.$apollo.queries.job.loading; + }, + isManualJob() { + return this.job?.manualJob; + }, retryButtonCategory() { - return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; + return this.restJob.status && this.restJob.recoverable ? 'primary' : 'secondary'; }, }, methods: { @@ -48,17 +91,15 @@ export default { <template> <div class="gl-py-5 gl-display-flex gl-align-items-center"> <tooltip-on-truncate :title="job.name" truncate-target="child" - ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> - {{ job.name }} - </h4> + ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate" data-testid="job-name">{{ job.name }}</h4> </tooltip-on-truncate> <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> <gl-button - v-if="erasePath" + v-if="restJob.erase_path" v-gl-tooltip.left :title="$options.i18n.eraseLogButtonLabel" :aria-label="$options.i18n.eraseLogButtonLabel" - :href="erasePath" + :href="restJob.erase_path" :data-confirm="$options.i18n.eraseLogConfirmText" class="gl-mr-2" data-testid="job-log-erase-link" @@ -67,23 +108,25 @@ export default { icon="remove" /> <job-sidebar-retry-button - v-if="job.retry_path" + v-if="canShowJobRetryButton" v-gl-tooltip.left - :title="$options.i18n.retryJobButtonLabel" - :aria-label="$options.i18n.retryJobButtonLabel" + :title="buttonTitle" + :aria-label="buttonTitle" + :is-manual-job="isManualJob" :category="retryButtonCategory" - :href="job.retry_path" + :href="restJob.retry_path" :modal-id="$options.forwardDeploymentFailureModalId" variant="confirm" data-qa-selector="retry_button" data-testid="retry-button" + @updateVariablesClicked="$emit('updateVariables')" /> <gl-button - v-if="job.cancel_path" + v-if="restJob.cancel_path" v-gl-tooltip.left :title="$options.i18n.cancelJobButtonLabel" :aria-label="$options.i18n.cancelJobButtonLabel" - :href="job.cancel_path" + :href="restJob.cancel_path" variant="danger" icon="cancel" data-method="post" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue index 3b1509e5be5..8300a22cb67 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue @@ -1,6 +1,7 @@ <script> import { mapState } from 'vuex'; import { GlBadge } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -79,7 +80,9 @@ export default { TAGS: __('Tags:'), TIMEOUT: __('Timeout'), }, - RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html', + TIMEOUT_HELP_URL: helpPagePath('/ci/pipelines/settings.md', { + anchor: 'set-a-limit-for-how-long-jobs-can-run', + }), }; </script> @@ -96,7 +99,7 @@ export default { <detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" /> <detail-row v-if="hasTimeout" - :help-url="$options.RUNNER_HELP_URL" + :help-url="$options.TIMEOUT_HELP_URL" :value="timeout" data-testid="job-timeout" :title="$options.i18n.TIMEOUT" diff --git a/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue index 1afc1c9a595..c9172fe0322 100644 --- a/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue @@ -2,9 +2,7 @@ import { GlButton, GlTableLite } from '@gitlab/ui'; import { __ } from '~/locale'; -const DEFAULT_TD_CLASSES = 'gl-w-half gl-font-sm! gl-border-gray-200!'; -const DEFAULT_TH_CLASSES = - 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1!'; +const DEFAULT_TD_CLASSES = 'gl-font-sm!'; export default { fields: [ @@ -13,14 +11,12 @@ export default { label: __('Key'), tdAttr: { 'data-testid': 'trigger-build-key' }, tdClass: DEFAULT_TD_CLASSES, - thClass: DEFAULT_TH_CLASSES, }, { key: 'value', label: __('Value'), tdAttr: { 'data-testid': 'trigger-build-value' }, tdClass: DEFAULT_TD_CLASSES, - thClass: DEFAULT_TH_CLASSES, }, ], components: { diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js index e9475994e8b..405aea11181 100644 --- a/app/assets/javascripts/jobs/constants.js +++ b/app/assets/javascripts/jobs/constants.js @@ -5,6 +5,11 @@ const moreInfo = __('More information'); export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; +export const GRAPHQL_ID_TYPES = { + commitStatus: 'CommitStatus', + ciBuild: 'Ci::Build', +}; + export const JOB_SIDEBAR_COPY = { cancel, cancelJobButtonLabel: s__('Job|Cancel'), @@ -12,10 +17,15 @@ export const JOB_SIDEBAR_COPY = { eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), newIssue: __('New issue'), - retry: __('Retry'), - retryJobButtonLabel: s__('Job|Retry'), + retryJobLabel: s__('Job|Retry'), toggleSidebar: __('Toggle Sidebar'), runAgainJobButtonLabel: s__('Job|Run again'), + updateVariables: s__('Job|Update CI/CD variables'), +}; + +export const JOB_GRAPHQL_ERRORS = { + retryMutationErrorText: __('There was an error running the job. Please try again.'), + jobQueryErrorText: __('There was an error fetching the job.'), }; export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { @@ -31,3 +41,4 @@ export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { }; export const SUCCESS_STATUS = 'SUCCESS'; +export const PASSED_STATUS = 'passed'; diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 9dd47f4046c..44bb1ffb1bc 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -1,10 +1,17 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import JobApp from './components/job/job_app.vue'; import createStore from './store'; +Vue.use(VueApollo); Vue.use(GlToast); +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + const initializeJobPage = (element) => { const store = createStore(); @@ -26,11 +33,13 @@ const initializeJobPage = (element) => { return new Vue({ el: element, + apolloProvider, store, components: { JobApp, }, provide: { + projectPath, retryOutdatedJobDocsUrl, }, render(createElement) { diff --git a/app/assets/javascripts/labels/labels_select.js b/app/assets/javascripts/labels/labels_select.js index 65dda804a20..515b0a79a03 100644 --- a/app/assets/javascripts/labels/labels_select.js +++ b/app/assets/javascripts/labels/labels_select.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import IssuableBulkUpdateActions from '~/issuable/bulk_update_sidebar/issuable_bulk_update_actions'; +import IssuableBulkUpdateActions from '~/issuable/issuable_bulk_update_actions'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; diff --git a/app/assets/javascripts/language_switcher/components/app.vue b/app/assets/javascripts/language_switcher/components/app.vue new file mode 100644 index 00000000000..71babe6c614 --- /dev/null +++ b/app/assets/javascripts/language_switcher/components/app.vue @@ -0,0 +1,49 @@ +<script> +import { GlCollapsibleListbox } from '@gitlab/ui'; +import { setCookie } from '~/lib/utils/common_utils'; +import { PREFERRED_LANGUAGE_COOKIE_KEY } from '../constants'; + +export default { + components: { + GlCollapsibleListbox, + }, + inject: { + locales: { + default: [], + }, + preferredLocale: { + default: {}, + }, + }, + data() { + return { + selected: this.preferredLocale.value, + }; + }, + methods: { + onLanguageSelected(code) { + setCookie(PREFERRED_LANGUAGE_COOKIE_KEY, code); + window.location.reload(); + }, + }, +}; +</script> +<template> + <gl-collapsible-listbox + v-model="selected" + :toggle-text="preferredLocale.text" + :items="locales" + category="tertiary" + right + icon="earth" + size="small" + toggle-class="py-0 gl-h-6" + @select="onLanguageSelected" + > + <template #list-item="{ item: locale }"> + <span :data-testid="`language_switcher_lang_${locale.value}`"> + {{ locale.text }} + </span> + </template> + </gl-collapsible-listbox> +</template> diff --git a/app/assets/javascripts/language_switcher/constants.js b/app/assets/javascripts/language_switcher/constants.js new file mode 100644 index 00000000000..b5c0613ac01 --- /dev/null +++ b/app/assets/javascripts/language_switcher/constants.js @@ -0,0 +1 @@ +export const PREFERRED_LANGUAGE_COOKIE_KEY = 'preferred_language'; diff --git a/app/assets/javascripts/language_switcher/index.js b/app/assets/javascripts/language_switcher/index.js new file mode 100644 index 00000000000..b224e2510bb --- /dev/null +++ b/app/assets/javascripts/language_switcher/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import { getCookie } from '~/lib/utils/common_utils'; +import LanguageSwitcher from './components/app.vue'; +import { PREFERRED_LANGUAGE_COOKIE_KEY } from './constants'; + +export const initLanguageSwitcher = () => { + const el = document.querySelector('.js-language-switcher'); + if (!el) return false; + const locales = JSON.parse(el.dataset.locales); + const preferredLangCode = getCookie(PREFERRED_LANGUAGE_COOKIE_KEY); + const preferredLocale = locales.find((locale) => locale.value === preferredLangCode); + + return new Vue({ + el, + provide: { + locales, + preferredLocale, + }, + render(createElement) { + return createElement(LanguageSwitcher); + }, + }); +}; diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js index 27760e483aa..5372f6555d2 100644 --- a/app/assets/javascripts/lib/dompurify.js +++ b/app/assets/javascripts/lib/dompurify.js @@ -18,7 +18,7 @@ export const defaultConfig = { 'data-disable', 'data-turbo', ], - FORBID_TAGS: ['style', 'mstyle'], + FORBID_TAGS: ['style', 'mstyle', 'form'], ALLOW_UNKNOWN_PROTOCOLS: true, }; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index beced4f9144..4ce63d518a6 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -4,9 +4,9 @@ import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; -import { isFunction, defer } from 'lodash'; +import { isFunction, defer, escape } from 'lodash'; import Cookies from '~/lib/utils/cookies'; -import { SCOPED_LABEL_DELIMITER } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; +import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; import { getLocationHash } from './url_utility'; @@ -28,16 +28,12 @@ export const checkPageAndAction = (page, action) => { export const isInIncidentPage = () => checkPageAndAction('incidents', 'show'); export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInDesignPage = () => checkPageAndAction('issues', 'designs'); -export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); +export const isInMRPage = () => + checkPageAndAction('merge_requests', 'show') || checkPageAndAction('merge_requests', 'diffs'); export const isInEpicPage = () => checkPageAndAction('epics', 'show'); export const getDashPath = (path = window.location.pathname) => path.split('/-/')[1] || null; -export const getCspNonceValue = () => { - const metaTag = document.querySelector('meta[name=csp-nonce]'); - return metaTag && metaTag.content; -}; - export const rstrip = (val) => { if (val) { return val.replace(/\s+$/, ''); @@ -469,7 +465,7 @@ export const backOff = (fn, timeout = 60000) => { export const spriteIcon = (icon, className = '') => { const classAttribute = className.length > 0 ? `class="${className}"` : ''; - return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${icon}" /></svg>`; + return `<svg ${classAttribute}><use xlink:href="${gon.sprite_icons}#${escape(icon)}" /></svg>`; }; /** @@ -715,3 +711,16 @@ export const getFirstPropertyValue = (data) => { return data[key]; }; + +// TODO: remove when FF `new_fonts` is removed https://gitlab.com/gitlab-org/gitlab/-/issues/379147 +/** + * This method checks the FF `new_fonts` + * as well as a query parameter `new_fonts`. + * If either of them is enabled, new fonts will be applied. + * + * @returns Boolean Whether to apply new fonts + */ +export const useNewFonts = () => { + const hasQueryParam = new URLSearchParams(window.location.search).has('new_fonts'); + return window?.gon.features?.newFonts || hasQueryParam; +}; diff --git a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue index 3788d8ab20c..ea91ccec546 100644 --- a/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue +++ b/app/assets/javascripts/lib/utils/confirm_via_gl_modal/confirm_modal.vue @@ -1,10 +1,11 @@ <script> -import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; export default { directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { GlModal, diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 379c57f3945..2c8953237cf 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,6 +1,5 @@ export const BYTES_IN_KIB = 1024; export const DEFAULT_DEBOUNCE_AND_THROTTLE_MS = 250; -export const HIDDEN_CLASS = 'hidden'; export const THOUSAND = 1000; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; diff --git a/app/assets/javascripts/lib/utils/create_and_submit_form.js b/app/assets/javascripts/lib/utils/create_and_submit_form.js new file mode 100644 index 00000000000..fce4f898f2f --- /dev/null +++ b/app/assets/javascripts/lib/utils/create_and_submit_form.js @@ -0,0 +1,26 @@ +import csrf from '~/lib/utils/csrf'; + +export const createAndSubmitForm = ({ url, data }) => { + const form = document.createElement('form'); + + form.action = url; + // For now we only support 'post'. + // `form.method` doesn't support other methods so we would need to + // use a hidden `_method` input, which is out of scope for now. + form.method = 'post'; + form.style.display = 'none'; + + Object.entries(data) + .concat([['authenticity_token', csrf.token]]) + .forEach(([key, value]) => { + const input = document.createElement('input'); + input.type = 'hidden'; + input.name = key; + input.value = value; + + form.appendChild(input); + }); + + document.body.appendChild(form); + form.submit(); +}; diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index cafee641174..317c401e404 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -118,3 +118,24 @@ export const getContentWrapperHeight = (contentWrapperClass) => { const wrapperEl = document.querySelector(contentWrapperClass); return wrapperEl ? `${wrapperEl.offsetTop}px` : ''; }; + +/** + * Replaces comment nodes in a DOM tree with a different element + * containing the text of the comment. + * + * @param {*} el + * @param {*} tagName + */ +export const replaceCommentsWith = (el, tagName) => { + const iterator = document.createNodeIterator(el, NodeFilter.SHOW_COMMENT); + let commentNode = iterator.nextNode(); + + while (commentNode) { + const newNode = document.createElement(tagName); + newNode.textContent = commentNode.textContent; + + commentNode.parentNode.replaceChild(newNode, commentNode); + + commentNode = iterator.nextNode(); + } +}; diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index c5190592bb6..ec0d8d433a5 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -1,45 +1,43 @@ -/** - * exports HTTP status codes - */ +export const HTTP_STATUS_ABORTED = 0; +export const HTTP_STATUS_CREATED = 201; +export const HTTP_STATUS_ACCEPTED = 202; +export const HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION = 203; +export const HTTP_STATUS_NO_CONTENT = 204; +export const HTTP_STATUS_RESET_CONTENT = 205; +export const HTTP_STATUS_PARTIAL_CONTENT = 206; +export const HTTP_STATUS_MULTI_STATUS = 207; +export const HTTP_STATUS_ALREADY_REPORTED = 208; +export const HTTP_STATUS_IM_USED = 226; +export const HTTP_STATUS_METHOD_NOT_ALLOWED = 405; +export const HTTP_STATUS_CONFLICT = 409; +export const HTTP_STATUS_GONE = 410; +export const HTTP_STATUS_PAYLOAD_TOO_LARGE = 413; +export const HTTP_STATUS_UNPROCESSABLE_ENTITY = 422; +export const HTTP_STATUS_TOO_MANY_REQUESTS = 429; +// TODO move the rest of the status codes to primitive constants +// https://docs.gitlab.com/ee/development/fe_guide/style/javascript.html#export-constants-as-primitives const httpStatusCodes = { - ABORTED: 0, OK: 200, - CREATED: 201, - ACCEPTED: 202, - NON_AUTHORITATIVE_INFORMATION: 203, - NO_CONTENT: 204, - RESET_CONTENT: 205, - PARTIAL_CONTENT: 206, - MULTI_STATUS: 207, - ALREADY_REPORTED: 208, - IM_USED: 226, - MULTIPLE_CHOICES: 300, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, - METHOD_NOT_ALLOWED: 405, - CONFLICT: 409, - GONE: 410, - PAYLOAD_TOO_LARGE: 413, - UNPROCESSABLE_ENTITY: 422, - TOO_MANY_REQUESTS: 429, INTERNAL_SERVER_ERROR: 500, SERVICE_UNAVAILABLE: 503, }; export const successCodes = [ httpStatusCodes.OK, - httpStatusCodes.CREATED, - httpStatusCodes.ACCEPTED, - httpStatusCodes.NON_AUTHORITATIVE_INFORMATION, - httpStatusCodes.NO_CONTENT, - httpStatusCodes.RESET_CONTENT, - httpStatusCodes.PARTIAL_CONTENT, - httpStatusCodes.MULTI_STATUS, - httpStatusCodes.ALREADY_REPORTED, - httpStatusCodes.IM_USED, + HTTP_STATUS_CREATED, + HTTP_STATUS_ACCEPTED, + HTTP_STATUS_NON_AUTHORITATIVE_INFORMATION, + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_RESET_CONTENT, + HTTP_STATUS_PARTIAL_CONTENT, + HTTP_STATUS_MULTI_STATUS, + HTTP_STATUS_ALREADY_REPORTED, + HTTP_STATUS_IM_USED, ]; export default httpStatusCodes; diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js index 71782c9a4ce..73add1e37ee 100644 --- a/app/assets/javascripts/lib/utils/poll.js +++ b/app/assets/javascripts/lib/utils/poll.js @@ -1,5 +1,5 @@ import { normalizeHeaders } from './common_utils'; -import httpStatusCodes, { successCodes } from './http_status'; +import { HTTP_STATUS_ABORTED, successCodes } from './http_status'; /** * Polling utility for handling realtime updates. @@ -108,7 +108,7 @@ export default class Poll { }) .catch((error) => { notificationCallback(false); - if (error.status === httpStatusCodes.ABORTED) { + if (error.status === HTTP_STATUS_ABORTED) { return; } errorCallback(error); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index b1a0baf8150..f33484f4192 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -86,7 +86,7 @@ export function cleanLeadingSeparator(path) { return path.replace(PATH_SEPARATOR_LEADING_REGEX, ''); } -function cleanEndingSeparator(path) { +export function cleanEndingSeparator(path) { return path.replace(PATH_SEPARATOR_ENDING_REGEX, ''); } diff --git a/app/assets/javascripts/listbox/index.js b/app/assets/javascripts/listbox/index.js index 7eacbf7fcdd..7e8fc4b637b 100644 --- a/app/assets/javascripts/listbox/index.js +++ b/app/assets/javascripts/listbox/index.js @@ -1,4 +1,4 @@ -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -31,7 +31,7 @@ export function initListbox(el, { onChange } = {}) { }, }, render(h) { - return h(GlListbox, { + return h(GlCollapsibleListbox, { props: { items, right, diff --git a/app/assets/javascripts/listbox/redirect_behavior.js b/app/assets/javascripts/listbox/redirect_behavior.js index 7e0ea2c4dfd..38d9d84f889 100644 --- a/app/assets/javascripts/listbox/redirect_behavior.js +++ b/app/assets/javascripts/listbox/redirect_behavior.js @@ -2,7 +2,7 @@ import { initListbox } from '~/listbox'; import { redirectTo } from '~/lib/utils/url_utility'; /** - * Instantiates GlListbox components with redirect behavior for tags created + * Instantiates GlCollapsibleListbox components with redirect behavior for tags created * with the `gl_redirect_listbox_tag` HAML helper. * * NOTE: Do not import this script explicitly. Using `gl_redirect_listbox_tag` diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 8e4ebd510aa..df3b55ed2ad 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -37,6 +37,7 @@ import initBroadcastNotifications from './broadcast_notification'; import { initTopNav } from './nav'; import { initCopyCodeButton } from './behaviors/copy_code'; import initHeaderSearch from './header_search/init'; +import initGitlabVersionCheck from './gitlab_version_check'; import 'ee_else_ce/main_ee'; import 'jh_else_ce/main_jh'; @@ -100,21 +101,7 @@ function deferredInitialisation() { initDefaultTrackers(); initFeatureHighlight(); initCopyCodeButton(); - - const helpToggle = document.querySelector('.header-help-dropdown-toggle'); - if (helpToggle) { - helpToggle.addEventListener( - 'click', - () => { - import(/* webpackChunkName: 'versionCheck' */ './gitlab_version_check') - .then(({ default: initGitlabVersionCheck }) => { - initGitlabVersionCheck(); - }) - .catch(() => {}); - }, - { once: true }, - ); - } + initGitlabVersionCheck(); addSelectOnFocusBehaviour('.js-select-on-focus'); diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue index ec59f0f681c..4260ee14a14 100644 --- a/app/assets/javascripts/members/components/avatars/user_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue @@ -1,10 +1,6 @@ <script> -import { - GlAvatarLink, - GlAvatarLabeled, - GlBadge, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlAvatarLink, GlAvatarLabeled, GlBadge } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { generateBadges } from 'ee_else_ce/members/utils'; import { glEmojiTag } from '~/emoji'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue index cb7b963b698..76b286f94ad 100644 --- a/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue +++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue @@ -7,11 +7,11 @@ import { redirectTo, } from '~/lib/utils/url_utility'; import { - SEARCH_TOKEN_TYPE, SORT_QUERY_PARAM_NAME, ACTIVE_TAB_QUERY_PARAM_NAME, AVAILABLE_FILTERED_SEARCH_TOKENS, } from 'ee_else_ce/members/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; export default { @@ -65,7 +65,7 @@ export default { if (query[this.filteredSearchBar.searchParam]) { tokens.push({ - type: SEARCH_TOKEN_TYPE, + type: FILTERED_SEARCH_TERM, value: { data: query[this.filteredSearchBar.searchParam], }, @@ -83,7 +83,7 @@ export default { return accumulator; } - if (type === SEARCH_TOKEN_TYPE) { + if (type === FILTERED_SEARCH_TERM) { if (value.data !== '') { const { searchParam } = this.filteredSearchBar; const { [searchParam]: searchParamValue } = accumulator; diff --git a/app/assets/javascripts/members/constants.js b/app/assets/javascripts/members/constants.js index 3135ec602be..dab544c7cbc 100644 --- a/app/assets/javascripts/members/constants.js +++ b/app/assets/javascripts/members/constants.js @@ -1,7 +1,7 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; // Overridden in EE export const EE_APP_OPTIONS = {}; @@ -117,7 +117,7 @@ export const FILTERED_SEARCH_TOKEN_TWO_FACTOR = { title: s__('Members|2FA'), token: GlFilteredSearchToken, unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { value: 'enabled', title: s__('Members|Enabled') }, { value: 'disabled', title: s__('Members|Disabled') }, @@ -131,7 +131,7 @@ export const FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS = { title: s__('Members|Membership'), token: GlFilteredSearchToken, unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { value: 'exclude', title: s__('Members|Direct') }, { value: 'only', title: s__('Members|Inherited') }, @@ -187,8 +187,6 @@ export const LEAVE_MODAL_ID = 'member-leave-modal'; export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id'; -export const SEARCH_TOKEN_TYPE = 'filtered-search-term'; - export const SORT_QUERY_PARAM_NAME = 'sort'; export const ACTIVE_TAB_QUERY_PARAM_NAME = 'tab'; diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue index 87eeb272659..6c431dc8af3 100644 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import syntaxHighlight from '~/syntax_highlight'; import { SYNTAX_HIGHLIGHT_CLASS } from '../constants'; import utilsMixin from '../mixins/line_conflict_utils'; diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue index 2c59e7bfa2f..f8a097a3a0f 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import syntaxHighlight from '~/syntax_highlight'; import { SYNTAX_HIGHLIGHT_CLASS } from '../constants'; import utilsMixin from '../mixins/line_conflict_utils'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 57b5e9809d2..80eb94a5364 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -94,7 +94,11 @@ MergeRequest.prototype.initMRBtnListeners = function () { .put(draftToggle.href, null, { params: { format: 'json' } }) .then(({ data }) => { draftToggle.removeAttribute('disabled'); - eventHub.$emit('MRWidgetUpdateRequested'); + + if (!window.gon?.features?.realtimeMrStatusChange) { + eventHub.$emit('MRWidgetUpdateRequested'); + } + MergeRequest.toggleDraftStatus(data.title, wipEvent === 'ready'); }) .catch(() => { @@ -173,7 +177,7 @@ MergeRequest.toggleDraftStatus = function (title, isReady) { ); draftToggle.setAttribute('href', url); - draftToggle.querySelector('.gl-new-dropdown-item-text-wrapper').textContent = isReady + draftToggle.querySelector('.gl-dropdown-item-text-wrapper').textContent = isReady ? __('Mark as draft') : __('Mark as ready'); }); diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 0ddf5def8ee..5a1410ceeba 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -5,6 +5,7 @@ import { createAlert } from '~/flash'; import { getCookie, isMetaClick, parseBoolean, scrollToElement } from '~/lib/utils/common_utils'; import { parseUrlPathname } from '~/lib/utils/url_utility'; import createEventHub from '~/helpers/event_hub_factory'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import Diff from './diff'; import { initDiffStatsDropdown } from './init_diff_stats_dropdown'; @@ -161,6 +162,23 @@ function toggleLoader(state) { $('.mr-loading-status .loading').toggleClass('hide', !state); } +function getActionFromHref(href) { + let action = new URL(href).pathname.match(/\/(commits|diffs|pipelines).*$/); + + if (action) { + action = action[0].replace(/(^\/|\.html)/g, ''); + } else { + action = 'show'; + } + + return action; +} + +const pageBundles = { + show: () => import(/* webpackPrefetch: true */ '~/mr_notes/init_notes'), + diffs: () => import(/* webpackPrefetch: true */ '~/diffs'), +}; + export default class MergeRequestTabs { constructor({ action, setUrl, stubLocation } = {}) { this.mergeRequestTabs = document.querySelector('.merge-request-tabs-container'); @@ -186,10 +204,10 @@ export default class MergeRequestTabs { this.currentTab = null; this.diffsLoaded = false; - this.pipelinesLoaded = false; this.commitsLoaded = false; this.fixedLayoutPref = null; this.eventHub = createEventHub(); + this.loadedPages = { [action]: true }; this.setUrl = setUrl !== undefined ? setUrl : true; this.setCurrentAction = this.setCurrentAction.bind(this); @@ -206,12 +224,11 @@ export default class MergeRequestTabs { bindEvents() { $('.merge-request-tabs a[data-toggle="tabvue"]').on('click', this.clickTab); - window.addEventListener('popstate', (event) => { - if (event.state && event.state.action) { - this.tabShown(event.state.action, event.target.location); - this.currentAction = event.state.action; - this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); - } + window.addEventListener('popstate', () => { + const action = getActionFromHref(location.href); + + this.tabShown(action, location.href); + this.eventHub.$emit('MergeRequestTabChange', action); }); } @@ -252,17 +269,18 @@ export default class MergeRequestTabs { } else if (action) { const href = e.currentTarget.getAttribute('href'); this.tabShown(action, href); - - if (this.setUrl) { - this.setCurrentAction(action); - } } } } tabShown(action, href, shouldScroll = true) { + toggleLoader(false); + if (action !== this.currentTab && this.mergeRequestTabs) { this.currentTab = action; + if (this.setUrl) { + this.setCurrentAction(action); + } if (this.mergeRequestTabPanesAll) { this.mergeRequestTabPanesAll.forEach((el) => { @@ -282,6 +300,20 @@ export default class MergeRequestTabs { const tab = this.mergeRequestTabs.querySelector(`.${action}-tab`); if (tab) tab.classList.add('active'); + if (!this.loadedPages[action] && action in pageBundles) { + toggleLoader(true); + pageBundles[action]() + .then(({ default: init }) => { + toggleLoader(false); + init(); + this.loadedPages[action] = true; + }) + .catch(() => { + toggleLoader(false); + createAlert({ message: __('MergeRequest|Failed to load the page') }); + }); + } + if (window.gon?.features?.movedMrSidebar) { this.expandSidebar?.forEach((el) => el.classList.toggle('gl-display-none!', action !== 'show'), @@ -334,7 +366,7 @@ export default class MergeRequestTabs { this.commitPipelinesTable = destroyPipelines(this.commitPipelinesTable); } - $('.detail-page-description').renderGFM(); + renderGFM(document.querySelector('.detail-page-description')); if (shouldScroll) this.recallScroll(action); } else if (action === this.currentAction) { @@ -398,7 +430,7 @@ export default class MergeRequestTabs { // Ensure parameters and hash come along for the ride newState += location.search + location.hash; - if (window.history.state && window.history.state.url && window.location.pathname !== newState) { + if (window.location.pathname !== newState) { window.history.pushState( { url: newState, @@ -477,8 +509,6 @@ export default class MergeRequestTabs { return; } - toggleLoader(true); - loadDiffs({ // We extract pathname for the current Changes tab anchor href // some pages like MergeRequestsController#new has query parameters on that anchor @@ -496,9 +526,6 @@ export default class MergeRequestTabs { createAlert({ message: __('An error occurred while fetching this tab.'), }); - }) - .finally(() => { - toggleLoader(false); }); } diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index b7629ba001f..4a675cf7563 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -1,12 +1,7 @@ <script> -import { - GlIntersectionObserver, - GlLink, - GlSprintf, - GlBadge, - GlSafeHtmlDirective, -} from '@gitlab/ui'; +import { GlIntersectionObserver, GlLink, GlSprintf, GlBadge } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -28,7 +23,7 @@ export default { ClipboardButton, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], inject: { diff --git a/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue b/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue new file mode 100644 index 00000000000..cd2e25793f4 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/target_project_dropdown.vue @@ -0,0 +1,87 @@ +<script> +import { GlListbox } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; + +export default { + components: { + GlListbox, + }, + inject: { + targetProjectsPath: { + type: String, + required: true, + }, + currentProject: { + type: Object, + required: true, + }, + }, + data() { + return { + currentProject: this.currentProject, + selected: this.currentProject.value, + isLoading: false, + projects: [], + }; + }, + methods: { + async fetchProjects(search = '') { + this.isLoading = true; + + try { + const { data } = await axios.get(this.targetProjectsPath, { + params: { search }, + }); + + this.projects = data.map((p) => ({ + value: `${p.id}`, + text: p.full_path.replace(/^\//, ''), + refsUrl: p.refs_url, + })); + this.isLoading = false; + } catch { + createAlert({ + message: __('Error fetching target projects. Please try again.'), + primaryButton: { text: __('Try again'), clickHandler: () => this.fetchProjects(search) }, + }); + } + }, + searchProjects: debounce(function searchProjects(search) { + this.fetchProjects(search); + }, 500), + selectProject(projectId) { + this.currentProject = this.projects.find((p) => p.value === projectId); + + this.$emit('project-selected', this.currentProject.refsUrl); + }, + }, +}; +</script> + +<template> + <div> + <input + id="merge_request_target_project_id" + type="hidden" + :value="currentProject.value" + name="merge_request[target_project_id]" + data-testid="target-project-input" + /> + <gl-listbox + v-model="selected" + :items="projects" + :toggle-text="currentProject.text" + :header-text="__('Select target project')" + :searching="isLoading" + searchable + class="gl-w-full dropdown-target-project" + toggle-class="gl-align-items-flex-start! gl-justify-content-start! mr-compare-dropdown js-target-project" + @shown="fetchProjects" + @search="searchProjects" + @select="selectProject" + /> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/components/experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/experiment.vue deleted file mode 100644 index 73cdfbc44b0..00000000000 --- a/app/assets/javascripts/ml/experiment_tracking/components/experiment.vue +++ /dev/null @@ -1,36 +0,0 @@ -<script> -import { GlTable } from '@gitlab/ui'; -import IncubationAlert from './incubation_alert.vue'; - -export default { - name: 'ShowMlExperiment', - components: { - GlTable, - IncubationAlert, - }, - inject: ['candidates', 'metricNames', 'paramNames'], - computed: { - fields() { - return [...this.paramNames, ...this.metricNames]; - }, - }, -}; -</script> - -<template> - <div> - <incubation-alert /> - - <h3> - {{ __('Experiment Candidates') }} - </h3> - - <gl-table - :fields="fields" - :items="candidates" - :empty-text="__('This Experiment has no logged Candidates')" - show-empty - class="gl-mt-0!" - /> - </div> -</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue b/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue index 51c1e935677..42f6394ed68 100644 --- a/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue +++ b/app/assets/javascripts/ml/experiment_tracking/components/incubation_alert.vue @@ -8,8 +8,8 @@ export default { contentLabel: __( 'GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited', ), - learnMoreLabel: __('Learn More'), - feedbackLabel: __('Feedback and Updates'), + learnMoreLabel: __('Learn more'), + feedbackLabel: __('Feedback'), }, name: 'MlopsIncubationAlert', components: { GlAlert, GlLink }, @@ -37,7 +37,7 @@ export default { :title="$options.i18n.titleLabel" variant="warning" :primary-button-text="$options.i18n.feedbackLabel" - primary-button-link="https://gitlab.com/groups/gitlab-org/-/epics/8560" + primary-button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/381660" @dismiss="dismissAlert" > {{ $options.i18n.contentLabel }} diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue new file mode 100644 index 00000000000..5f54f24e24c --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_candidate.vue @@ -0,0 +1,94 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; +import IncubationAlert from './incubation_alert.vue'; + +export default { + name: 'MlCandidate', + components: { + IncubationAlert, + GlLink, + }, + inject: ['candidate'], + i18n: { + titleLabel: __('Model candidate details'), + infoLabel: __('Info'), + idLabel: __('ID'), + statusLabel: __('Status'), + experimentLabel: __('Experiment'), + artifactsLabel: __('Artifacts'), + parametersLabel: __('Parameters'), + metricsLabel: __('Metrics'), + }, +}; +</script> + +<template> + <div> + <incubation-alert /> + + <h3> + {{ $options.i18n.titleLabel }} + </h3> + + <table class="candidate-details"> + <tbody> + <tr class="divider"></tr> + + <tr> + <td class="gl-text-secondary gl-font-weight-bold">{{ $options.i18n.infoLabel }}</td> + <td class="gl-font-weight-bold">{{ $options.i18n.idLabel }}</td> + <td>{{ candidate.info.iid }}</td> + </tr> + + <tr> + <td></td> + <td class="gl-font-weight-bold">{{ $options.i18n.statusLabel }}</td> + <td>{{ candidate.info.status }}</td> + </tr> + + <tr> + <td></td> + <td class="gl-font-weight-bold">{{ $options.i18n.experimentLabel }}</td> + <td> + <gl-link :href="candidate.info.path_to_experiment">{{ + candidate.info.experiment_name + }}</gl-link> + </td> + </tr> + + <tr v-if="candidate.info.path_to_artifact"> + <td></td> + <td class="gl-font-weight-bold">{{ $options.i18n.artifactsLabel }}</td> + <td> + <gl-link :href="candidate.info.path_to_artifact">{{ + $options.i18n.artifactsLabel + }}</gl-link> + </td> + </tr> + + <tr class="divider"></tr> + + <tr v-for="(param, index) in candidate.params" :key="param.name"> + <td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold"> + {{ $options.i18n.parametersLabel }} + </td> + <td v-else></td> + <td class="gl-font-weight-bold">{{ param.name }}</td> + <td>{{ param.value }}</td> + </tr> + + <tr class="divider"></tr> + + <tr v-for="(metric, index) in candidate.metrics" :key="metric.name"> + <td v-if="index == 0" class="gl-text-secondary gl-font-weight-bold"> + {{ $options.i18n.metricsLabel }} + </td> + <td v-else></td> + <td class="gl-font-weight-bold">{{ metric.name }}</td> + <td>{{ metric.value }}</td> + </tr> + </tbody> + </table> + </div> +</template> diff --git a/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue new file mode 100644 index 00000000000..f8e269d3b57 --- /dev/null +++ b/app/assets/javascripts/ml/experiment_tracking/components/ml_experiment.vue @@ -0,0 +1,59 @@ +<script> +import { GlTable, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; +import IncubationAlert from './incubation_alert.vue'; + +export default { + name: 'MlExperiment', + components: { + GlTable, + GlLink, + IncubationAlert, + }, + inject: ['candidates', 'metricNames', 'paramNames'], + computed: { + fields() { + return [ + ...this.paramNames, + ...this.metricNames, + { key: 'details', label: '' }, + { key: 'artifact', label: '' }, + ]; + }, + }, + i18n: { + titleLabel: __('Experiment candidates'), + emptyStateLabel: __('This experiment has no logged candidates'), + artifactsLabel: __('Artifacts'), + detailsLabel: __('Details'), + }, +}; +</script> + +<template> + <div> + <incubation-alert /> + + <h3> + {{ $options.i18n.titleLabel }} + </h3> + + <gl-table + :fields="fields" + :items="candidates" + :empty-text="$options.i18n.emptyStateLabel" + show-empty + class="gl-mt-0!" + > + <template #cell(artifact)="data"> + <gl-link v-if="data.value" :href="data.value" target="_blank">{{ + $options.i18n.artifactsLabel + }}</gl-link> + </template> + + <template #cell(details)="data"> + <gl-link :href="data.value">{{ $options.i18n.detailsLabel }}</gl-link> + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue index ae079da0b0b..da4c92df711 100644 --- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue +++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue @@ -1,11 +1,11 @@ <script> import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg'; -import { GlSafeHtmlDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { chartHeight } from '../../constants'; export default { directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, data() { return { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index b6ad2d21757..2c185794d17 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -391,11 +391,7 @@ export default { }; </script> <template> - <div - class="prometheus-graphs" - data-qa-selector="prometheus_graphs_content" - data-testid="prometheus-graphs" - > + <div class="prometheus-graphs" data-testid="prometheus-graphs"> <div> <gl-alert v-if="!isDeprecationNoticeDismissed" diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue index 7f8fb3c223d..d67154b7697 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue @@ -146,7 +146,6 @@ export default { <gl-dropdown v-gl-tooltip data-testid="actions-menu" - data-qa-selector="actions_menu_dropdown" right no-caret toggle-class="gl-px-3!" @@ -223,7 +222,6 @@ export default { <gl-dropdown-item v-if="isMenuItemEnabled.editDashboard" :href="selectedDashboard ? selectedDashboard.project_blob_path : null" - data-qa-selector="edit_dashboard_button_enabled" data-testid="edit-dashboard-item-enabled" > {{ $options.i18n.editDashboard }} diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index 90d2498ac19..7bb0d3874d1 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -173,7 +173,6 @@ export default { <div class="gl-mb-3 gl-mr-3 gl-display-flex gl-sm-display-block"> <dashboards-dropdown id="monitor-dashboards-dropdown" - data-qa-selector="dashboards_filter_dropdown" class="flex-grow-1" toggle-class="dropdown-menu-toggle" :default-branch="defaultBranch" @@ -188,7 +187,6 @@ export default { id="monitor-environments-dropdown" ref="monitorEnvironmentsDropdown" class="flex-grow-1" - data-qa-selector="environments_dropdown" data-testid="environments-dropdown" toggle-class="dropdown-menu-toggle" menu-class="monitor-environment-dropdown-menu" @@ -225,7 +223,6 @@ export default { <date-time-picker ref="dateTimePicker" class="flex-grow-1 show-last-dropdown" - data-qa-selector="range_picker_dropdown" :value="selectedTimeRange" :options="$options.timeRanges" :utc="displayUtc" diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 7e7dcef7639..9ad6da35d6b 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -292,11 +292,7 @@ export default { <div v-if="graphDataIsLoading" class="mx-1 mt-1"> <gl-loading-icon size="sm" /> </div> - <div - v-if="isContextualMenuShown" - ref="contextualMenu" - data-qa-selector="prometheus_graph_widgets" - > + <div v-if="isContextualMenuShown" ref="contextualMenu"> <div data-testid="dropdown-wrapper" class="d-flex align-items-center"> <!-- This component should be replaced with a variant developed @@ -310,7 +306,6 @@ export default { :text-sr-only="true" toggle-class="gl-px-3!" no-caret - data-qa-selector="prometheus_widgets_dropdown" right :title="__('More actions')" > @@ -339,7 +334,6 @@ export default { ref="copyChartLink" v-track-event="generateLinkToChartOptions(clipboardText)" :data-clipboard-text="clipboardText" - data-qa-selector="generate_chart_link_menu_item" @click="showToast(clipboardText)" > {{ __('Copy link to chart') }} diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue index 8efea2bfc3e..e8a9c24f5c2 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue @@ -100,7 +100,7 @@ export default { <gl-form-textarea id="panel-yml-input" v-model="yml" - class="gl-h-200! gl-font-monospace! gl-font-size-monospace!" + class="gl-h-200! gl-font-monospace!" /> </gl-form-group> <div class="gl-text-right"> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue index a63008aa382..9ad14b3d52e 100644 --- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue @@ -104,13 +104,7 @@ export default { label-size="sm" label-for="fileName" > - <gl-form-input - id="fileName" - ref="fileName" - v-model="form.fileName" - data-qa-selector="duplicate_dashboard_filename_field" - :required="true" - /> + <gl-form-input id="fileName" ref="fileName" v-model="form.fileName" :required="true" /> </gl-form-group> <gl-form-group :label="__('Branch')" label-size="sm" label-for="branch"> <gl-form-radio-group diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue index 0365fc66331..a67770b93be 100644 --- a/app/assets/javascripts/monitoring/components/group_empty_state.vue +++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue @@ -1,5 +1,6 @@ <script> -import { GlEmptyState, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlEmptyState } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, sprintf } from '~/locale'; import { metricStates } from '../constants'; diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue index 544fe10f26e..55c602db33d 100644 --- a/app/assets/javascripts/monitoring/components/refresh_button.vue +++ b/app/assets/javascripts/monitoring/components/refresh_button.vue @@ -11,8 +11,6 @@ import Visibility from 'visibilityjs'; import { mapActions } from 'vuex'; import { n__, __, s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - const makeInterval = (length = 0, unit = 's') => { const shortLabel = `${length}${unit}`; switch (unit) { @@ -58,7 +56,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagsMixin()], data() { return { refreshInterval: null, @@ -66,12 +63,6 @@ export default { }; }, computed: { - disableMetricDashboardRefreshRate() { - // Can refresh rates impact performance? - // Add "negative" feature flag called `disable_metric_dashboard_refresh_rate` - // See more at: https://gitlab.com/gitlab-org/gitlab/-/issues/229831 - return this.glFeatures.disableMetricDashboardRefreshRate; - }, dropdownText() { return this.refreshInterval?.shortLabel ?? __('Off'); }, @@ -156,12 +147,7 @@ export default { icon="retry" @click="refresh" /> - <gl-dropdown - v-if="!disableMetricDashboardRefreshRate" - v-gl-tooltip - :title="s__('Metrics|Set refresh rate')" - :text="dropdownText" - > + <gl-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText"> <gl-dropdown-item is-check-item :is-checked="refreshInterval === null" diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue index 493d37ce263..971f188e9f3 100644 --- a/app/assets/javascripts/monitoring/components/variables_section.vue +++ b/app/assets/javascripts/monitoring/components/variables_section.vue @@ -37,11 +37,7 @@ export default { }; </script> <template> - <div - ref="variablesSection" - class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section" - data-qa-selector="variables_content" - > + <div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section"> <div v-for="variable in variables" :key="variable.name" class="mb-1 pr-2 d-flex d-sm-block"> <component :is="variableField(variable.type)" @@ -50,7 +46,6 @@ export default { :value="variable.value" :name="variable.name" :options="variable.options" - data-qa-selector="variable_item" @input="refreshDashboard(variable, $event)" /> </div> diff --git a/app/assets/javascripts/monitoring/csv_export.js b/app/assets/javascripts/monitoring/csv_export.js index eaeed4a54d4..7e15b659767 100644 --- a/app/assets/javascripts/monitoring/csv_export.js +++ b/app/assets/javascripts/monitoring/csv_export.js @@ -110,7 +110,7 @@ const csvData = (metricHeaders, metricValues) => { // "If double-quotes are used to enclose fields, then a double-quote // appearing inside a field must be escaped by preceding it with // another double quote." - // https://tools.ietf.org/html/rfc4180#page-2 + // https://www.rfc-editor.org/rfc/rfc4180#page-2 const headers = metricHeaders.map((header) => `"${header.replace(/"/g, '""')}"`); return { diff --git a/app/assets/javascripts/monitoring/requests/index.js b/app/assets/javascripts/monitoring/requests/index.js index 26fedb9c81c..8b65eec051f 100644 --- a/app/assets/javascripts/monitoring/requests/index.js +++ b/app/assets/javascripts/monitoring/requests/index.js @@ -1,13 +1,16 @@ import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; -import statusCodes from '~/lib/utils/http_status'; +import statusCodes, { + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_UNPROCESSABLE_ENTITY, +} from '~/lib/utils/http_status'; import { PROMETHEUS_TIMEOUT } from '../constants'; const cancellableBackOffRequest = (makeRequestCallback) => backOff((next, stop) => { makeRequestCallback() .then((resp) => { - if (resp.status === statusCodes.NO_CONTENT) { + if (resp.status === HTTP_STATUS_NO_CONTENT) { next(); } else { stop(resp); @@ -34,7 +37,7 @@ export const getPrometheusQueryData = (prometheusEndpoint, params, opts) => const { response = {} } = error; if ( response.status === statusCodes.BAD_REQUEST || - response.status === statusCodes.UNPROCESSABLE_ENTITY || + response.status === HTTP_STATUS_UNPROCESSABLE_ENTITY || response.status === statusCodes.SERVICE_UNAVAILABLE ) { const { data } = response; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index fd8749625da..0d849e1a2d8 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -39,7 +39,6 @@ export const stateAndPropsFromDataset = (dataset = {}) => { // HTML attributes are always strings, parse other types. dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics); dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable); - dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable); return { initState: { diff --git a/app/assets/javascripts/mr_notes/discussion_counter.js b/app/assets/javascripts/mr_notes/discussion_counter.js new file mode 100644 index 00000000000..0bb63a7c0f9 --- /dev/null +++ b/app/assets/javascripts/mr_notes/discussion_counter.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import DiscussionCounter from '~/notes/components/discussion_counter.vue'; +import store from '~/mr_notes/stores'; + +export function initDiscussionCounter() { + const el = document.getElementById('js-vue-discussion-counter'); + + if (el) { + const { blocksMerge } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'DiscussionCounter', + components: { + DiscussionCounter, + }, + store, + render(createElement) { + return createElement('discussion-counter', { + props: { + blocksMerge: blocksMerge === 'true', + }, + }); + }, + }); + } +} diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js index c32a1f4c2ac..a202923bd21 100644 --- a/app/assets/javascripts/mr_notes/index.js +++ b/app/assets/javascripts/mr_notes/index.js @@ -1,12 +1,8 @@ -import Vue from 'vue'; -import store from '~/mr_notes/stores'; import initCherryPickCommitModal from '~/projects/commit/init_cherry_pick_commit_modal'; import initRevertCommitModal from '~/projects/commit/init_revert_commit_modal'; -import initDiffsApp from '../diffs'; -import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; +import { initMrStateLazyLoad } from '~/mr_notes/init'; import MergeRequest from '../merge_request'; -import DiscussionCounter from '../notes/components/discussion_counter.vue'; -import initNotesApp from './init_notes'; +import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; export default function initMrNotes() { resetServiceWorkersPublicPath(); @@ -17,36 +13,10 @@ export default function initMrNotes() { action: mrShowNode.dataset.mrAction, }); - initDiffsApp(store); - initNotesApp(); + initMrStateLazyLoad(); document.addEventListener('merged:UpdateActions', () => { initRevertCommitModal('i_code_review_post_merge_submit_revert_modal'); initCherryPickCommitModal('i_code_review_post_merge_submit_cherry_pick_modal'); }); - - requestIdleCallback(() => { - const el = document.getElementById('js-vue-discussion-counter'); - - if (el) { - const { blocksMerge } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - name: 'DiscussionCounter', - components: { - DiscussionCounter, - }, - store, - render(createElement) { - return createElement('discussion-counter', { - props: { - blocksMerge: blocksMerge === 'true', - }, - }); - }, - }); - } - }); } diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js new file mode 100644 index 00000000000..aab3c41b4cf --- /dev/null +++ b/app/assets/javascripts/mr_notes/init.js @@ -0,0 +1,52 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; +import store from '~/mr_notes/stores'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import eventHub from '~/notes/event_hub'; +import { initReviewBar } from '~/batch_comments'; +import { initDiscussionCounter } from '~/mr_notes/discussion_counter'; +import { initOverviewTabCounter } from '~/mr_notes/init_count'; + +function setupMrNotesState(notesDataset) { + const noteableData = JSON.parse(notesDataset.noteableData); + noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; + noteableData.discussion_locked = parseBoolean(notesDataset.isLocked); + const notesData = JSON.parse(notesDataset.notesData); + const currentUserData = JSON.parse(notesDataset.currentUserData); + const endpoints = { metadata: notesDataset.endpointMetadata }; + + store.dispatch('setNotesData', notesData); + store.dispatch('setNoteableData', noteableData); + store.dispatch('setUserData', currentUserData); + store.dispatch('setTargetNoteHash', getLocationHash()); + store.dispatch('setEndpoints', endpoints); + eventHub.$once('fetchNotesData', () => store.dispatch('fetchNotes')); +} + +export function initMrStateLazyLoad() { + store.dispatch('setActiveTab', window.mrTabs.getCurrentAction()); + window.mrTabs.eventHub.$on('MergeRequestTabChange', (value) => + store.dispatch('setActiveTab', value), + ); + + const discussionsEl = document.getElementById('js-vue-mr-discussions'); + const notesDataset = discussionsEl.dataset; + let stop = () => {}; + stop = store.watch( + (state) => state.page.activeTab, + (activeTab) => { + // prevent loading MR state on commits and pipelines pages + // this is due to them having a shared controller with the Overview page + if (['diffs', 'show'].includes(activeTab)) { + setupMrNotesState(notesDataset); + requestIdleCallback(() => { + initReviewBar(); + initOverviewTabCounter(); + initDiscussionCounter(); + }); + stop(); + } + }, + { immediate: true }, + ); +} diff --git a/app/assets/javascripts/mr_notes/init_count.js b/app/assets/javascripts/mr_notes/init_count.js new file mode 100644 index 00000000000..3e924ebd9d5 --- /dev/null +++ b/app/assets/javascripts/mr_notes/init_count.js @@ -0,0 +1,13 @@ +import store from '~/mr_notes/stores'; + +export function initOverviewTabCounter() { + const discussionsCount = document.querySelector('.js-discussions-count'); + store.watch( + (state, getters) => getters.discussionTabCounter, + (val) => { + if (typeof val !== 'undefined') { + discussionsCount.textContent = val; + } + }, + ); +} diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 3a67e7925c3..e10605609b0 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -1,8 +1,8 @@ -import $ from 'jquery'; import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; import store from '~/mr_notes/stores'; +import notesEventHub from '~/notes/event_hub'; import discussionNavigator from '../notes/components/discussion_navigator.vue'; import NotesApp from '../notes/components/notes_app.vue'; import { getNotesFilterData } from '../notes/utils/get_notes_filter_data'; @@ -36,13 +36,12 @@ export default () => { endpoints: { metadata: notesDataset.endpointMetadata, }, - currentUserData: JSON.parse(notesDataset.currentUserData), notesData: JSON.parse(notesDataset.notesData), helpPagePath: notesDataset.helpPagePath, }; }, computed: { - ...mapGetters(['discussionTabCounter']), + ...mapGetters(['isNotesFetched']), ...mapState({ activeTab: (state) => state.page.activeTab, }), @@ -51,15 +50,6 @@ export default () => { }, }, watch: { - discussionTabCounter() { - if (window.gon?.features?.paginatedMrDiscussions) { - if (this.$store.state.notes.doneFetchingBatchDiscussions) { - this.updateDiscussionTabCounter(); - } - } else { - this.updateDiscussionTabCounter(); - } - }, isShowTabActive: { handler(newVal) { if (newVal) { @@ -70,25 +60,16 @@ export default () => { }, }, created() { - this.setActiveTab(window.mrTabs.getCurrentAction()); this.setEndpoints(this.endpoints); + if (!this.isNotesFetched) { + notesEventHub.$emit('fetchNotesData'); + } + this.fetchMrMetadata(); }, - mounted() { - this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); - $(document).on('visibilitychange', this.updateDiscussionTabCounter); - window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab); - }, - beforeDestroy() { - $(document).off('visibilitychange', this.updateDiscussionTabCounter); - window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab); - }, methods: { - ...mapActions(['setActiveTab', 'setEndpoints', 'fetchMrMetadata']), - updateDiscussionTabCounter() { - this.notesCountBadge.text(this.discussionTabCounter); - }, + ...mapActions(['setEndpoints', 'fetchMrMetadata']), }, render(createElement) { // NOTE: Even though `discussionNavigator` is added to the `notes-app`, diff --git a/app/assets/javascripts/nav/components/new_nav_toggle.vue b/app/assets/javascripts/nav/components/new_nav_toggle.vue new file mode 100644 index 00000000000..ef59140115d --- /dev/null +++ b/app/assets/javascripts/nav/components/new_nav_toggle.vue @@ -0,0 +1,71 @@ +<script> +import { GlBadge, GlToggle } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; + +export default { + i18n: { + badgeLabel: s__('NorthstarNavigation|Alpha'), + sectionTitle: s__('NorthstarNavigation|Navigation redesign'), + toggleMenuItemLabel: s__('NorthstarNavigation|New navigation'), + toggleLabel: s__('NorthstarNavigation|Toggle new navigation'), + updateError: s__( + 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.', + ), + }, + components: { + GlBadge, + GlToggle, + }, + props: { + enabled: { + type: Boolean, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + }, + data() { + return { + isEnabled: this.enabled, + }; + }, + methods: { + async toggleNav() { + try { + await axios.put(this.endpoint, { user: { use_new_navigation: !this.enabled } }); + window.location.reload(); + } catch (error) { + createAlert({ + message: this.$options.i18n.updateError, + error, + }); + } + }, + }, +}; +</script> + +<template> + <li> + <div + class="gl-px-4 gl-py-2 gl-display-flex gl-justify-content-space-between gl-align-items-center" + > + <b>{{ $options.i18n.sectionTitle }}</b> + <gl-badge>{{ $options.i18n.badgeLabel }}</gl-badge> + </div> + + <div class="menu-item gl-display-flex! gl-justify-content-space-between gl-align-items-center"> + {{ $options.i18n.toggleMenuItemLabel }} + <gl-toggle + v-model="isEnabled" + :label="$options.i18n.toggleLabel" + label-position="hidden" + @change="toggleNav" + /> + </div> + </li> +</template> diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index ef36e58374c..a7c2e572037 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,15 +1,9 @@ /* eslint-disable func-names, no-return-assign, @gitlab/require-i18n-strings */ - -import $ from 'jquery'; -import RefSelectDropdown from './ref_select_dropdown'; - export default class NewBranchForm { - constructor(form, availableRefs) { + constructor(form) { this.validate = this.validate.bind(this); this.branchNameError = form.querySelector('.js-branch-name-error'); this.name = form.querySelector('.js-branch-name'); - this.ref = form.querySelector('#ref'); - new RefSelectDropdown($('.js-branch-select'), availableRefs); // eslint-disable-line no-new this.setupRestrictions(); this.addBinding(); this.init(); diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 9aa6abd9d8c..2caa93c3c93 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,7 +1,7 @@ <script> import katex from 'katex'; import { marked } from 'marked'; -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { sanitize } from '~/lib/dompurify'; import { hasContent, markdownConfig } from '~/lib/utils/text_utility'; import Prompt from './prompt.vue'; diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index 5437a607e8a..74a5dd3806d 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -1,14 +1,10 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; import Prompt from '../prompt.vue'; export default { components: { Prompt, }, - directives: { - SafeHtml: GlSafeHtmlDirective, - }, props: { count: { type: Number, @@ -28,12 +24,6 @@ export default { return this.index === 0; }, }, - safeHtmlConfig: { - ADD_TAGS: ['use'], // to support icon SVGs - FORBID_TAGS: ['style'], - FORBID_ATTR: ['style'], - ALLOW_DATA_ATTR: false, - }, }; </script> diff --git a/app/assets/javascripts/notebook/cells/output/latex.vue b/app/assets/javascripts/notebook/cells/output/latex.vue index d0ed963b55d..55f97fee3dc 100644 --- a/app/assets/javascripts/notebook/cells/output/latex.vue +++ b/app/assets/javascripts/notebook/cells/output/latex.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import 'mathjax/es5/tex-svg'; import Prompt from '../prompt.vue'; diff --git a/app/assets/javascripts/notebook/cells/output/markdown.vue b/app/assets/javascripts/notebook/cells/output/markdown.vue index 5da057dee72..ad74e28ac74 100644 --- a/app/assets/javascripts/notebook/cells/output/markdown.vue +++ b/app/assets/javascripts/notebook/cells/output/markdown.vue @@ -1,5 +1,4 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import Prompt from '../prompt.vue'; import Markdown from '../markdown.vue'; @@ -9,9 +8,6 @@ export default { Prompt, Markdown, }, - directives: { - SafeHtml, - }, props: { count: { type: Number, diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 0d7ff022f8f..2ccb9a0b514 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -7,7 +7,7 @@ import Autosave from '~/autosave'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; import { badgeState } from '~/issuable/components/status_box.vue'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { capitalizeFirstCharacter, convertToCamelCase, @@ -28,8 +28,6 @@ import CommentTypeDropdown from './comment_type_dropdown.vue'; import DiscussionLockedWidget from './discussion_locked_widget.vue'; import NoteSignedOutWidget from './note_signed_out_widget.vue'; -const { UNPROCESSABLE_ENTITY } = httpStatusCodes; - export default { name: 'CommentForm', i18n: COMMENT_FORM, @@ -198,7 +196,7 @@ export default { 'toggleIssueLocalState', ]), handleSaveError({ data, status }) { - if (status === UNPROCESSABLE_ENTITY && data.errors?.commands_only?.length) { + if (status === HTTP_STATUS_UNPROCESSABLE_ENTITY && data.errors?.commands_only?.length) { this.errors = data.errors.commands_only; } else { this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK]; diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue index cf6474270a2..f949142d90a 100644 --- a/app/assets/javascripts/notes/components/diff_discussion_header.vue +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -1,7 +1,8 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, __, sprintf } from '~/locale'; import NoteEditedText from './note_edited_text.vue'; diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index 3bdf8349a12..aabdc1c99b6 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -1,6 +1,7 @@ <script> -import { GlSkeletonLoader, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlSkeletonLoader } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { getDiffMode } from '~/diffs/store/utils'; diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 930876e90b1..c15c11ed9db 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -19,6 +19,7 @@ export default { editCommentLabel: __('Edit comment'), deleteCommentLabel: __('Delete comment'), moreActionsLabel: __('More actions'), + reportAbuse: __('Report abuse to administrator'), }, name: 'NoteActions', components: { @@ -362,7 +363,7 @@ export default { <!-- eslint-enable @gitlab/vue-no-data-toggle --> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <gl-dropdown-item v-if="canReportAsAbuse" :href="reportAbusePath"> - {{ __('Report abuse to admin') }} + {{ $options.i18n.reportAbuse }} </gl-dropdown-item> <gl-dropdown-item v-if="noteUrl" diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 82c125b79ce..20cf21cd1b6 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -1,12 +1,10 @@ <script> -import $ from 'jquery'; -import { GlSafeHtmlDirective } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; - +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; -import '~/behaviors/markdown/render_gfm'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import autosave from '../mixins/autosave'; import NoteAttachment from './note_attachment.vue'; import NoteAwardsList from './note_awards_list.vue'; @@ -22,7 +20,7 @@ export default { Suggestions, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [autosave], props: { @@ -122,7 +120,7 @@ export default { 'removeSuggestionInfoFromBatch', ]), renderGFM() { - $(this.$refs['note-body']).renderGFM(); + renderGFM(this.$refs['note-body']); }, handleFormUpdate(noteText, parentElement, callback, resolveDiscussion) { this.$emit('handleFormUpdate', { noteText, parentElement, callback, resolveDiscussion }); diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 63c7010983e..36f7d720e48 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,17 +1,10 @@ <script> -import { - GlIcon, - GlBadge, - GlLoadingIcon, - GlTooltipDirective, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlIcon, GlBadge, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __, s__ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, components: { TimeAgoTooltip, GitlabTeamMemberBadge: () => @@ -21,7 +14,6 @@ export default { GlLoadingIcon, }, directives: { - SafeHtml, GlTooltip: GlTooltipDirective, }, props: { diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue index 593933016e1..94636b3e47b 100644 --- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue +++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, sprintf } from '~/locale'; export default { diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index b668d6ec182..ff801cdccea 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -235,7 +235,7 @@ export default { this.saveNote(replyData) .then((res) => { - if (res.hasFlash !== true) { + if (res.hasAlert !== true) { this.isReplying = false; clearDraft(this.autosaveKey); } @@ -307,7 +307,7 @@ export default { :draft="draftForDiscussion(discussion.reply_id)" :line="line" /> - <div + <li v-else-if="canShowReplyActions && showReplies" :class="{ 'is-replying': isReplying }" class="discussion-reply-holder gl-border-t-0! clearfix" @@ -334,7 +334,7 @@ export default { @cancelForm="cancelReplyForm" /> <note-signed-out-widget v-if="!isLoggedIn" /> - </div> + </li> </template> </discussion-notes> </component> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 8ce0c2f8648..826e7e5a3d0 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -1,16 +1,18 @@ <script> -import { GlSprintf, GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { GlSprintf, GlAvatarLink, GlAvatar } from '@gitlab/ui'; import $ from 'jquery'; import { escape, isEmpty } from 'lodash'; import { mapGetters, mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; import { createAlert } from '~/flash'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_GONE } from '~/lib/utils/http_status'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { __, s__, sprintf } from '~/locale'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import eventHub from '../event_hub'; import noteable from '../mixins/noteable'; import resolvable from '../mixins/resolvable'; @@ -286,7 +288,7 @@ export default { this.isEditing = false; this.isRequesting = false; this.oldContent = null; - $(this.$refs.noteBody.$el).renderGFM(); + renderGFM(this.$refs.noteBody.$el); this.$refs.noteBody.resetAutoSave(); this.$emit('updateSuccess'); }, @@ -336,7 +338,7 @@ export default { callback(); }) .catch((response) => { - if (response.status === httpStatusCodes.GONE) { + if (response.status === HTTP_STATUS_GONE) { this.removeNote(this.note); this.updateSuccess(); callback(); @@ -515,6 +517,9 @@ export default { @handleFormUpdate="formUpdateHandler" @cancelForm="formCancelHandler" /> + <div class="timeline-discussion-body-footer"> + <slot name="after-note-body"></slot> + </div> </div> </div> </timeline-entry-item> diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 7bb1a1a1bfe..fcf37217902 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,13 +1,11 @@ <script> import { mapGetters, mapActions } from 'vuex'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; -import { createAlert } from '~/flash'; -import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DraftNote from '~/batch_comments/components/draft_note.vue'; -import { getLocationHash, doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { getLocationHash } from '~/lib/utils/url_utility'; import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue'; import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue'; @@ -57,11 +55,6 @@ export default { default: undefined, required: false, }, - userData: { - type: Object, - required: false, - default: () => ({}), - }, shouldShow: { type: Boolean, required: false, @@ -90,16 +83,12 @@ export default { 'commentsDisabled', 'getNoteableData', 'userCanReply', - 'discussionTabCounter', 'sortDirection', 'timelineEnabled', ]), sortDirDesc() { return this.sortDirection === constants.DESC; }, - discussionTabCounterText() { - return this.isLoading ? '' : this.discussionTabCounter; - }, noteableType() { return this.noteableData.noteableType; }, @@ -147,11 +136,6 @@ export default { this.renderSkeleton = !this.shouldShow; }); }, - discussionTabCounterText(val) { - if (this.discussionsCount) { - this.discussionsCount.textContent = val; - } - }, isAppReady: { handler(isReady) { if (!isReady) return; @@ -162,20 +146,7 @@ export default { immediate: true, }, }, - created() { - this.discussionsCount = document.querySelector('.js-discussions-count'); - - this.setNotesData(this.notesData); - this.setNoteableData(this.noteableData); - this.setUserData(this.userData); - this.setTargetNoteHash(getLocationHash()); - eventHub.$once('fetchNotesData', this.fetchNotes); - }, mounted() { - if (this.shouldShow) { - this.fetchNotes(); - } - const { parentElement } = this.$el; if (parentElement && parentElement.classList.contains('js-vue-notes-event')) { parentElement.addEventListener('toggleAward', (event) => { @@ -200,23 +171,16 @@ export default { }, methods: { ...mapActions([ - 'setFetchingState', - 'setLoadingState', - 'fetchDiscussions', - 'poll', 'toggleAward', - 'setNotesData', - 'setNoteableData', - 'setUserData', 'setLastFetchedAt', 'setTargetNoteHash', 'toggleDiscussion', - 'setNotesFetchedState', 'expandDiscussion', 'startTaskList', 'convertToDiscussion', 'stopPolling', 'setConfidentiality', + 'fetchNotes', ]), discussionIsIndividualNoteAndNotConverted(discussion) { return discussion.individual_note && !this.convertedDisscussionIds.includes(discussion.id); @@ -228,37 +192,6 @@ export default { this.setTargetNoteHash(getLocationHash()); } }, - fetchNotes() { - if (this.isFetching) return null; - - this.setFetchingState(true); - - return this.fetchDiscussions(this.getFetchDiscussionsConfig()) - .then(this.initPolling) - .then(() => { - this.setLoadingState(false); - this.setNotesFetchedState(true); - eventHub.$emit('fetchedNotesData'); - this.setFetchingState(false); - }) - .catch(() => { - this.setLoadingState(false); - this.setNotesFetchedState(true); - createAlert({ - message: __('Something went wrong while fetching comments. Please try again.'), - }); - }); - }, - initPolling() { - if (this.isPollingInitialized) { - return; - } - - this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); - - this.poll(); - this.isPollingInitialized = true; - }, checkLocationHash() { const hash = getLocationHash(); const noteId = hash && hash.replace(/^note_/, ''); @@ -278,24 +211,6 @@ export default { .then(this.$nextTick) .then(() => eventHub.$emit('startReplying', discussionId)); }, - getFetchDiscussionsConfig() { - const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') }; - - const currentFilter = - this.getNotesDataByProp('notesFilter') || constants.DISCUSSION_FILTERS_DEFAULT_VALUE; - - if ( - doesHashExistInUrl(constants.NOTE_UNDERSCORE) && - currentFilter !== constants.DISCUSSION_FILTERS_DEFAULT_VALUE - ) { - return { - ...defaultConfig, - filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, - persistFilter: false, - }; - } - return defaultConfig; - }, }, systemNote: constants.SYSTEM_NOTE, }; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index defcb0533b7..95263e666b2 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { getLocationHash } from '~/lib/utils/url_utility'; import NotesApp from './components/notes_app.vue'; import { store } from './stores'; import { getNotesFilterData } from './utils/get_notes_filter_data'; @@ -13,6 +14,34 @@ export default () => { const notesFilterProps = getNotesFilterData(el); const showTimelineViewToggle = parseBoolean(el.dataset.showTimelineViewToggle); + const notesDataset = el.dataset; + const parsedUserData = JSON.parse(notesDataset.currentUserData); + const noteableData = JSON.parse(notesDataset.noteableData); + let currentUserData = {}; + + noteableData.noteableType = notesDataset.noteableType; + noteableData.targetType = notesDataset.targetType; + noteableData.discussion_locked = parseBoolean(noteableData.discussion_locked); + + if (parsedUserData) { + currentUserData = { + id: parsedUserData.id, + name: parsedUserData.name, + username: parsedUserData.username, + avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, + path: parsedUserData.path, + can_add_timeline_events: parseBoolean(notesDataset.canAddTimelineEvents), + }; + } + + const notesData = JSON.parse(notesDataset.notesData); + + store.dispatch('setNotesData', notesData); + store.dispatch('setNoteableData', noteableData); + store.dispatch('setUserData', currentUserData); + store.dispatch('setTargetNoteHash', getLocationHash()); + store.dispatch('fetchNotes'); + // eslint-disable-next-line no-new new Vue({ el, @@ -25,30 +54,6 @@ export default () => { showTimelineViewToggle, }, data() { - const notesDataset = el.dataset; - const parsedUserData = JSON.parse(notesDataset.currentUserData); - const noteableData = JSON.parse(notesDataset.noteableData); - let currentUserData = {}; - - noteableData.noteableType = notesDataset.noteableType; - noteableData.targetType = notesDataset.targetType; - if (noteableData.discussion_locked === null) { - // discussion_locked has never been set for this issuable. - // set to `false` for safety. - noteableData.discussion_locked = false; - } - - if (parsedUserData) { - currentUserData = { - id: parsedUserData.id, - name: parsedUserData.name, - username: parsedUserData.username, - avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, - path: parsedUserData.path, - can_add_timeline_events: parseBoolean(notesDataset.canAddTimelineEvents), - }; - } - return { noteableData, currentUserData, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index fcef26d720c..d290a8ccb84 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -2,14 +2,14 @@ import $ from 'jquery'; import Visibility from 'visibilityjs'; import Vue from 'vue'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; -import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; -import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; +import updateIssueLockMutation from '~/sidebar/queries/update_issue_lock.mutation.graphql'; +import updateMergeRequestLockMutation from '~/sidebar/queries/update_merge_request_lock.mutation.graphql'; import loadAwardsHandler from '~/awards_handler'; import { isInViewport, scrollToElement, isInMRPage } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; @@ -114,6 +114,39 @@ export const fetchDiscussions = ( }); }; +export const fetchNotes = ({ dispatch, getters }) => { + if (getters.isFetching) return null; + + dispatch('setFetchingState', true); + + return dispatch('fetchDiscussions', getters.getFetchDiscussionsConfig) + .then(() => dispatch('initPolling')) + .then(() => { + dispatch('setLoadingState', false); + dispatch('setNotesFetchedState', true); + notesEventHub.$emit('fetchedNotesData'); + dispatch('setFetchingState', false); + }) + .catch(() => { + dispatch('setLoadingState', false); + dispatch('setNotesFetchedState', true); + createAlert({ + message: __('Something went wrong while fetching comments. Please try again.'), + }); + }); +}; + +export const initPolling = ({ state, dispatch, getters, commit }) => { + if (state.isPollingInitialized) { + return; + } + + dispatch('setLastFetchedAt', getters.getNotesDataByProp('lastFetchedAt')); + + dispatch('poll'); + commit(types.SET_IS_POLLING_INITIALIZED, true); +}; + export const fetchDiscussionsBatch = ({ commit, dispatch }, { path, config, cursor, perPage }) => { const params = { ...config?.params, per_page: perPage }; @@ -270,7 +303,7 @@ export const promoteCommentToTimelineEvent = ( errorObj = error; } - createFlash({ + createAlert({ message, captureError, error: errorObj, @@ -465,9 +498,9 @@ export const saveNote = ({ commit, dispatch }, noteData) => { $('.js-gfm-input').trigger('clear-commands-cache.atwho'); - createFlash({ + createAlert({ message: message || __('Commands applied'), - type: 'notice', + variant: VARIANT_INFO, parent: noteData.flashContainer, }); } @@ -490,7 +523,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => { awardsHandler.scrollToAwards(); }) .catch(() => { - createFlash({ + createAlert({ message: __('Something went wrong while adding your award. Please try again.'), parent: noteData.flashContainer, }); @@ -529,11 +562,11 @@ export const saveNote = ({ commit, dispatch }, noteData) => { const errorMsg = sprintf(__('Your comment could not be submitted because %{error}'), { error: base[0].toLowerCase(), }); - createFlash({ + createAlert({ message: errorMsg, parent: noteData.flashContainer, }); - return { ...data, hasFlash: true }; + return { ...data, hasAlert: true }; } } @@ -580,7 +613,7 @@ const getFetchDataParams = (state) => { export const poll = ({ commit, state, getters, dispatch }) => { const notePollOccurrenceTracking = create(); - let flashContainer; + let alert; notePollOccurrenceTracking.handle(1, () => { // Since polling halts internally after 1 failure, we manually try one more time @@ -588,7 +621,7 @@ export const poll = ({ commit, state, getters, dispatch }) => { }); notePollOccurrenceTracking.handle(2, () => { // On the second failure in a row, show the alert and try one more time (hoping to succeed and clear the error) - flashContainer = createFlash({ + alert = createAlert({ message: __('Something went wrong while fetching latest comments.'), }); setTimeout(() => eTagPoll.restart(), NOTES_POLLING_INTERVAL); @@ -608,7 +641,7 @@ export const poll = ({ commit, state, getters, dispatch }) => { if (notePollOccurrenceTracking.count) { notePollOccurrenceTracking.reset(); } - flashContainer?.close(); + alert?.dismiss(); }, errorCallback: () => notePollOccurrenceTracking.occur(), }); @@ -681,7 +714,7 @@ export const filterDiscussion = ({ commit, dispatch }, { path, filter, persistFi .catch(() => { dispatch('setLoadingState', false); dispatch('setNotesFetchedState', true); - createFlash({ + createAlert({ message: __('Something went wrong while fetching comments. Please try again.'), }); }); @@ -726,7 +759,7 @@ export const submitSuggestion = ( const flashMessage = errorMessage || defaultMessage; - createFlash({ + createAlert({ message: flashMessage, parent: flashContainer, }); @@ -762,7 +795,7 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { message, fl const flashMessage = errorMessage || defaultMessage; - createFlash({ + createAlert({ message: flashMessage, parent: flashContainer, }); @@ -804,7 +837,7 @@ export const fetchDescriptionVersion = ({ dispatch }, { endpoint, startingVersio }) .catch((error) => { dispatch('receiveDescriptionVersionError', error); - createFlash({ + createAlert({ message: __('Something went wrong while fetching description changes. Please try again.'), }); }); @@ -838,7 +871,7 @@ export const softDeleteDescriptionVersion = ( }) .catch((error) => { dispatch('receiveDeleteDescriptionVersionError', error); - createFlash({ + createAlert({ message: __('Something went wrong while deleting description changes. Please try again.'), }); diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 5ad7a811726..f6373f24b74 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -2,6 +2,7 @@ import { flattenDeep, clone } from 'lodash'; import { match } from '~/diffs/utils/diff_file'; import { badgeState } from '~/issuable/components/status_box.vue'; import { isInMRPage } from '~/lib/utils/common_utils'; +import { doesHashExistInUrl } from '~/lib/utils/url_utility'; import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; @@ -314,3 +315,22 @@ export const getSuggestionsFilePaths = (state) => () => return acc; }, []); + +export const getFetchDiscussionsConfig = (state, getters) => { + const defaultConfig = { path: getters.getNotesDataByProp('discussionsPath') }; + + const currentFilter = + getters.getNotesDataByProp('notesFilter') || constants.DISCUSSION_FILTERS_DEFAULT_VALUE; + + if ( + doesHashExistInUrl(constants.NOTE_UNDERSCORE) && + currentFilter !== constants.DISCUSSION_FILTERS_DEFAULT_VALUE + ) { + return { + ...defaultConfig, + filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE, + persistFilter: false, + }; + } + return defaultConfig; +}; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index 7ba1f470b05..81c4c42a49a 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -50,6 +50,7 @@ export default () => ({ descriptionVersions: {}, isTimelineEnabled: false, isFetching: false, + isPollingInitialized: false, }, actions, getters, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 42df6bc0980..bc1d5b5bba4 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -27,6 +27,7 @@ export const CLEAR_SUGGESTION_BATCH = 'CLEAR_SUGGESTION_BATCH'; export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION'; export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION'; export const UPDATE_ASSIGNEES = 'UPDATE_ASSIGNEES'; +export const SET_IS_POLLING_INITIALIZED = 'SET_IS_POLLING_INITIALIZED'; // DISCUSSION export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 83c15c12eac..5d532b68f1b 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -428,4 +428,7 @@ export default { [types.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS](state, value) { state.isPromoteCommentToTimelineEventInProgress = value; }, + [types.SET_IS_POLLING_INITIALIZED](state, value) { + state.isPollingInitialized = value; + }, }; diff --git a/app/assets/javascripts/observability/components/observability_app.vue b/app/assets/javascripts/observability/components/observability_app.vue index 4f5e27be46f..33d23ea043b 100644 --- a/app/assets/javascripts/observability/components/observability_app.vue +++ b/app/assets/javascripts/observability/components/observability_app.vue @@ -1,21 +1,69 @@ <script> +import { darkModeEnabled } from '~/lib/utils/color_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; + +import { MESSAGE_EVENT_TYPE, OBSERVABILITY_ROUTES, SKELETON_VARIANT } from '../constants'; +import ObservabilitySkeleton from './skeleton/index.vue'; + export default { + components: { + ObservabilitySkeleton, + }, props: { observabilityIframeSrc: { type: String, required: true, }, }, + computed: { + iframeSrcWithParams() { + return setUrlParams( + { theme: darkModeEnabled() ? 'dark' : 'light', username: gon?.current_username }, + this.observabilityIframeSrc, + ); + }, + getSkeletonVariant() { + switch (this.$route.path) { + case OBSERVABILITY_ROUTES.DASHBOARDS: + return SKELETON_VARIANT.DASHBOARDS; + case OBSERVABILITY_ROUTES.EXPLORE: + return SKELETON_VARIANT.EXPLORE; + case OBSERVABILITY_ROUTES.MANAGE: + return SKELETON_VARIANT.MANAGE; + default: + return SKELETON_VARIANT.DASHBOARDS; + } + }, + }, mounted() { window.addEventListener('message', this.messageHandler); }, + destroyed() { + window.removeEventListener('message', this.messageHandler); + }, methods: { messageHandler(e) { const isExpectedOrigin = e.origin === new URL(this.observabilityIframeSrc)?.origin; + if (!isExpectedOrigin) return; - const isNewObservabilityPath = this.$route?.query?.observability_path !== e.data?.url; + const { + data: { type, payload }, + } = e; + switch (type) { + case MESSAGE_EVENT_TYPE.GOUI_LOADED: + this.$refs.iframeSkeleton.handleSkeleton(); + break; + case MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE: + this.routeUpdateHandler(payload); + break; + default: + break; + } + }, + routeUpdateHandler(payload) { + const isNewObservabilityPath = this.$route?.query?.observability_path !== payload?.url; - const shouldNotHandleMessage = !isExpectedOrigin || !e.data.url || !isNewObservabilityPath; + const shouldNotHandleMessage = !payload.url || !isNewObservabilityPath; if (shouldNotHandleMessage) { return; @@ -24,7 +72,7 @@ export default { // this will update the `observability_path` query param on each route change inside Observability UI this.$router.replace({ name: this.$route.pathname, - query: { ...this.$route.query, observability_path: e.data.url }, + query: { ...this.$route.query, observability_path: payload.url }, }); }, }, @@ -32,11 +80,14 @@ export default { </script> <template> - <iframe - id="observability-ui-iframe" - data-testid="observability-ui-iframe" - frameborder="0" - height="100%" - :src="observabilityIframeSrc" - ></iframe> + <observability-skeleton ref="iframeSkeleton" :variant="getSkeletonVariant"> + <iframe + id="observability-ui-iframe" + data-testid="observability-ui-iframe" + frameborder="0" + height="100%" + :src="iframeSrcWithParams" + sandbox="allow-same-origin allow-forms allow-scripts" + ></iframe> + </observability-skeleton> </template> diff --git a/app/assets/javascripts/observability/components/skeleton/dashboards.vue b/app/assets/javascripts/observability/components/skeleton/dashboards.vue new file mode 100644 index 00000000000..8b106407953 --- /dev/null +++ b/app/assets/javascripts/observability/components/skeleton/dashboards.vue @@ -0,0 +1,29 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> +<template> + <gl-skeleton-loader :height="200"> + <!-- Top left --> + <rect y="2" width="10" height="8" /> + <rect y="2" x="15" width="15" height="8" /> + <rect y="2" x="35" width="15" height="8" /> + + <!-- Top right --> + <rect y="2" x="354" width="10" height="8" /> + <rect y="2" x="366" width="10" height="8" /> + <rect y="2" x="378" width="10" height="8" /> + <rect y="2" x="390" width="10" height="8" /> + + <!-- Middle header --> + <rect y="15" width="400" height="30" rx="2" ry="2" /> + + <!-- Dashboard container --> + <rect y="50" width="200" height="100" rx="2" ry="2" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/observability/components/skeleton/explore.vue b/app/assets/javascripts/observability/components/skeleton/explore.vue new file mode 100644 index 00000000000..1fcbd4fb1cb --- /dev/null +++ b/app/assets/javascripts/observability/components/skeleton/explore.vue @@ -0,0 +1,27 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> +<template> + <gl-skeleton-loader :height="200"> + <!-- Top left --> + <circle y="2" cx="6" cy="6" r="4" /> + <rect y="2" x="15" width="15" height="8" /> + <rect y="2" x="35" width="40" height="8" /> + + <!-- Top right --> + + <rect y="2" x="263" width="13" height="8" /> + <rect y="2" x="278" width="8" height="8" /> + <rect y="2" x="288" width="50" height="8" /> + <rect y="2" x="340" width="18" height="8" /> + <rect y="2" x="360" width="30" height="8" /> + + <rect y="15" width="400" height="30" rx="2" ry="2" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/skeleton/index.vue new file mode 100644 index 00000000000..1e2671c8166 --- /dev/null +++ b/app/assets/javascripts/observability/components/skeleton/index.vue @@ -0,0 +1,89 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { SKELETON_VARIANT } from '../../constants'; +import DashboardsSkeleton from './dashboards.vue'; +import ExploreSkeleton from './explore.vue'; +import ManageSkeleton from './manage.vue'; + +export default { + SKELETON_VARIANT, + components: { + GlSkeletonLoader, + DashboardsSkeleton, + ExploreSkeleton, + ManageSkeleton, + }, + props: { + variant: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + loading: null, + timerId: null, + }; + }, + mounted() { + this.timerId = setTimeout(() => { + /** + * If observability UI is not loaded then this.loading would be null + * we will show skeleton in that case + */ + if (this.loading !== false) { + this.showSkeleton(); + } + }, 500); + }, + methods: { + handleSkeleton() { + if (this.loading === null) { + /** + * If observability UI content loads with in 500ms + * do not show skeleton. + */ + clearTimeout(this.timerId); + return; + } + + /** + * If observability UI content loads after 500ms + * wait for 400ms to hide skeleton. + * This is mostly to avoid the flashing effect If content loads imediately after skeleton + */ + setTimeout(this.hideSkeleton, 400); + }, + hideSkeleton() { + this.loading = false; + }, + showSkeleton() { + this.loading = true; + }, + }, +}; +</script> +<template> + <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"> + <div v-show="loading" class="gl-px-5"> + <dashboards-skeleton v-if="variant === $options.SKELETON_VARIANT.DASHBOARDS" /> + <explore-skeleton v-else-if="variant === $options.SKELETON_VARIANT.EXPLORE" /> + <manage-skeleton v-else-if="variant === $options.SKELETON_VARIANT.MANAGE" /> + + <gl-skeleton-loader v-else> + <rect y="2" width="10" height="8" /> + <rect y="2" x="15" width="15" height="8" /> + <rect y="2" x="35" width="15" height="8" /> + <rect y="15" width="400" height="30" /> + </gl-skeleton-loader> + </div> + + <div + v-show="!loading" + class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch" + > + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/observability/components/skeleton/manage.vue b/app/assets/javascripts/observability/components/skeleton/manage.vue new file mode 100644 index 00000000000..4b029120328 --- /dev/null +++ b/app/assets/javascripts/observability/components/skeleton/manage.vue @@ -0,0 +1,25 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> +<template> + <gl-skeleton-loader :height="200"> + <!-- Top header--> + <rect y="2" width="400" height="30" /> + + <rect y="35" x="65" width="80" height="8" /> + <rect y="35" x="205" width="30" height="8" /> + <rect y="35" x="240" width="25" height="8" /> + <rect y="35" x="270" width="20" height="8" /> + + <rect y="55" x="65" width="100" height="8" /> + <rect y="55" x="225" width="65" height="8" /> + + <rect y="65" x="65" width="225" height="200" rx="2" ry="2" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js new file mode 100644 index 00000000000..74dd543e285 --- /dev/null +++ b/app/assets/javascripts/observability/constants.js @@ -0,0 +1,16 @@ +export const MESSAGE_EVENT_TYPE = Object.freeze({ + GOUI_LOADED: 'GOUI_LOADED', + GOUI_ROUTE_UPDATE: 'GOUI_ROUTE_UPDATE', +}); + +export const OBSERVABILITY_ROUTES = Object.freeze({ + DASHBOARDS: '/groups/gitlab-org/-/observability/dashboards', + EXPLORE: '/groups/gitlab-org/-/observability/explore', + MANAGE: '/groups/gitlab-org/-/observability/manage', +}); + +export const SKELETON_VARIANT = Object.freeze({ + DASHBOARDS: 'dashboards', + EXPLORE: 'explore', + MANAGE: 'manage', +}); diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue index 1b7d5af6134..56d2ff86fb7 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue @@ -1,11 +1,7 @@ <script> import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui'; -import { - ALERT_MESSAGES, - ADMIN_GARBAGE_COLLECTION_TIP, - ALERT_DANGER_IMPORTING, -} from '../../constants/index'; +import { ALERT_MESSAGES, ADMIN_GARBAGE_COLLECTION_TIP } from '../../constants/index'; export default { components: { @@ -27,7 +23,6 @@ export default { }, }, garbageCollectionHelpPagePath: { type: String, required: false, default: '' }, - containerRegistryImportingHelpPagePath: { type: String, required: false, default: '' }, isAdmin: { type: Boolean, default: false, @@ -53,11 +48,6 @@ export default { } return config; }, - alertHref() { - return this.deleteAlertType === ALERT_DANGER_IMPORTING - ? this.containerRegistryImportingHelpPagePath - : this.garbageCollectionHelpPagePath; - }, }, }; </script> @@ -71,7 +61,7 @@ export default { > <gl-sprintf :message="deleteAlertConfig.message"> <template #docLink="{ content }"> - <gl-link :href="alertHref" target="_blank"> + <gl-link :href="garbageCollectionHelpPagePath" target="_blank"> {{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 597df2b9bc3..c10d8be69a0 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -6,8 +6,8 @@ import { joinPaths } from '~/lib/utils/url_utility'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE, diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js index 98c24350f09..7bb69363743 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js @@ -93,10 +93,6 @@ export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while scheduling the image for deletion.', ); -export const DETAILS_IMPORTING_ERROR_MESSAGE = s__( - 'ContainerRegistry|Tags temporarily cannot be marked for deletion. Please try again in a few minutes. %{docLinkStart}More details%{docLinkEnd}.', -); - export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?'); export const DELETE_IMAGE_CONFIRMATION_TEXT = s__( 'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}', @@ -137,7 +133,6 @@ export const ALERT_DANGER_TAG = 'danger_tag'; export const ALERT_SUCCESS_TAGS = 'success_tags'; export const ALERT_DANGER_TAGS = 'danger_tags'; export const ALERT_DANGER_IMAGE = 'danger_image'; -export const ALERT_DANGER_IMPORTING = 'danger_importing'; export const DELETE_SCHEDULED = 'DELETE_SCHEDULED'; export const DELETE_FAILED = 'DELETE_FAILED'; @@ -148,7 +143,6 @@ export const ALERT_MESSAGES = { [ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE, [ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE, [ALERT_DANGER_IMAGE]: DETAILS_DELETE_IMAGE_ERROR_MESSAGE, - [ALERT_DANGER_IMPORTING]: DETAILS_IMPORTING_ERROR_MESSAGE, }; export const UNFINISHED_STATUS = 'UNFINISHED'; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue index b339c8c8371..83c0d2cdfca 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue @@ -20,7 +20,6 @@ import { ALERT_SUCCESS_TAGS, ALERT_DANGER_TAGS, ALERT_DANGER_IMAGE, - ALERT_DANGER_IMPORTING, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, MISSING_OR_DELETED_IMAGE_BREADCRUMB, @@ -33,8 +32,6 @@ import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container import getContainerRepositoryTagsQuery from '../graphql/queries/get_container_repository_tags.query.graphql'; import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql'; -const REPOSITORY_IMPORTING_ERROR_MESSAGE = 'repository importing'; - export default { name: 'RegistryDetailsPage', components: { @@ -157,17 +154,12 @@ export default { }); if (data?.destroyContainerRepositoryTags?.errors[0]) { - throw new Error(data.destroyContainerRepositoryTags.errors[0]); + throw new Error(); } this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS; } catch (e) { - if (e.message === REPOSITORY_IMPORTING_ERROR_MESSAGE) { - this.deleteAlertType = ALERT_DANGER_IMPORTING; - } else { - this.deleteAlertType = - itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; - } + this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS; } this.mutationLoading = false; @@ -203,7 +195,6 @@ export default { <delete-alert v-model="deleteAlertType" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" - :container-registry-importing-help-page-path="config.containerRegistryImportingHelpPagePath" :is-admin="config.isAdmin" class="gl-my-2" /> diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index 794be8d5195..8a038d7c974 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -11,9 +11,9 @@ import { import { get } from 'lodash'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import { createAlert } from '~/flash'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import Tracking from '~/tracking'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import DeleteImage from '../components/delete_image.vue'; import RegistryHeader from '../components/list_page/registry_header.vue'; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue index c6ab746b9f4..bafcd78ad5d 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue @@ -8,7 +8,7 @@ import { TOKEN_TYPE_TAG_NAME, TAG_LABEL, } from '~/packages_and_registries/harbor_registry/constants/index'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { createAlert } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; @@ -39,7 +39,7 @@ export default { title: TAG_LABEL, unique: true, token: GlFilteredSearchToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, ], data() { diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js index 13df303cffe..2ae5957343b 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js @@ -3,8 +3,8 @@ import { SORT_FIELD_MAPPING, TOKEN_TYPE_TAG_NAME, } from '~/packages_and_registries/harbor_registry/constants'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; export const extractSortingDetail = (parsedSorting = '') => { const [orderBy, sortOrder] = parsedSorting.split('_'); diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue index 2adf6187c4b..0aeeb2c3d15 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue @@ -4,16 +4,14 @@ import { mapActions, mapState } from 'vuex'; import { createAlert, VARIANT_INFO } from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; -import { - SHOW_DELETE_SUCCESS_ALERT, - FILTERED_SEARCH_TERM, -} from '~/packages_and_registries/shared/constants'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; import InfrastructureTitle from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_title.vue'; import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue'; import PackageList from '~/packages_and_registries/infrastructure_registry/list/components/packages_list.vue'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; export default { components: { diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js index 37b51797490..7a452abdc26 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list/stores/actions.js @@ -2,6 +2,7 @@ import Api from '~/api'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages_and_registries/shared/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { FETCH_PACKAGES_LIST_ERROR_MESSAGE, DELETE_PACKAGE_SUCCESS_MESSAGE, @@ -31,7 +32,7 @@ export const requestPackagesList = ({ dispatch, state }, params = {}) => { const type = state.config.forceTerraform ? TERRAFORM_SEARCH_TYPE : state.filter.find((f) => f.type === 'type'); - const name = state.filter.find((f) => f.type === 'filtered-search-term'); + const name = state.filter.find((f) => f.type === FILTERED_SEARCH_TERM); const packageFilters = { package_type: type?.value?.data, package_name: name?.value?.data }; const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index 4553dd3421b..7ad1ebac11e 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -54,6 +54,9 @@ export default { }, }, computed: { + containsWebPathLink() { + return Boolean(this.packageEntity?._links?.webPath); + }, packageType() { return getPackageTypeLabel(this.packageEntity.packageType); }, @@ -109,6 +112,7 @@ export default { <template #left-primary> <div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"> <router-link + v-if="containsWebPathLink" :class="errorPackageStyle" class="gl-text-body gl-min-w-0" data-testid="details-link" @@ -118,6 +122,7 @@ export default { > <gl-truncate :text="packageEntity.name" /> </router-link> + <gl-truncate v-else :text="packageEntity.name" /> <package-tags v-if="showTags" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue index d28847c7900..0cf49b25bf2 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue @@ -1,14 +1,14 @@ <script> -import { s__ } from '~/locale'; import { sortableFields } from '~/packages_and_registries/package_registry/utils'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + FILTERED_SEARCH_TERM, + OPERATORS_IS, + TOKEN_TITLE_TYPE, + TOKEN_TYPE_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import UrlSync from '~/vue_shared/components/url_sync.vue'; import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; -import { - FILTERED_SEARCH_TERM, - FILTERED_SEARCH_TYPE, -} from '~/packages_and_registries/shared/constants'; import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import PackageTypeToken from './tokens/package_type_token.vue'; @@ -16,12 +16,12 @@ import PackageTypeToken from './tokens/package_type_token.vue'; export default { tokens: [ { - type: 'type', + type: TOKEN_TYPE_TYPE, icon: 'package', - title: s__('PackageRegistry|Type'), + title: TOKEN_TITLE_TYPE, unique: true, token: PackageTypeToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, ], components: { RegistrySearch, UrlSync, LocalStorageSync }, @@ -51,7 +51,7 @@ export default { }; return this.filters.reduce((acc, filter) => { - if (filter.type === FILTERED_SEARCH_TYPE && filter.value?.data) { + if (filter.type === TOKEN_TYPE_TYPE && filter.value?.data) { return { ...acc, packageType: filter.value.data.toUpperCase(), diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql index b5695a01376..2d405f3e9cc 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql @@ -29,4 +29,7 @@ fragment PackageData on Package { fullPath webUrl } + _links { + webPath + } } diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql index 51e0ab5aba8..9153906a38c 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql +++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql @@ -62,6 +62,7 @@ query getPackageDetails( } } versions(after: $after, before: $before, first: $first, last: $last) { + count nodes { id name diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue index c59dcaee411..03352f01aca 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue @@ -304,7 +304,7 @@ export default { deleteFileModalContent: s__( `PackageRegistry|You are about to delete %{filename}. This is a destructive action that may render your package unusable. Are you sure?`, ), - otherVersionsTabTitle: __('Other versions'), + otherVersionsTabTitle: s__('PackageRegistry|Other versions'), }, modal: { packageDeletePrimaryAction: { @@ -380,7 +380,9 @@ export default { <gl-tab v-if="showDependencies"> <template #title> <span>{{ __('Dependencies') }}</span> - <gl-badge size="sm">{{ packageDependencies.length }}</gl-badge> + <gl-badge size="sm" data-testid="dependencies-badge">{{ + packageDependencies.length + }}</gl-badge> </template> <template v-if="packageDependencies.length > 0"> @@ -392,7 +394,14 @@ export default { </p> </gl-tab> - <gl-tab :title="$options.i18n.otherVersionsTabTitle" title-item-class="js-versions-tab" lazy> + <gl-tab title-item-class="js-versions-tab" lazy> + <template #title> + <span>{{ $options.i18n.otherVersionsTabTitle }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge" data-testid="other-versions-badge">{{ + packageEntity.versions.count + }}</gl-badge> + </template> + <package-versions-list :is-loading="isLoading" :page-info="versionPageInfo" diff --git a/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue index 6fb001e5e92..0a94f67ea5e 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue @@ -47,7 +47,7 @@ export default { </script> <template> - <div data-qa-selector="package-path" class="gl-display-flex gl-align-items-center"> + <div data-qa-selector="package_path" class="gl-display-flex gl-align-items-center"> <gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" /> <gl-link diff --git a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js index f3ce967b756..fe6e06ad830 100644 --- a/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js +++ b/app/assets/javascripts/packages_and_registries/shared/constants/package_registry.js @@ -1,7 +1,5 @@ import { s__ } from '~/locale'; -export const FILTERED_SEARCH_TERM = 'filtered-search-term'; -export const FILTERED_SEARCH_TYPE = 'type'; export const HISTORY_PIPELINES_LIMIT = 5; export const DELETE_PACKAGE_TRACKING_ACTION = 'delete_package'; diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js index 7e963cd0b08..76623377d90 100644 --- a/app/assets/javascripts/packages_and_registries/shared/utils.js +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { queryToObject } from '~/lib/utils/url_utility'; -import { FILTERED_SEARCH_TERM } from './constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; export const getQueryParams = (query) => queryToObject(query, { gatherArrays: true, legacySpacesDecode: true }); diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue index b68148e5461..96477b9f476 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue +++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue @@ -43,7 +43,6 @@ export default { 'settingsPath', 'signupEnabled', 'requireAdminApprovalAfterUserSignup', - 'sendUserConfirmationEmail', 'emailConfirmationSetting', 'minimumPasswordLength', 'minimumPasswordLengthMin', @@ -68,7 +67,6 @@ export default { form: { signupEnabled: this.signupEnabled, requireAdminApproval: this.requireAdminApprovalAfterUserSignup, - sendConfirmationEmail: this.sendUserConfirmationEmail, emailConfirmationSetting: this.emailConfirmationSetting, minimumPasswordLength: this.minimumPasswordLength, minimumPasswordLengthMin: this.minimumPasswordLengthMin, @@ -204,7 +202,6 @@ export default { buttonText: s__('ApplicationSettings|Save changes'), signupEnabledLabel: s__('ApplicationSettings|Sign-up enabled'), requireAdminApprovalLabel: s__('ApplicationSettings|Require admin approval for new sign-ups'), - sendConfirmationEmailLabel: s__('ApplicationSettings|Send confirmation email on sign-up'), emailConfirmationSettingsLabel: s__('ApplicationSettings|Email confirmation settings'), emailConfirmationSettingsOffLabel: s__('ApplicationSettings|Off'), emailConfirmationSettingsOffHelpText: s__( @@ -284,13 +281,6 @@ export default { data-testid="require-admin-approval-checkbox" /> - <signup-checkbox - v-model="form.sendConfirmationEmail" - class="gl-mb-5" - name="application_setting[send_user_confirmation_email]" - :label="$options.i18n.sendConfirmationEmailLabel" - /> - <gl-form-group :label="$options.i18n.emailConfirmationSettingsLabel"> <gl-form-radio-group v-model="form.emailConfirmationSetting" diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js index 0d5c55cb87b..395d8a38bf7 100644 --- a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js +++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js @@ -14,7 +14,6 @@ export default function initSignupRestrictions(elementSelector = '#js-signup-for booleanAttributes: [ 'signupEnabled', 'requireAdminApprovalAfterUserSignup', - 'sendUserConfirmationEmail', 'domainDenylistEnabled', 'denylistTypeRawSelected', 'emailRestrictionsEnabled', diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js new file mode 100644 index 00000000000..25036984082 --- /dev/null +++ b/app/assets/javascripts/pages/admin/broadcast_messages/edit/index.js @@ -0,0 +1,8 @@ +import initEditBroadcastMessage from '~/admin/broadcast_messages/edit'; +import initBroadcastMessagesForm from '../broadcast_message'; + +if (gon.features.vueBroadcastMessages) { + initEditBroadcastMessage(); +} else { + initBroadcastMessagesForm(); +} diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js index ffd976be8c6..1f37df2b340 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/index.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/index/index.js @@ -1,6 +1,6 @@ import initBroadcastMessages from '~/admin/broadcast_messages'; import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; -import initBroadcastMessagesForm from './broadcast_message'; +import initBroadcastMessagesForm from '../broadcast_message'; if (gon.features.vueBroadcastMessages) { initBroadcastMessages(); diff --git a/app/assets/javascripts/pages/admin/dashboard/index.js b/app/assets/javascripts/pages/admin/dashboard/index.js deleted file mode 100644 index b63e612be47..00000000000 --- a/app/assets/javascripts/pages/admin/dashboard/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initGitlabVersionCheck from '~/gitlab_version_check'; - -initGitlabVersionCheck(); diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue index b06c804f3ca..48241a213ef 100644 --- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue +++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue @@ -1,6 +1,7 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlModal } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; import { escape } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__, sprintf } from '~/locale'; export default { diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index 2a7619da8cc..c5d62ae5daf 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -6,9 +6,7 @@ import { getProjects } from '~/api/projects_api'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { isMetaClick } from '~/lib/utils/common_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; -import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import UsersSelect from '~/users_select'; @@ -34,10 +32,6 @@ export default class Todos { document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => { el.removeEventListener('click', this.updateallStateClickedWrapper); }); - document.querySelectorAll('.todo').forEach((el) => { - el.removeEventListener('click', this.goToTodoUrl); - el.removeEventListener('auxclick', this.goToTodoUrl); - }); } bindEvents() { @@ -50,10 +44,6 @@ export default class Todos { document.querySelectorAll('.js-todos-mark-all, .js-todos-undo-all').forEach((el) => { el.addEventListener('click', this.updateAllStateClickedWrapper); }); - document.querySelectorAll('.todo').forEach((el) => { - el.addEventListener('click', this.goToTodoUrl); - el.addEventListener('auxclick', this.goToTodoUrl); - }); } initFilters() { @@ -106,19 +96,22 @@ export default class Todos { e.stopPropagation(); e.preventDefault(); - const { target } = e; - target.setAttribute('disabled', true); - target.classList.add('disabled'); + let { currentTarget } = e; + if (currentTarget.tagName === 'svg' || currentTarget.tagName === 'use') { + currentTarget = currentTarget.closest('a'); + } + currentTarget.setAttribute('disabled', true); + currentTarget.classList.add('disabled'); - target.querySelector('.gl-spinner-container').classList.add('gl-mr-2'); + currentTarget.querySelector('.js-todo-button-icon').classList.add('hidden'); - axios[target.dataset.method](target.dataset.href) + axios[currentTarget.dataset.method](currentTarget.href) .then(({ data }) => { - this.updateRowState(target); + this.updateRowState(currentTarget); this.updateBadges(data); }) .catch(() => { - this.updateRowState(target, true); + this.updateRowState(currentTarget, true); return createAlert({ message: __('Error updating status of to-do item.'), }); @@ -134,7 +127,7 @@ export default class Todos { target.removeAttribute('disabled'); target.classList.remove('disabled'); - target.querySelector('.gl-spinner-container').classList.remove('gl-mr-2'); + target.querySelector('.js-todo-button-icon').classList.remove('hidden'); if (isInactive === true) { restoreBtn.classList.add('hidden'); @@ -209,25 +202,4 @@ export default class Todos { data.done_count, ); } - - goToTodoUrl(e) { - const todoLink = this.dataset.url; - - if (!todoLink || e.target.closest('a')) { - return; - } - - e.stopPropagation(); - e.preventDefault(); - - const isPrimaryClick = e.button === 0; - - if (isMetaClick(e)) { - const windowTarget = '_blank'; - - window.open(todoLink, windowTarget); - } else if (isPrimaryClick) { - visitUrl(todoLink); - } - } } diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 377ba0f13a9..bf0147ca885 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -1,11 +1,7 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import { - initBulkUpdateSidebar, - initStatusDropdown, - initSubscriptionsDropdown, -} from '~/issuable/bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; +import { initBulkUpdateSidebar } from '~/issuable'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; @@ -13,8 +9,6 @@ const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_'; addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); initBulkUpdateSidebar(ISSUABLE_BULK_UPDATE_PREFIX); -initStatusDropdown(); -initSubscriptionsDropdown(); initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, diff --git a/app/assets/javascripts/pages/help/index/index.js b/app/assets/javascripts/pages/help/index/index.js index a8e67c57307..da748223440 100644 --- a/app/assets/javascripts/pages/help/index/index.js +++ b/app/assets/javascripts/pages/help/index/index.js @@ -1,5 +1,3 @@ import docs from '~/docs/docs_bundle'; -import initGitlabVersionCheck from '~/gitlab_version_check'; docs(); -initGitlabVersionCheck(); diff --git a/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue b/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue index 20ce296bbec..912b84dbae6 100644 --- a/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue +++ b/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue @@ -1,5 +1,5 @@ <script> -import { GlAvatarLabeled, GlListbox } from '@gitlab/ui'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; import { __ } from '~/locale'; import searchUsersQuery from '~/graphql_shared/queries/users_search_all.query.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -11,7 +11,7 @@ const USERS_PER_PAGE = 20; export default { components: { GlAvatarLabeled, - GlListbox, + GlCollapsibleListbox, }, props: { name: { @@ -70,7 +70,7 @@ export default { </script> <template> <div> - <gl-listbox + <gl-collapsible-listbox ref="listbox" v-model="user" :items="users" @@ -89,7 +89,7 @@ export default { :sub-label="item.username" /> </template> - </gl-listbox> + </gl-collapsible-listbox> <input type="hidden" :name="name" :value="userId" /> </div> </template> diff --git a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js index 870c14f99ae..d0560af5b3f 100644 --- a/app/assets/javascripts/pages/import/gitlab_projects/new/index.js +++ b/app/assets/javascripts/pages/import/gitlab_projects/new/index.js @@ -1,3 +1,5 @@ import initGitLabImportProject from '~/projects/project_import_gitlab_project'; +import { initNewProjectUrlSelect } from '~/projects/new'; +initNewProjectUrlSelect(); initGitLabImportProject(); diff --git a/app/assets/javascripts/pages/import/manifest/new/index.js b/app/assets/javascripts/pages/import/manifest/new/index.js new file mode 100644 index 00000000000..0bb70a7364e --- /dev/null +++ b/app/assets/javascripts/pages/import/manifest/new/index.js @@ -0,0 +1,3 @@ +import { initNewProjectUrlSelect } from '~/projects/new'; + +initNewProjectUrlSelect(); diff --git a/app/assets/javascripts/pages/import/phabricator/new/index.js b/app/assets/javascripts/pages/import/phabricator/new/index.js new file mode 100644 index 00000000000..0bb70a7364e --- /dev/null +++ b/app/assets/javascripts/pages/import/phabricator/new/index.js @@ -0,0 +1,3 @@ +import { initNewProjectUrlSelect } from '~/projects/new'; + +initNewProjectUrlSelect(); diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js index dbae89b5ade..f2b03468b0b 100644 --- a/app/assets/javascripts/pages/projects/branches/new/index.js +++ b/app/assets/javascripts/pages/projects/branches/new/index.js @@ -1,7 +1,6 @@ import NewBranchForm from '~/new_branch_form'; +import initNewBranchRefSelector from '~/branches/init_new_branch_ref_selector'; +initNewBranchRefSelector(); // eslint-disable-next-line no-new -new NewBranchForm( - document.querySelector('.js-create-branch-form'), - JSON.parse(document.getElementById('availableRefs').innerHTML), -); +new NewBranchForm(document.querySelector('.js-create-branch-form')); diff --git a/app/assets/javascripts/pages/projects/ci/lints/show/index.js b/app/assets/javascripts/pages/projects/ci/lints/show/index.js index 6e1cdf557b5..caac76fc6d7 100644 --- a/app/assets/javascripts/pages/projects/ci/lints/show/index.js +++ b/app/assets/javascripts/pages/projects/ci/lints/show/index.js @@ -1,3 +1,3 @@ -import initCiLint from '~/ci_lint'; +import initCiLint from '~/ci/ci_lint'; initCiLint(); diff --git a/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js index 67d32648ce8..7e91f23dd7f 100644 --- a/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js +++ b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js @@ -1,3 +1,3 @@ -import { initPipelineEditor } from '~/pipeline_editor'; +import { initPipelineEditor } from '~/ci/pipeline_editor'; initPipelineEditor(); diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js index ee74628a994..f5ecf9be591 100644 --- a/app/assets/javascripts/pages/projects/commits/show/index.js +++ b/app/assets/javascripts/pages/projects/commits/show/index.js @@ -1,9 +1,10 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import CommitsList from '~/commits'; import GpgBadges from '~/gpg_badges'; -import mountCommits from '~/projects/commits'; +import { mountCommits, initCommitsRefSwitcher } from '~/projects/commits'; new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new GpgBadges.fetch(); mountCommits(document.getElementById('js-author-dropdown')); +initCommitsRefSwitcher(); diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js index bef21ef8fdf..05a1bbc69ed 100644 --- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js +++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js @@ -1,3 +1,3 @@ -import initCycleAnalytics from '~/cycle_analytics'; +import initCycleAnalytics from '~/analytics/cycle_analytics'; initCycleAnalytics(); diff --git a/app/assets/javascripts/pages/projects/environments/show/index.js b/app/assets/javascripts/pages/projects/environments/show/index.js index 53e48ad8d86..1ce8899ac63 100644 --- a/app/assets/javascripts/pages/projects/environments/show/index.js +++ b/app/assets/javascripts/pages/projects/environments/show/index.js @@ -1,5 +1,6 @@ import initConfirmRollBackModal from '~/environments/init_confirm_rollback_modal'; -import { initHeader } from '~/environments/mount_show'; +import { initHeader, initPage } from '~/environments/mount_show'; initHeader(); +initPage(); initConfirmRollBackModal(); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 30cefa3d717..91650003d4a 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -23,6 +23,7 @@ import { VISIBILITY_LEVEL_INTERNAL_STRING, VISIBILITY_LEVEL_PUBLIC_STRING, VISIBILITY_LEVELS_STRING_TO_INTEGER, + VISIBILITY_LEVELS_INTEGER_TO_STRING, } from '~/visibility_level/constants'; import ProjectNamespace from './project_namespace.vue'; @@ -105,39 +106,8 @@ export default { }; }, computed: { - projectVisibilityLevel() { - return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility]; - }, - namespaceVisibilityLevel() { - const visibility = - this.form.fields.namespace.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING; - return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; - }, - visibilityLevelCap() { - return Math.min(this.projectVisibilityLevel, this.namespaceVisibilityLevel); - }, - restrictedVisibilityLevelsSet() { - return new Set(this.restrictedVisibilityLevels); - }, allowedVisibilityLevels() { - const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce( - (levels, [levelName, levelValue]) => { - if ( - !this.restrictedVisibilityLevelsSet.has(levelValue) && - levelValue <= this.visibilityLevelCap - ) { - levels.push(levelName); - } - return levels; - }, - [], - ); - - if (!allowedLevels.length) { - return [VISIBILITY_LEVEL_PRIVATE_STRING]; - } - - return allowedLevels; + return this.getAllowedVisibilityLevels(); }, visibilityLevels() { return [ @@ -178,13 +148,60 @@ export default { return !this.allowedVisibilityLevels.includes(visibility); }, getInitialVisibilityValue() { - return this.restrictedVisibilityLevels.length !== 0 ? null : this.projectVisibility; + return this.getMaximumAllowedVisibilityLevel(this.projectVisibility); }, setNamespace(namespace) { - this.form.fields.visibility.value = - this.restrictedVisibilityLevels.length !== 0 ? null : VISIBILITY_LEVEL_PRIVATE_STRING; this.form.fields.namespace.value = namespace; this.form.fields.namespace.state = true; + this.form.fields.visibility.value = this.getMaximumAllowedVisibilityLevel( + this.form.fields.visibility.value, + ); + }, + getProjectVisibilityLevel() { + return VISIBILITY_LEVELS_STRING_TO_INTEGER[this.projectVisibility]; + }, + getNamespaceVisibilityLevel() { + const visibility = + this.form?.fields?.namespace?.value?.visibility || VISIBILITY_LEVEL_PUBLIC_STRING; + return VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; + }, + getVisibilityLevelCap() { + return Math.min(this.getProjectVisibilityLevel(), this.getNamespaceVisibilityLevel()); + }, + getRestrictedVisibilityLevelsSet() { + return new Set(this.restrictedVisibilityLevels); + }, + getAllowedVisibilityLevels() { + const allowedLevels = Object.entries(VISIBILITY_LEVELS_STRING_TO_INTEGER).reduce( + (levels, [levelName, levelValue]) => { + if ( + !this.getRestrictedVisibilityLevelsSet().has(levelValue) && + levelValue <= this.getVisibilityLevelCap() + ) { + levels.push(levelName); + } + return levels; + }, + [], + ); + + if (!allowedLevels.length) { + return [VISIBILITY_LEVEL_PRIVATE_STRING]; + } + + return allowedLevels; + }, + getMaximumAllowedVisibilityLevel(visibility) { + const allowedVisibilities = this.getAllowedVisibilityLevels().map( + (s) => VISIBILITY_LEVELS_STRING_TO_INTEGER[s], + ); + const current = VISIBILITY_LEVELS_STRING_TO_INTEGER[visibility]; + const lower = allowedVisibilities.filter((l) => l <= current); + if (lower.length) { + return VISIBILITY_LEVELS_INTEGER_TO_STRING[Math.max(...lower)]; + } + const higher = allowedVisibilities.filter((l) => l >= current); + return VISIBILITY_LEVELS_INTEGER_TO_STRING[Math.min(...higher)]; }, async onSubmit() { this.form.showValidation = true; diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue index 08d24344ffc..10bfcdc2294 100644 --- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlButton, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlListbox, GlSprintf } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { get } from 'lodash'; import { formatDate } from '~/lib/utils/datetime_utility'; @@ -12,8 +12,7 @@ export default { GlAlert, GlAreaChart, GlButton, - GlDropdown, - GlDropdownItem, + GlListbox, GlSprintf, }, props: { @@ -96,6 +95,14 @@ export default { formattedData() { return this.sortedData.map((value) => [value.date, value.coverage]); }, + mappedCoverages() { + return this.dailyCoverageData?.map((item, index) => ({ + // A numerical index makes an item into a group header, so + // convert these to strings to get non-header GlListbox items + value: index.toString(), + text: item.group_name, + })); + }, chartData() { return [ { @@ -175,18 +182,13 @@ export default { {{ __('It seems that there is currently no available data for code coverage') }} </span> </gl-alert> - <gl-dropdown v-if="canShowData" :text="selectedDailyCoverageName"> - <gl-dropdown-item - v-for="({ group_name }, index) in dailyCoverageData" - :key="index" - :value="group_name" - is-check-item - :is-checked="index === selectedCoverageIndex" - @click="setSelectedCoverage(index)" - > - {{ group_name }} - </gl-dropdown-item> - </gl-dropdown> + <gl-listbox + v-if="canShowData" + :items="mappedCoverages" + :selected="selectedCoverageIndex.toString()" + :toggle-text="selectedDailyCoverageName" + @select="setSelectedCoverage" + /> </div> <gl-area-chart v-if="!isLoading" diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js index 2d26d3922bf..653f903c6d1 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/compare.js @@ -26,7 +26,10 @@ const updateCommitList = (url, $emptyState, $loadingIndicator, $commitList, para export default (mrNewCompareNode) => { const { sourceBranchUrl, targetBranchUrl } = mrNewCompareNode.dataset; - initTargetProjectDropdown(); + + if (!window.gon?.features?.mrCompareDropdowns) { + initTargetProjectDropdown(); + } const updateSourceBranchCommitList = () => updateCommitList( diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index 9aecd154483..b3868653d6a 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -1,10 +1,37 @@ +import $ from 'jquery'; +import Vue from 'vue'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; +import TargetProjectDropdown from '~/merge_requests/components/target_project_dropdown.vue'; import initCompare from './compare'; const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); if (mrNewCompareNode) { initCompare(mrNewCompareNode); + + const el = document.getElementById('js-target-project-dropdown'); + const { targetProjectsPath, currentProject } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'TargetProjectDropdown', + provide: { + targetProjectsPath, + currentProject: JSON.parse(currentProject), + }, + render(h) { + return h(TargetProjectDropdown, { + on: { + 'project-selected': function projectSelectedFunction(refsUrl) { + const $targetBranchDropdown = $('.js-target-branch'); + $targetBranchDropdown.data('refsUrl', refsUrl); + $targetBranchDropdown.data('deprecatedJQueryDropdown').clearMenu(); + }, + }, + }); + }, + }); } else { const mrNewSubmitNode = document.querySelector('.js-merge-request-new-submit'); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js b/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js new file mode 100644 index 00000000000..77294c0fb9e --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/diffs/index.js @@ -0,0 +1,5 @@ +import initDiffsApp from '~/diffs'; +import { initMrPage } from '../page'; + +initMrPage(); +initDiffsApp(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index 2399aafc9b5..b3a09cc0be3 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -1,20 +1,13 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; -import { initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; -import { - initBulkUpdateSidebar, - initStatusDropdown, - initSubscriptionsDropdown, -} from '~/issuable/bulk_update_sidebar'; import { FILTERED_SEARCH } from '~/filtered_search/constants'; +import { initBulkUpdateSidebar, initCsvImportExportButtons, initIssuableByEmail } from '~/issuable'; import { ISSUABLE_INDEX } from '~/issuable/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import UsersSelect from '~/users_select'; initBulkUpdateSidebar(ISSUABLE_INDEX.MERGE_REQUEST); -initStatusDropdown(); -initSubscriptionsDropdown(); addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); IssuableFilteredSearchTokenKeys.removeTokensForKeys('iteration'); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index 42fa306d226..a4e3ddfc506 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import IssuableForm from 'ee_else_ce/issuable/issuable_form'; +import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import Diff from '~/diff'; import GLForm from '~/gl_form'; @@ -14,6 +15,7 @@ export default () => { new ShortcutsNavigation(); new GLForm($('.merge-request-form')); new IssuableForm($('.merge-request-form')); + IssuableLabelSelector(); new LabelsSelect(); new IssuableTemplateSelectors({ warnTemplateOverride: true, diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js new file mode 100644 index 00000000000..a8699b350f8 --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/page.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import StickyHeader from '~/merge_requests/components/sticky_header.vue'; +import { initIssuableHeaderWarnings } from '~/issuable'; +import initMrNotes from '~/mr_notes'; +import store from '~/mr_notes/stores'; +import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import { apolloProvider } from '~/graphql_shared/issuable_client'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import initShow from './init_merge_request_show'; +import getStateQuery from './queries/get_state.query.graphql'; + +export function initMrPage() { + initMrNotes(); + initShow(); +} + +requestIdleCallback(() => { + initSidebarBundle(store); + initIssuableHeaderWarnings(store); + + const el = document.getElementById('js-merge-sticky-header'); + + if (el) { + const { data } = el.dataset; + const { iid, projectPath, title, tabs, isFluidLayout } = JSON.parse(data); + + // eslint-disable-next-line no-new + new Vue({ + el, + store, + apolloProvider, + provide: { + query: getStateQuery, + iid, + projectPath, + title, + tabs, + isFluidLayout: parseBoolean(isFluidLayout), + }, + render(h) { + return h(StickyHeader); + }, + }); + } +}); diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index cc5c393ff8c..568bf19b55e 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -1,45 +1,5 @@ -import Vue from 'vue'; -import StickyHeader from '~/merge_requests/components/sticky_header.vue'; -import { initReviewBar } from '~/batch_comments'; -import { initIssuableHeaderWarnings } from '~/issuable'; -import initMrNotes from '~/mr_notes'; -import store from '~/mr_notes/stores'; -import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import { apolloProvider } from '~/graphql_shared/issuable_client'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import initShow from '../init_merge_request_show'; -import getStateQuery from '../queries/get_state.query.graphql'; +import initNotesApp from '~/mr_notes/init_notes'; +import { initMrPage } from '../page'; -initMrNotes(); -initShow(); - -requestIdleCallback(() => { - initSidebarBundle(store); - initReviewBar(); - initIssuableHeaderWarnings(store); - - const el = document.getElementById('js-merge-sticky-header'); - - if (el) { - const { data } = el.dataset; - const { iid, projectPath, title, tabs, isFluidLayout } = JSON.parse(data); - - // eslint-disable-next-line no-new - new Vue({ - el, - store, - apolloProvider, - provide: { - query: getStateQuery, - iid, - projectPath, - title, - tabs, - isFluidLayout: parseBoolean(isFluidLayout), - }, - render(h) { - return h(StickyHeader); - }, - }); - } -}); +initMrPage(); +initNotesApp(); diff --git a/app/assets/javascripts/pages/projects/ml/candidates/show/index.js b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js new file mode 100644 index 00000000000..c1acef5ac13 --- /dev/null +++ b/app/assets/javascripts/pages/projects/ml/candidates/show/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import MlCandidate from '~/ml/experiment_tracking/components/ml_candidate.vue'; + +const initShowCandidate = () => { + const element = document.querySelector('#js-show-ml-candidate'); + if (!element) { + return; + } + + const container = document.createElement('div'); + element.appendChild(container); + + const candidate = JSON.parse(element.dataset.candidate); + + // eslint-disable-next-line no-new + new Vue({ + el: container, + provide: { + candidate, + }, + render(h) { + return h(MlCandidate); + }, + }); +}; + +initShowCandidate(); diff --git a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js index 0a9d9f4c987..97e436920c7 100644 --- a/app/assets/javascripts/pages/projects/ml/experiments/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/experiments/show/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import ShowExperiment from '~/ml/experiment_tracking/components/experiment.vue'; +import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue'; const initShowExperiment = () => { const element = document.querySelector('#js-show-ml-experiment'); @@ -23,7 +23,7 @@ const initShowExperiment = () => { paramNames, }, render(h) { - return h(ShowExperiment); + return h(MlExperiment); }, }); }; diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 50733d8a145..d022428df98 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -4,10 +4,8 @@ import { initDeploymentTargetSelect, } from '~/projects/new'; import initProjectVisibilitySelector from '~/projects/project_visibility'; -import initProjectNew from '~/projects/project_new'; initProjectVisibilitySelector(); -initProjectNew.bindEvents(); initNewProjectCreation(); initNewProjectUrlSelect(); initDeploymentTargetSelect(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue index 85443843684..fd8b1a6290f 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue @@ -39,6 +39,11 @@ export default { required: false, default: '', }, + sendNativeErrors: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -114,9 +119,11 @@ export default { cronInterval() { // updates field validation state when model changes, as // glFieldError only updates on input. - this.$nextTick(() => { - gl.pipelineScheduleFieldErrors.updateFormValidityState(); - }); + if (this.sendNativeErrors) { + this.$nextTick(() => { + gl.pipelineScheduleFieldErrors.updateFormValidityState(); + }); + } }, radioValue: { immediate: true, diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js deleted file mode 100644 index bc467952551..00000000000 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js +++ /dev/null @@ -1,82 +0,0 @@ -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { formatTimezone } from '~/lib/utils/datetime_utility'; - -const defaultTimezone = { identifier: 'Etc/UTC', name: 'UTC', offset: 0 }; -const defaults = { - $inputEl: null, - $dropdownEl: null, - onSelectTimezone: null, - displayFormat: (item) => item.name, -}; - -export const formatUtcOffset = (offset) => { - const parsed = parseInt(offset, 10); - if (Number.isNaN(parsed) || parsed === 0) { - return `0`; - } - const prefix = offset > 0 ? '+' : '-'; - return `${prefix} ${Math.abs(offset / 3600)}`; -}; - -export const findTimezoneByIdentifier = (tzList = [], identifier = null) => { - if (tzList && tzList.length && identifier && identifier.length) { - return tzList.find((tz) => tz.identifier === identifier) || null; - } - return null; -}; - -export default class TimezoneDropdown { - constructor({ - $dropdownEl, - $inputEl, - onSelectTimezone, - displayFormat, - allowEmpty = false, - } = defaults) { - this.$dropdown = $dropdownEl; - this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text'); - this.$input = $inputEl; - this.timezoneData = this.$dropdown.data('data') || []; - - this.onSelectTimezone = onSelectTimezone; - this.displayFormat = displayFormat || defaults.displayFormat; - this.allowEmpty = allowEmpty; - - this.initDropdown(); - } - - initDropdown() { - initDeprecatedJQueryDropdown(this.$dropdown, { - data: this.timezoneData, - filterable: true, - selectable: true, - toggleLabel: this.displayFormat, - search: { - fields: ['name'], - }, - clicked: (cfg) => this.handleDropdownChange(cfg), - text: (item) => formatTimezone(item), - }); - - const initialTimezone = findTimezoneByIdentifier(this.timezoneData, this.$input.val()); - - if (initialTimezone !== null) { - this.setDropdownValue(initialTimezone); - } else if (!this.allowEmpty) { - this.setDropdownValue(defaultTimezone); - } - } - - setDropdownValue(timezone) { - this.$dropdownToggle.text(this.displayFormat(timezone)); - this.$input.val(timezone.identifier); - } - - handleDropdownChange({ selectedObj, e }) { - e.preventDefault(); - this.$input.val(selectedObj.identifier); - if (this.onSelectTimezone) { - this.onSelectTimezone({ selectedObj, e }); - } - } -} diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index d177c67f133..4c9eb830ff6 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -11,10 +11,14 @@ import { mergeUrlParams } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import projectSelect from '~/project_select'; +const BRANCH_REF_TYPE = 'heads'; +const TAG_REF_TYPE = 'tags'; +const BRANCH_GROUP_NAME = __('Branches'); +const TAG_GROUP_NAME = __('Tags'); + export default class Project { constructor() { initClonePanel(); - // Ref switcher if (document.querySelector('.js-project-refs-dropdown')) { Project.initRefSwitcher(); @@ -62,6 +66,7 @@ export default class Project { return $('.js-project-refs-dropdown').each(function () { const $dropdown = $(this); const selected = $dropdown.data('selected'); + const refType = $dropdown.data('refType'); const fieldName = $dropdown.data('fieldName'); const shouldVisit = Boolean($dropdown.data('visit')); const $form = $dropdown.closest('form'); @@ -91,18 +96,32 @@ export default class Project { filterByText: true, inputFieldName: $dropdown.data('inputFieldName'), fieldName, - renderRow(ref) { + renderRow(ref, _, params) { const li = refListItem.cloneNode(false); const link = refLink.cloneNode(false); if (ref === selected) { - link.className = 'is-active'; + // Check group and current ref type to avoid adding a class when tags and branches share the same name + if ( + (refType === BRANCH_REF_TYPE && params.group === BRANCH_GROUP_NAME) || + (refType === TAG_REF_TYPE && params.group === TAG_GROUP_NAME) || + !refType + ) { + link.className = 'is-active'; + } } + link.textContent = ref; link.dataset.ref = ref; if (ref.length > 0 && shouldVisit) { - link.href = mergeUrlParams({ [fieldName]: ref }, linkTarget); + const urlParams = { [fieldName]: ref }; + if (params.group === BRANCH_GROUP_NAME) { + urlParams.ref_type = BRANCH_REF_TYPE; + } else { + urlParams.ref_type = TAG_REF_TYPE; + } + link.href = mergeUrlParams(urlParams, linkTarget); } li.appendChild(link); diff --git a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js index 739e666644c..0f7ede8ed42 100644 --- a/app/assets/javascripts/pages/projects/settings/merge_requests/index.js +++ b/app/assets/javascripts/pages/projects/settings/merge_requests/index.js @@ -1,9 +1,6 @@ import groupsSelect from '~/groups_select'; import UserCallout from '~/user_callout'; -import UsersSelect from '~/users_select'; -// eslint-disable-next-line no-new -new UsersSelect(); groupsSelect(); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index c37b4cc643a..5fa3288bbef 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -23,6 +23,12 @@ import ProjectSettingRow from './project_setting_row.vue'; const FEATURE_ACCESS_LEVEL_ANONYMOUS = [30, s__('ProjectSettings|Everyone')]; +const PACKAGE_REGISTRY_ACCESS_LEVEL_DEFAULT_BY_PROJECT_VISIBILITY = { + [VISIBILITY_LEVEL_PRIVATE_INTEGER]: featureAccessLevel.PROJECT_MEMBERS, + [VISIBILITY_LEVEL_INTERNAL_INTEGER]: featureAccessLevel.EVERYONE, + [VISIBILITY_LEVEL_PUBLIC_INTEGER]: FEATURE_ACCESS_LEVEL_ANONYMOUS[0], +}; + export default { i18n: { ...CVE_ID_REQUEST_BUTTON_I18N, @@ -32,7 +38,6 @@ export default { issuesLabel: s__('ProjectSettings|Issues'), lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'), mergeRequestsLabel: s__('ProjectSettings|Merge requests'), - operationsLabel: s__('ProjectSettings|Operations'), environmentsLabel: s__('ProjectSettings|Environments'), environmentsHelpText: s__( 'ProjectSettings|Every project can make deployments to environments either via CI/CD or API calls. Non-project members have read-only access.', @@ -47,11 +52,15 @@ export default { packagesHelpText: s__( 'ProjectSettings|Every project can have its own space to store its packages. Note: The Package Registry is always visible when a project is public.', ), - packageRegistryHelpText: s__( - 'ProjectSettings|Every project can have its own space to store its packages.', + packageRegistryHelpText: s__('ProjectSettings|Publish, store, and view packages in a project.'), + packageRegistryForEveryoneHelpText: s__( + 'ProjectSettings|Anyone can pull packages with a package manager API.', ), packagesLabel: s__('ProjectSettings|Packages'), packageRegistryLabel: s__('ProjectSettings|Package registry'), + packageRegistryForEveryoneLabel: s__( + 'ProjectSettings|Allow anyone to pull from Package Registry', + ), pagesLabel: s__('ProjectSettings|Pages'), ciCdLabel: __('CI/CD'), repositoryLabel: s__('ProjectSettings|Repository'), @@ -249,7 +258,6 @@ export default { analyticsAccessLevel: featureAccessLevel.EVERYONE, requirementsAccessLevel: featureAccessLevel.EVERYONE, securityAndComplianceAccessLevel: featureAccessLevel.PROJECT_MEMBERS, - operationsAccessLevel: featureAccessLevel.EVERYONE, environmentsAccessLevel: featureAccessLevel.EVERYONE, featureFlagsAccessLevel: featureAccessLevel.PROJECT_MEMBERS, infrastructureAccessLevel: featureAccessLevel.PROJECT_MEMBERS, @@ -287,18 +295,6 @@ export default { ); }, - packageRegistryFeatureAccessLevelOptions() { - const options = [FEATURE_ACCESS_LEVEL_ANONYMOUS]; - - if (this.visibilityLevel === VISIBILITY_LEVEL_PRIVATE_INTEGER) { - options.unshift(featureAccessLevelMembers); - } else if (this.visibilityLevel === VISIBILITY_LEVEL_INTERNAL_INTEGER) { - options.unshift(featureAccessLevelEveryone); - } - - return options; - }, - pagesFeatureAccessLevelOptions() { const options = [featureAccessLevelMembers]; @@ -318,10 +314,6 @@ export default { return options; }, - operationsEnabled() { - return this.operationsAccessLevel > featureAccessLevel.NOT_ENABLED; - }, - environmentsEnabled() { return this.environmentsAccessLevel > featureAccessLevel.NOT_ENABLED; }, @@ -351,7 +343,7 @@ export default { } return s__( - 'ProjectSettings|View and edit files in this project. Non-project members have only read access.', + 'ProjectSettings|View and edit files in this project. When set to **Everyone With Access** non-project members have only read access.', ); }, cveIdRequestIsDisabled() { @@ -366,16 +358,17 @@ export default { packageRegistryAccessLevelEnabled() { return this.glFeatures.packageRegistryAccessLevel; }, - splitOperationsEnabled() { - return this.glFeatures.splitOperationsVisibilityPermissions; + packageRegistryEnabled() { + return this.packageRegistryAccessLevel > featureAccessLevel.NOT_ENABLED; + }, + packageRegistryApiForEveryoneEnabled() { + return this.packageRegistryAccessLevel === FEATURE_ACCESS_LEVEL_ANONYMOUS[0]; + }, + packageRegistryApiForEveryoneEnabledShown() { + return this.visibilityLevel !== VISIBILITY_LEVEL_PUBLIC_INTEGER; }, monitorOperationsFeatureAccessLevelOptions() { - if (this.splitOperationsEnabled) { - return this.featureAccessLevelOptions.filter(([value]) => value <= this.monitorAccessLevel); - } - return this.featureAccessLevelOptions.filter( - ([value]) => value <= this.operationsAccessLevel, - ); + return this.featureAccessLevelOptions.filter(([value]) => value <= this.monitorAccessLevel); }, }, @@ -429,10 +422,6 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.securityAndComplianceAccessLevel, ); - this.operationsAccessLevel = Math.min( - featureAccessLevel.PROJECT_MEMBERS, - this.operationsAccessLevel, - ); this.environmentsAccessLevel = Math.min( featureAccessLevel.PROJECT_MEMBERS, this.environmentsAccessLevel, @@ -474,9 +463,8 @@ export default { this.packageRegistryAccessLevelEnabled && this.packageRegistryAccessLevel === featureAccessLevel.PROJECT_MEMBERS ) { - this.packageRegistryAccessLevel = Math.min( - ...this.packageRegistryFeatureAccessLevelOptions.map((option) => option[0]), - ); + this.packageRegistryAccessLevel = + PACKAGE_REGISTRY_ACCESS_LEVEL_DEFAULT_BY_PROJECT_VISIBILITY[value]; } if (this.buildsAccessLevel > featureAccessLevel.NOT_ENABLED) this.buildsAccessLevel = featureAccessLevel.EVERYONE; @@ -492,8 +480,6 @@ export default { this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE; if (this.requirementsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.requirementsAccessLevel = featureAccessLevel.EVERYONE; - if (this.operationsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) - this.operationsAccessLevel = featureAccessLevel.EVERYONE; if (this.environmentsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.environmentsAccessLevel = featureAccessLevel.EVERYONE; if (this.monitorAccessLevel === featureAccessLevel.PROJECT_MEMBERS) @@ -532,10 +518,6 @@ export default { toggleHiddenClassBySelector('.merge-requests-feature', false); }, - operationsAccessLevel(value, oldValue) { - this.updateSubFeatureAccessLevel(value, oldValue); - }, - monitorAccessLevel(value, oldValue) { this.updateSubFeatureAccessLevel(value, oldValue); }, @@ -561,6 +543,22 @@ export default { visibilityAllowed(option) { return this.allowedVisibilityOptions.includes(option); }, + onPackageRegistryEnabledToggle(value) { + this.packageRegistryAccessLevel = value + ? this.packageRegistryAccessLevelDefault() + : featureAccessLevel.NOT_ENABLED; + }, + onPackageRegistryApiForEveryoneEnabledToggle(value) { + this.packageRegistryAccessLevel = value + ? FEATURE_ACCESS_LEVEL_ANONYMOUS[0] + : this.packageRegistryAccessLevelDefault(); + }, + packageRegistryAccessLevelDefault() { + return ( + PACKAGE_REGISTRY_ACCESS_LEVEL_DEFAULT_BY_PROJECT_VISIBILITY[this.visibilityLevel] ?? + featureAccessLevel.NOT_ENABLED + ); + }, }, }; </script> @@ -897,10 +895,36 @@ export default { :help-text="$options.i18n.packageRegistryHelpText" data-testid="package-registry-access-level" > - <project-feature-setting - v-model="packageRegistryAccessLevel" + <gl-toggle + class="gl-my-2" + :value="packageRegistryEnabled" :label="$options.i18n.packageRegistryLabel" - :options="packageRegistryFeatureAccessLevelOptions" + label-position="hidden" + name="package_registry_enabled" + @change="onPackageRegistryEnabledToggle" + /> + <div + v-if="packageRegistryApiForEveryoneEnabledShown" + class="project-feature-setting-group gl-pl-7 gl-sm-pl-5 gl-my-3" + > + <project-setting-row + :label="$options.i18n.packageRegistryForEveryoneLabel" + :help-text="$options.i18n.packageRegistryForEveryoneHelpText" + > + <gl-toggle + class="gl-my-2" + :value="packageRegistryApiForEveryoneEnabled" + :disabled="!packageRegistryEnabled" + :label="$options.i18n.packageRegistryForEveryoneLabel" + label-position="hidden" + name="package_registry_api_for_everyone_enabled" + @change="onPackageRegistryApiForEveryoneEnabledToggle" + /> + </project-setting-row> + </div> + <input + :value="packageRegistryAccessLevel" + type="hidden" name="project[project_feature_attributes][package_registry_access_level]" /> </project-setting-row> @@ -923,11 +947,10 @@ export default { /> </project-setting-row> <project-setting-row - v-if="splitOperationsEnabled" ref="monitor-settings" :label="$options.i18n.monitorLabel" :help-text=" - s__('ProjectSettings|Configure your project resources and monitor their health.') + s__('ProjectSettings|Monitor the health of your project and respond to incidents.') " > <project-feature-setting @@ -937,21 +960,6 @@ export default { name="project[project_feature_attributes][monitor_access_level]" /> </project-setting-row> - <project-setting-row - v-else - ref="operations-settings" - :label="$options.i18n.operationsLabel" - :help-text=" - s__('ProjectSettings|Configure your project resources and monitor their health.') - " - > - <project-feature-setting - v-model="operationsAccessLevel" - :label="$options.i18n.operationsLabel" - :options="featureAccessLevelOptions" - name="project[project_feature_attributes][operations_access_level]" - /> - </project-setting-row> <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5"> <project-setting-row ref="metrics-visibility-settings" @@ -966,47 +974,45 @@ export default { /> </project-setting-row> </div> - <template v-if="splitOperationsEnabled"> - <project-setting-row - ref="environments-settings" + <project-setting-row + ref="environments-settings" + :label="$options.i18n.environmentsLabel" + :help-text="$options.i18n.environmentsHelpText" + :help-path="environmentsHelpPath" + > + <project-feature-setting + v-model="environmentsAccessLevel" :label="$options.i18n.environmentsLabel" - :help-text="$options.i18n.environmentsHelpText" - :help-path="environmentsHelpPath" - > - <project-feature-setting - v-model="environmentsAccessLevel" - :label="$options.i18n.environmentsLabel" - :options="featureAccessLevelOptions" - name="project[project_feature_attributes][environments_access_level]" - /> - </project-setting-row> - <project-setting-row - ref="feature-flags-settings" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][environments_access_level]" + /> + </project-setting-row> + <project-setting-row + ref="feature-flags-settings" + :label="$options.i18n.featureFlagsLabel" + :help-text="$options.i18n.featureFlagsHelpText" + :help-path="featureFlagsHelpPath" + > + <project-feature-setting + v-model="featureFlagsAccessLevel" :label="$options.i18n.featureFlagsLabel" - :help-text="$options.i18n.featureFlagsHelpText" - :help-path="featureFlagsHelpPath" - > - <project-feature-setting - v-model="featureFlagsAccessLevel" - :label="$options.i18n.featureFlagsLabel" - :options="featureAccessLevelOptions" - name="project[project_feature_attributes][feature_flags_access_level]" - /> - </project-setting-row> - <project-setting-row - ref="infrastructure-settings" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][feature_flags_access_level]" + /> + </project-setting-row> + <project-setting-row + ref="infrastructure-settings" + :label="$options.i18n.infrastructureLabel" + :help-text="$options.i18n.infrastructureHelpText" + :help-path="infrastructureHelpPath" + > + <project-feature-setting + v-model="infrastructureAccessLevel" :label="$options.i18n.infrastructureLabel" - :help-text="$options.i18n.infrastructureHelpText" - :help-path="infrastructureHelpPath" - > - <project-feature-setting - v-model="infrastructureAccessLevel" - :label="$options.i18n.infrastructureLabel" - :options="featureAccessLevelOptions" - name="project[project_feature_attributes][infrastructure_access_level]" - /> - </project-setting-row> - </template> + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][infrastructure_access_level]" + /> + </project-setting-row> <project-setting-row ref="releases-settings" :label="$options.i18n.releasesLabel" diff --git a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js index 5f08943d211..84ff802c268 100644 --- a/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js +++ b/app/assets/javascripts/pages/projects/shared/web_ide_link/index.js @@ -1,7 +1,15 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { joinPaths, webIDEUrl } from '~/lib/utils/url_utility'; import WebIdeButton from '~/vue_shared/components/web_ide_link.vue'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export default ({ el, router }) => { if (!el) return; @@ -9,15 +17,18 @@ export default ({ el, router }) => { const { projectPath, ref, isBlob, webIdeUrl, ...options } = convertObjectPropsToCamelCase( JSON.parse(el.dataset.options), ); + const { webIdePromoPopoverImg } = el.dataset; // eslint-disable-next-line no-new new Vue({ el, router, + apolloProvider, render(h) { return h(WebIdeButton, { props: { isBlob, + webIdePromoPopoverImg, webIdeUrl: isBlob ? webIdeUrl : webIDEUrl( diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js index 9ef1017f9f2..eb1f705eab9 100644 --- a/app/assets/javascripts/pages/projects/tags/new/index.js +++ b/app/assets/javascripts/pages/projects/tags/new/index.js @@ -1,8 +1,8 @@ import $ from 'jquery'; import GLForm from '~/gl_form'; -import RefSelectDropdown from '~/ref_select_dropdown'; import ZenMode from '~/zen_mode'; +import initNewTagRefSelector from '~/tags/init_new_tag_ref_selector'; +initNewTagRefSelector(); new ZenMode(); // eslint-disable-line no-new new GLForm($('.tag-form')); // eslint-disable-line no-new -new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index 897acf9b02c..eaafc0235a8 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -4,6 +4,7 @@ import NoEmojiValidator from '~/emoji/no_emoji_validator'; import LengthValidator from '~/pages/sessions/new/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; import EmailFormatValidator from '~/pages/sessions/new/email_format_validator'; +import { initLanguageSwitcher } from '~/language_switcher'; import Tracking from '~/tracking'; new UsernameValidator(); // eslint-disable-line no-new @@ -19,3 +20,5 @@ trackNewRegistrations(); Tracking.enableFormTracking({ forms: { allow: ['new_user'] }, }); + +initLanguageSwitcher(); diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index b62417cf595..a84ed5f01ad 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import initVueAlerts from '~/vue_alerts'; import NoEmojiValidator from '~/emoji/no_emoji_validator'; +import { initLanguageSwitcher } from '~/language_switcher'; import LengthValidator from './length_validator'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; @@ -20,3 +21,4 @@ new OAuthRememberMe({ // redirected to sign-in after attempting to access a protected URL that included a fragment. preserveUrlFragment(window.location.hash); initVueAlerts(); +initLanguageSwitcher(); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue index b72579276e8..b19809aff53 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_content.vue @@ -1,10 +1,11 @@ <script> -import { GlSkeletonLoader, GlSafeHtmlDirective, GlAlert } from '@gitlab/ui'; +import { GlSkeletonLoader, GlAlert } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { handleLocationHash } from '~/lib/utils/common_utils'; -import { renderGFM } from '../render_gfm_facade'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { components: { @@ -12,7 +13,7 @@ export default { GlAlert, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { getWikiContentUrl: { @@ -86,9 +87,9 @@ export default { <div v-else-if="!loadingContentFailed && !isLoadingContent" ref="content" + v-safe-html="content" data-qa-selector="wiki_page_content" data-testid="wiki-page-content" class="js-wiki-page-content md" - v-html="content /* eslint-disable-line vue/no-v-html */" ></div> </template> diff --git a/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js b/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js deleted file mode 100644 index 90cc2983153..00000000000 --- a/app/assets/javascripts/pages/shared/wikis/render_gfm_facade.js +++ /dev/null @@ -1,5 +0,0 @@ -import $ from 'jquery'; - -export const renderGFM = (el) => { - return $(el).renderGFM(); -}; diff --git a/app/assets/javascripts/pages/web_ide/remote_ide/index.js b/app/assets/javascripts/pages/web_ide/remote_ide/index.js new file mode 100644 index 00000000000..463798e85b9 --- /dev/null +++ b/app/assets/javascripts/pages/web_ide/remote_ide/index.js @@ -0,0 +1,3 @@ +import { mountRemoteIDE } from '~/ide/remote'; + +mountRemoteIDE(document.getElementById('ide')); diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 0640faae8b7..ea8005e8dfb 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective, GlCollapsibleListbox } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { sortOrders, sortOrderOptions } from '../constants'; @@ -9,9 +9,8 @@ export default { components: { RequestWarning, GlButton, - GlDropdown, - GlDropdownItem, GlModal, + GlCollapsibleListbox, }, directives: { 'gl-modal': GlModalDirective, @@ -119,9 +118,6 @@ export default { itemHasOpenedBacktrace(toggledIndex) { return this.openedBacktraces.find((openedIndex) => openedIndex === toggledIndex) >= 0; }, - changeSortOrder(order) { - this.sortOrder = order; - }, sortDetailByDuration(a, b) { return a.duration < b.duration ? 1 : -1; }, @@ -157,19 +153,14 @@ export default { </div> </div> </div> - <gl-dropdown + <gl-collapsible-listbox v-if="displaySortOrder" - :text="$options.sortOrderOptions[sortOrder]" + v-model="sortOrder" + :toggle-text="$options.sortOrderOptions[sortOrder].text" + :items="Object.values($options.sortOrderOptions)" right data-testid="performance-bar-sort-order" - > - <gl-dropdown-item - v-for="option in Object.keys($options.sortOrderOptions)" - :key="option" - @click="changeSortOrder(option)" - >{{ $options.sortOrderOptions[option] }}</gl-dropdown-item - > - </gl-dropdown> + /> </div> <hr /> <table class="table gl-table"> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index a5fa85f1ed5..dbca8bc9be7 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { glEmojiTag } from '~/emoji'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -15,7 +15,7 @@ export default { RequestSelector, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { store: { diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue index 3ebd222029b..91e905d62e6 100644 --- a/app/assets/javascripts/performance_bar/components/request_warning.vue +++ b/app/assets/javascripts/performance_bar/components/request_warning.vue @@ -1,5 +1,6 @@ <script> -import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlPopover } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { glEmojiTag } from '~/emoji'; export default { @@ -7,7 +8,7 @@ export default { GlPopover, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { htmlId: { diff --git a/app/assets/javascripts/performance_bar/constants.js b/app/assets/javascripts/performance_bar/constants.js index 09745797424..6f4ddd5c242 100644 --- a/app/assets/javascripts/performance_bar/constants.js +++ b/app/assets/javascripts/performance_bar/constants.js @@ -6,6 +6,12 @@ export const sortOrders = { }; export const sortOrderOptions = { - [sortOrders.DURATION]: s__('PerformanceBar|Sort by duration'), - [sortOrders.CHRONOLOGICAL]: s__('PerformanceBar|Sort chronologically'), + [sortOrders.DURATION]: { + value: sortOrders.DURATION, + text: s__('PerformanceBar|Sort by duration'), + }, + [sortOrders.CHRONOLOGICAL]: { + value: sortOrders.CHRONOLOGICAL, + text: s__('PerformanceBar|Sort chronologically'), + }, }; diff --git a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue deleted file mode 100644 index cd7cb7f8393..00000000000 --- a/app/assets/javascripts/pipeline_new/components/legacy_pipeline_new_form.vue +++ /dev/null @@ -1,490 +0,0 @@ -<script> -import { - GlAlert, - GlIcon, - GlButton, - GlDropdown, - GlDropdownItem, - GlForm, - GlFormGroup, - GlFormInput, - GlFormTextarea, - GlLink, - GlSprintf, - GlLoadingIcon, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; -import { uniqueId } from 'lodash'; -import Vue from 'vue'; -import axios from '~/lib/utils/axios_utils'; -import { backOff } from '~/lib/utils/common_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; -import { redirectTo } from '~/lib/utils/url_utility'; -import { s__, __, n__ } from '~/locale'; -import { - VARIABLE_TYPE, - FILE_TYPE, - CONFIG_VARIABLES_TIMEOUT, - CC_VALIDATION_REQUIRED_ERROR, -} from '../constants'; -import filterVariables from '../utils/filter_variables'; -import RefsDropdown from './refs_dropdown.vue'; - -const i18n = { - variablesDescription: s__( - 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', - ), - defaultError: __('Something went wrong on our end. Please try again.'), - refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'), - submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'), - warningTitle: __('The form contains the following warning:'), - maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), - removeVariableLabel: s__('CiVariables|Remove variable'), -}; - -export default { - typeOptions: { - [VARIABLE_TYPE]: __('Variable'), - [FILE_TYPE]: __('File'), - }, - i18n, - formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', - // this height value is used inline on the textarea to match the input field height - // it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used - textAreaStyle: { height: '32px' }, - components: { - GlAlert, - GlIcon, - GlButton, - GlDropdown, - GlDropdownItem, - GlForm, - GlFormGroup, - GlFormInput, - GlFormTextarea, - GlLink, - GlSprintf, - GlLoadingIcon, - RefsDropdown, - CcValidationRequiredAlert: () => - import('ee_component/billings/components/cc_validation_required_alert.vue'), - }, - directives: { SafeHtml }, - props: { - pipelinesPath: { - type: String, - required: true, - }, - configVariablesPath: { - type: String, - required: true, - }, - defaultBranch: { - type: String, - required: true, - }, - projectId: { - type: String, - required: true, - }, - settingsLink: { - type: String, - required: true, - }, - fileParams: { - type: Object, - required: false, - default: () => ({}), - }, - refParam: { - type: String, - required: false, - default: '', - }, - variableParams: { - type: Object, - required: false, - default: () => ({}), - }, - maxWarnings: { - type: Number, - required: true, - }, - }, - data() { - return { - refValue: { - shortName: this.refParam, - }, - form: {}, - errorTitle: null, - error: null, - warnings: [], - totalWarnings: 0, - isWarningDismissed: false, - isLoading: false, - submitted: false, - ccAlertDismissed: false, - }; - }, - computed: { - overMaxWarningsLimit() { - return this.totalWarnings > this.maxWarnings; - }, - warningsSummary() { - return n__('%d warning found:', '%d warnings found:', this.warnings.length); - }, - summaryMessage() { - return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary; - }, - shouldShowWarning() { - return this.warnings.length > 0 && !this.isWarningDismissed; - }, - refShortName() { - return this.refValue.shortName; - }, - refFullName() { - return this.refValue.fullName; - }, - variables() { - return this.form[this.refFullName]?.variables ?? []; - }, - descriptions() { - return this.form[this.refFullName]?.descriptions ?? {}; - }, - ccRequiredError() { - return this.error === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed; - }, - }, - watch: { - refValue() { - this.loadConfigVariablesForm(); - }, - }, - created() { - // this is needed until we add support for ref type in url query strings - // ensure default branch is called with full ref on load - // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 - if (this.refValue.shortName === this.defaultBranch) { - this.refValue.fullName = `refs/heads/${this.refValue.shortName}`; - } - - this.loadConfigVariablesForm(); - }, - methods: { - addEmptyVariable(refValue) { - const { variables } = this.form[refValue]; - - const lastVar = variables[variables.length - 1]; - if (lastVar?.key === '' && lastVar?.value === '') { - return; - } - - variables.push({ - uniqueId: uniqueId(`var-${refValue}`), - variable_type: VARIABLE_TYPE, - key: '', - value: '', - }); - }, - setVariable(refValue, type, key, value) { - const { variables } = this.form[refValue]; - - const variable = variables.find((v) => v.key === key); - if (variable) { - variable.type = type; - variable.value = value; - } else { - variables.push({ - uniqueId: uniqueId(`var-${refValue}`), - key, - value, - variable_type: type, - }); - } - }, - setVariableType(key, type) { - const { variables } = this.form[this.refFullName]; - const variable = variables.find((v) => v.key === key); - variable.variable_type = type; - }, - setVariableParams(refValue, type, paramsObj) { - Object.entries(paramsObj).forEach(([key, value]) => { - this.setVariable(refValue, type, key, value); - }); - }, - removeVariable(index) { - this.variables.splice(index, 1); - }, - canRemove(index) { - return index < this.variables.length - 1; - }, - loadConfigVariablesForm() { - // Skip when variables already cached in `form` - if (this.form[this.refFullName]) { - return; - } - - this.fetchConfigVariables(this.refFullName || this.refShortName) - .then(({ descriptions, params }) => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions, - }); - - // Add default variables from yml - this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); - }) - .catch(() => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions: {}, - }); - }) - .finally(() => { - // Add/update variables, e.g. from query string - if (this.variableParams) { - this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); - } - if (this.fileParams) { - this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); - } - - // Adds empty var at the end of the form - this.addEmptyVariable(this.refFullName); - }); - }, - fetchConfigVariables(refValue) { - this.isLoading = true; - - return backOff((next, stop) => { - axios - .get(this.configVariablesPath, { - params: { - sha: refValue, - }, - }) - .then(({ data, status }) => { - if (status === httpStatusCodes.NO_CONTENT) { - next(); - } else { - this.isLoading = false; - stop(data); - } - }) - .catch((error) => { - stop(error); - }); - }, CONFIG_VARIABLES_TIMEOUT) - .then((data) => { - const params = {}; - const descriptions = {}; - - Object.entries(data).forEach(([key, { value, description }]) => { - if (description) { - params[key] = value; - descriptions[key] = description; - } - }); - - return { params, descriptions }; - }) - .catch((error) => { - this.isLoading = false; - - Sentry.captureException(error); - - return { params: {}, descriptions: {} }; - }); - }, - createPipeline() { - this.submitted = true; - this.ccAlertDismissed = false; - - return axios - .post(this.pipelinesPath, { - // send shortName as fall back for query params - // https://gitlab.com/gitlab-org/gitlab/-/issues/287815 - ref: this.refValue.fullName || this.refShortName, - variables_attributes: filterVariables(this.variables), - }) - .then(({ data }) => { - redirectTo(`${this.pipelinesPath}/${data.id}`); - }) - .catch((err) => { - // always re-enable submit button - this.submitted = false; - - const { - errors = [], - warnings = [], - total_warnings: totalWarnings = 0, - } = err.response.data; - const [error] = errors; - - this.reportError({ - title: i18n.submitErrorTitle, - error, - warnings, - totalWarnings, - }); - }); - }, - onRefsLoadingError(error) { - this.reportError({ title: i18n.refsLoadingErrorTitle }); - - Sentry.captureException(error); - }, - reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) { - this.errorTitle = title; - this.error = error; - this.warnings = warnings; - this.totalWarnings = totalWarnings; - }, - dismissError() { - this.ccAlertDismissed = true; - this.error = null; - }, - }, -}; -</script> - -<template> - <gl-form @submit.prevent="createPipeline"> - <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" @dismiss="dismissError" /> - <gl-alert - v-else-if="error" - :title="errorTitle" - :dismissible="false" - variant="danger" - class="gl-mb-4" - data-testid="run-pipeline-error-alert" - > - <span v-safe-html="error"></span> - </gl-alert> - <gl-alert - v-if="shouldShowWarning" - :title="$options.i18n.warningTitle" - variant="warning" - class="gl-mb-4" - data-testid="run-pipeline-warning-alert" - @dismiss="isWarningDismissed = true" - > - <details> - <summary> - <gl-sprintf :message="summaryMessage"> - <template #total> - {{ totalWarnings }} - </template> - <template #warningsDisplayed> - {{ maxWarnings }} - </template> - </gl-sprintf> - </summary> - <p - v-for="(warning, index) in warnings" - :key="`warning-${index}`" - data-testid="run-pipeline-warning" - > - {{ warning }} - </p> - </details> - </gl-alert> - <gl-form-group :label="s__('Pipeline|Run for branch name or tag')"> - <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" /> - </gl-form-group> - - <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> - - <gl-form-group v-else :label="s__('Pipeline|Variables')"> - <div - v-for="(variable, index) in variables" - :key="variable.uniqueId" - class="gl-mb-3 gl-pb-2" - data-testid="ci-variable-row" - data-qa-selector="ci_variable_row_container" - > - <div - class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row" - > - <gl-dropdown - :text="$options.typeOptions[variable.variable_type]" - :class="$options.formElementClasses" - data-testid="pipeline-form-ci-variable-type" - > - <gl-dropdown-item - v-for="type in Object.keys($options.typeOptions)" - :key="type" - @click="setVariableType(variable.key, type)" - > - {{ $options.typeOptions[type] }} - </gl-dropdown-item> - </gl-dropdown> - <gl-form-input - v-model="variable.key" - :placeholder="s__('CiVariables|Input variable key')" - :class="$options.formElementClasses" - data-testid="pipeline-form-ci-variable-key" - data-qa-selector="ci_variable_key_field" - @change="addEmptyVariable(refFullName)" - /> - <gl-form-textarea - v-model="variable.value" - :placeholder="s__('CiVariables|Input variable value')" - class="gl-mb-3" - :style="$options.textAreaStyle" - :no-resize="false" - data-testid="pipeline-form-ci-variable-value" - data-qa-selector="ci_variable_value_field" - /> - - <template v-if="variables.length > 1"> - <gl-button - v-if="canRemove(index)" - class="gl-md-ml-3 gl-mb-3" - data-testid="remove-ci-variable-row" - variant="danger" - category="secondary" - :aria-label="$options.i18n.removeVariableLabel" - @click="removeVariable(index)" - > - <gl-icon class="gl-mr-0! gl-display-none gl-md-display-block" name="clear" /> - <span class="gl-md-display-none">{{ $options.i18n.removeVariableLabel }}</span> - </gl-button> - <gl-button - v-else - class="gl-md-ml-3 gl-mb-3 gl-display-none gl-md-display-block gl-visibility-hidden" - icon="clear" - :aria-label="$options.i18n.removeVariableLabel" - /> - </template> - </div> - <div v-if="descriptions[variable.key]" class="gl-text-gray-500 gl-mb-3"> - {{ descriptions[variable.key] }} - </div> - </div> - - <template #description - ><gl-sprintf :message="$options.i18n.variablesDescription"> - <template #link="{ content }"> - <gl-link :href="settingsLink">{{ content }}</gl-link> - </template> - </gl-sprintf></template - > - </gl-form-group> - <div class="gl-pt-5 gl-display-flex"> - <gl-button - type="submit" - category="primary" - variant="confirm" - class="js-no-auto-disable gl-mr-3" - data-qa-selector="run_pipeline_button" - data-testid="run_pipeline_button" - :disabled="submitted" - >{{ s__('Pipeline|Run pipeline') }}</gl-button - > - <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button> - </div> - </gl-form> -</template> diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index a9af1181027..5692627abef 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -12,11 +12,11 @@ import { GlLink, GlSprintf, GlLoadingIcon, - GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; import { VARIABLE_TYPE, FILE_TYPE, CC_VALIDATION_REQUIRED_ERROR } from '../constants'; @@ -400,11 +400,13 @@ export default { :class="$options.formElementClasses" class="gl-flex-grow-1 gl-mr-0!" data-testid="pipeline-form-ci-variable-value-dropdown" + data-qa-selector="ci_variable_value_dropdown" > <gl-dropdown-item v-for="value in predefinedValueOptions[variable.key]" :key="value" data-testid="pipeline-form-ci-variable-value-dropdown-items" + data-qa-selector="ci_variable_value_dropdown_item" @click="setVariableAttribute(variable.key, 'value', value)" > {{ value }} diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index 60b4c93d1d5..71c76aeab36 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -1,53 +1,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import LegacyPipelineNewForm from './components/legacy_pipeline_new_form.vue'; import PipelineNewForm from './components/pipeline_new_form.vue'; import { resolvers } from './graphql/resolvers'; -const mountLegacyPipelineNewForm = (el) => { - const { - // provide/inject - projectRefsEndpoint, - - // props - configVariablesPath, - defaultBranch, - fileParam, - maxWarnings, - pipelinesPath, - projectId, - refParam, - settingsLink, - varParam, - } = el.dataset; - - const variableParams = JSON.parse(varParam); - const fileParams = JSON.parse(fileParam); - - return new Vue({ - el, - provide: { - projectRefsEndpoint, - }, - render(createElement) { - return createElement(LegacyPipelineNewForm, { - props: { - configVariablesPath, - defaultBranch, - fileParams, - maxWarnings: Number(maxWarnings), - pipelinesPath, - projectId, - refParam, - settingsLink, - variableParams, - }, - }); - }, - }); -}; - const mountPipelineNewForm = (el) => { const { // provide/inject @@ -101,9 +57,5 @@ const mountPipelineNewForm = (el) => { export default () => { const el = document.getElementById('js-new-pipeline'); - if (gon.features?.runPipelineGraphql) { - mountPipelineNewForm(el); - } else { - mountLegacyPipelineNewForm(el); - } + mountPipelineNewForm(el); }; diff --git a/app/assets/javascripts/pipeline_wizard/components/step_nav.vue b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue index 8f9198855c6..e3d825bbcc7 100644 --- a/app/assets/javascripts/pipeline_wizard/components/step_nav.vue +++ b/app/assets/javascripts/pipeline_wizard/components/step_nav.vue @@ -27,12 +27,13 @@ export default { </script> <template> - <div> + <div class="gl-display-flex"> <slot name="before"></slot> <gl-button v-if="showBackButton" category="secondary" data-testid="back-button" + class="gl-mr-3" @click="$emit('back')" > {{ __('Back') }} diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index f822e2c0874..4d7596e6e16 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -148,12 +148,13 @@ export default { reportMessageToSentry( this.$options.name, - `| type: ${LOAD_FAILURE} , info: ${serializeLoadErrors(err)}`, + `| type: ${LOAD_FAILURE} , info: ${JSON.stringify(err)}`, { + graphViewType: this.graphViewType, + graphqlResourceEtag: this.graphqlResourceEtag, + metricsPath: this.metricsPath, projectPath: this.pipelineProjectPath, pipelineIid: this.pipelineIid, - pipelineStages: this.pipeline?.stages?.length || 0, - nbOfDownstreams: this.pipeline?.downstream?.length || 0, }, ); }, diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 377f21b299f..4f2be27486c 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -252,7 +252,7 @@ export default { @click="jobItemClick" @mouseout="hideTooltips" > - <div class="ci-job-name-component gl-display-flex gl-align-items-center"> + <div class="gl-display-flex gl-align-items-center gl-flex-grow-1"> <ci-icon :size="24" :status="job.status" class="gl-line-height-0" /> <div class="gl-pl-3 gl-pr-3 gl-display-flex gl-flex-direction-column gl-pipeline-job-width"> <div class="gl-text-truncate gl-pr-9 gl-line-height-normal">{{ job.name }}</div> diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue index 18607bfae1c..c56537f4039 100644 --- a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlLink, GlSafeHtmlDirective, GlTableLite } from '@gitlab/ui'; +import { GlButton, GlLink, GlTableLite } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __, s__ } from '~/locale'; import { createAlert } from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; @@ -17,7 +18,7 @@ export default { GlTableLite, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { failedJobs: { diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index 7ee5ec48f44..387b01aee7e 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -70,7 +70,6 @@ export default { axios .post(`${this.link}.json`) .then(() => { - this.isDisabled = false; this.isLoading = false; this.$emit('pipelineActionRequestComplete'); diff --git a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue index f4fc6893520..1c7f5a7476d 100644 --- a/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue @@ -29,7 +29,7 @@ export default { }; </script> <template> - <span class="ci-job-name-component mw-100 gl-display-flex gl-align-items-center"> + <span class="mw-100 gl-display-flex gl-align-items-center gl-flex-grow-1"> <ci-icon :size="iconSize" :status="status" class="gl-line-height-0" /> <span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block"> {{ name }} diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue index 211c5f117c7..51b46f25048 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/job_item.vue @@ -137,9 +137,6 @@ export default { hideTooltips() { this.$root.$emit(BV_HIDE_TOOLTIP); }, - pipelineActionRequestComplete() { - this.$emit('pipelineActionRequestComplete'); - }, }, }; </script> @@ -163,7 +160,7 @@ export default { @click.stop="hideTooltips" @mouseout="hideTooltips" > - <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> + <job-name-component :name="job.name" :status="job.status" /> </gl-link> <div @@ -175,7 +172,7 @@ export default { data-testid="job-without-link" @mouseout="hideTooltips" > - <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> + <job-name-component :name="job.name" :status="job.status" /> </div> <action-component @@ -184,7 +181,6 @@ export default { :link="status.action.path" :action-icon="status.action.icon" data-qa-selector="action_button" - @pipelineActionRequestComplete="pipelineActionRequestComplete" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue index 993fa121d89..827adf9f7f7 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue @@ -35,11 +35,6 @@ export default { required: true, default: () => [], }, - stagesClass: { - type: [Array, Object, String], - required: false, - default: '', - }, updateDropdown: { type: Boolean, required: false, @@ -56,15 +51,10 @@ export default { return Boolean(this.downstreamPipelines.length); }, }, - methods: { - onPipelineActionRequestComplete() { - this.$emit('pipelineActionRequestComplete'); - }, - }, }; </script> <template> - <div class="stage-cell" data-testid="pipeline-mini-graph"> + <div data-testid="pipeline-mini-graph"> <linked-pipelines-mini-list v-if="upstreamPipeline" :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ @@ -82,9 +72,7 @@ export default { :is-merge-train="isMergeTrain" :stages="stages" :update-dropdown="updateDropdown" - :stages-class="stagesClass" data-testid="pipeline-stages" - @pipelineActionRequestComplete="onPipelineActionRequestComplete" @miniGraphStageClick="$emit('miniGraphStageClick')" /> <gl-icon diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue index ba150919e58..ec42b738e03 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stage.vue @@ -100,13 +100,6 @@ export default { }); }); }, - pipelineActionRequestComplete() { - // close the dropdown in MR widget - this.$refs.dropdown.hide(); - - // warn the pipelines table to update - this.$emit('pipelineActionRequestComplete'); - }, stageAriaLabel(title) { return sprintf(__('View Stage: %{title}'), { title }); }, @@ -149,7 +142,7 @@ export default { class="js-builds-dropdown-list scrollable-menu" data-testid="mini-pipeline-graph-dropdown-menu-list" > - <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-pb-3"> + <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-mb-3 gl-pb-3"> <span class="gl-mr-1">{{ $options.i18n.stage }}</span> <span data-testid="pipeline-stage-dropdown-menu-title">{{ stageName }}</span> </div> @@ -158,11 +151,10 @@ export default { :dropdown-length="dropdownContent.length" :job="job" css-class-job-name="mini-pipeline-graph-dropdown-item" - @pipelineActionRequestComplete="pipelineActionRequestComplete" /> </li> <template v-if="isMergeTrain"> - <li class="gl-new-dropdown-divider" role="presentation"> + <li class="gl-dropdown-divider" role="presentation"> <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" /> </li> <li> diff --git a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue index e965dc5e6b0..ba549d9b423 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_mini_graph/pipeline_stages.vue @@ -17,22 +17,12 @@ export default { required: false, default: false, }, - stagesClass: { - type: [Array, Object, String], - required: false, - default: '', - }, isMergeTrain: { type: Boolean, required: false, default: false, }, }, - methods: { - onPipelineActionRequestComplete() { - this.$emit('pipelineActionRequestComplete'); - }, - }, }; </script> <template> @@ -40,14 +30,12 @@ export default { <div v-for="stage in stages" :key="stage.name" - :class="stagesClass" - class="dropdown gl-display-inline-block gl-mr-2 gl-my-2 gl-vertical-align-middle stage-container" + class="pipeline-mini-graph-stage-container dropdown gl-display-inline-block gl-mr-2 gl-my-2 gl-vertical-align-middle" > <pipeline-stage :stage="stage" :update-dropdown="updateDropdown" :is-merge-train="isMergeTrain" - @pipelineActionRequestComplete="onPipelineActionRequestComplete" @miniGraphStageClick="$emit('miniGraphStageClick')" /> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue index 3eafb36bd1d..03a2eac89e4 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue @@ -8,7 +8,7 @@ import { RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, I18N, -} from '~/pipeline_editor/constants'; +} from '~/ci/pipeline_editor/constants'; import Tracking from '~/tracking'; import { helpPagePath } from '~/helpers/help_page_helper'; import { isExperimentVariant } from '~/experimentation/utils'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index af089aebbbe..7dc1e60610e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue @@ -3,7 +3,7 @@ import { GlFilteredSearch } from '@gitlab/ui'; import { map } from 'lodash'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { TRACKING_CATEGORIES } from '../../constants'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; import PipelineSourceToken from './tokens/pipeline_source_token.vue'; @@ -54,7 +54,7 @@ export default { title: s__('Pipeline|Trigger author'), unique: true, token: PipelineTriggerAuthorToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, projectId: this.projectId, }, { @@ -63,7 +63,7 @@ export default { title: s__('Pipeline|Branch name'), unique: true, token: PipelineBranchNameToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, projectId: this.projectId, defaultBranchName: this.defaultBranchName, disabled: this.selectedTypes.includes(this.$options.tagType), @@ -74,7 +74,7 @@ export default { title: s__('Pipeline|Tag name'), unique: true, token: PipelineTagNameToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, projectId: this.projectId, disabled: this.selectedTypes.includes(this.$options.branchType), }, @@ -84,7 +84,7 @@ export default { title: s__('Pipeline|Status'), unique: true, token: PipelineStatusToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, { type: this.$options.sourceType, @@ -92,7 +92,7 @@ export default { title: s__('Pipeline|Source'), unique: true, token: PipelineSourceToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, ]; }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index f6e46c090d3..346f5735576 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -124,9 +124,6 @@ export default { eventHub.$emit('postAction', this.endpoint); this.cancelingPipeline = this.pipelineId; }, - onPipelineActionRequestComplete() { - eventHub.$emit('refreshPipelinesTable'); - }, trackPipelineMiniGraph() { this.track('click_minigraph', { label: TRACKING_CATEGORIES.table }); }, @@ -179,7 +176,6 @@ export default { :stages="item.details.stages" :update-dropdown="updateGraphDropdown" :upstream-pipeline="item.triggered_by" - @pipelineActionRequestComplete="onPipelineActionRequestComplete" @miniGraphStageClick="trackPipelineMiniGraph" /> </template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index e5666f7a658..3f2c013d44a 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -1,8 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import createTestReportsStore from '../../stores/test_reports'; import EmptyState from './empty_state.vue'; import TestSuiteTable from './test_suite_table.vue'; import TestSummary from './test_summary.vue'; @@ -17,7 +15,6 @@ export default { TestSummary, TestSummaryTable, }, - mixins: [glFeatureFlagMixin()], inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'], computed: { ...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']), @@ -31,17 +28,6 @@ export default { }, }, created() { - if (!this.glFeatures.pipelineTabsVue) { - this.$store.registerModule( - 'testReports', - createTestReportsStore({ - blobPath: this.blobPath, - summaryEndpoint: this.summaryEndpoint, - suiteEndpoint: this.suiteEndpoint, - }), - ); - } - this.fetchSummary(); }, methods: { diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index 9602ca1ba88..07551c2342f 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -55,7 +55,6 @@ export default { eventHub.$on('retryPipeline', this.postAction); eventHub.$on('clickedDropdown', this.updateTable); eventHub.$on('updateTable', this.updateTable); - eventHub.$on('refreshPipelinesTable', this.fetchPipelines); eventHub.$on('runMergeRequestPipeline', this.runMergeRequestPipeline); }, beforeDestroy() { @@ -63,7 +62,6 @@ export default { eventHub.$off('retryPipeline', this.postAction); eventHub.$off('clickedDropdown', this.updateTable); eventHub.$off('updateTable', this.updateTable); - eventHub.$off('refreshPipelinesTable', this.fetchPipelines); eventHub.$off('runMergeRequestPipeline', this.runMergeRequestPipeline); }, destroyed() { diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 1bbdd3625be..f00378733fc 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,36 +1,33 @@ import VueRouter from 'vue-router'; import { createAlert } from '~/flash'; -import { __, s__ } from '~/locale'; -import createDagApp from './pipeline_details_dag'; -import { createPipelinesDetailApp } from './pipeline_details_graph'; +import { __ } from '~/locale'; import { createPipelineHeaderApp } from './pipeline_details_header'; -import { createPipelineJobsApp } from './pipeline_details_jobs'; -import { createPipelineFailedJobsApp } from './pipeline_details_failed_jobs'; import { apolloProvider } from './pipeline_shared_client'; -import { createTestDetails } from './pipeline_test_details'; const SELECTORS = { - PIPELINE_DETAILS: '.js-pipeline-details-vue', - PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', PIPELINE_TABS: '#js-pipeline-tabs', - PIPELINE_TESTS: '#js-pipeline-tests-detail', - PIPELINE_JOBS: '#js-pipeline-jobs-vue', - PIPELINE_FAILED_JOBS: '#js-pipeline-failed-jobs-vue', }; export default async function initPipelineDetailsBundle() { - const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); + const { dataset: headerDataset } = document.querySelector(SELECTORS.PIPELINE_HEADER); try { - createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); + createPipelineHeaderApp( + SELECTORS.PIPELINE_HEADER, + apolloProvider, + headerDataset.graphqlResourceEtag, + ); } catch { createAlert({ message: __('An error occurred while loading a section of this page.'), }); } - if (gon.features?.pipelineTabsVue) { + const tabsEl = document.querySelector(SELECTORS.PIPELINE_TABS); + + if (tabsEl) { + const { dataset } = tabsEl; const { createAppOptions } = await import('ee_else_ce/pipelines/pipeline_tabs'); const { createPipelineTabs } = await import('./pipeline_tabs'); const { routes } = await import('ee_else_ce/pipelines/routes'); @@ -49,45 +46,5 @@ export default async function initPipelineDetailsBundle() { message: __('An error occurred while loading a section of this page.'), }); } - } else { - try { - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); - } catch { - createAlert({ - message: __('An error occurred while loading the pipeline.'), - }); - } - - try { - createDagApp(apolloProvider); - } catch { - createAlert({ - message: __('An error occurred while loading the Needs tab.'), - }); - } - - try { - createTestDetails(SELECTORS.PIPELINE_TESTS); - } catch { - createAlert({ - message: __('An error occurred while loading the Test Reports tab.'), - }); - } - - try { - createPipelineJobsApp(SELECTORS.PIPELINE_JOBS); - } catch { - createAlert({ - message: __('An error occurred while loading the Jobs tab.'), - }); - } - - try { - createPipelineFailedJobsApp(SELECTORS.PIPELINE_FAILED_JOBS); - } catch { - createAlert({ - message: s__('Jobs|An error occurred while loading the Failed Jobs tab.'), - }); - } } } diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js deleted file mode 100644 index b2cb0457c4d..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_dag.js +++ /dev/null @@ -1,42 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import Dag from './components/dag/dag.vue'; - -Vue.use(VueApollo); - -const createDagApp = (apolloProvider) => { - const el = document.querySelector('#js-pipeline-dag-vue'); - - if (!el) { - return; - } - - const { - aboutDagDocPath, - dagDocPath, - emptySvgPath, - pipelineProjectPath, - pipelineIid, - } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - Dag, - }, - apolloProvider, - provide: { - aboutDagDocPath, - dagDocPath, - emptySvgPath, - pipelineProjectPath, - pipelineIid, - }, - render(createElement) { - return createElement('dag', {}); - }, - }); -}; - -export default createDagApp; diff --git a/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js deleted file mode 100644 index 7bf3b64bf47..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_failed_jobs.js +++ /dev/null @@ -1,36 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import FailedJobsApp from './components/jobs/failed_jobs_app.vue'; - -Vue.use(VueApollo); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export const createPipelineFailedJobsApp = (selector) => { - const containerEl = document.querySelector(selector); - - if (!containerEl) { - return false; - } - - const { fullPath, pipelineIid, failedJobsSummaryData } = containerEl.dataset; - - return new Vue({ - el: containerEl, - apolloProvider, - provide: { - fullPath, - pipelineIid, - }, - render(createElement) { - return createElement(FailedJobsApp, { - props: { - failedJobsSummary: JSON.parse(failedJobsSummaryData), - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js deleted file mode 100644 index 9dd5cd7b281..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue'; -import { reportToSentry } from './utils'; - -Vue.use(VueApollo); - -const createPipelinesDetailApp = ( - selector, - apolloProvider, - { pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {}, -) => { - // eslint-disable-next-line no-new - new Vue({ - el: selector, - components: { - PipelineGraphWrapper, - }, - apolloProvider, - provide: { - metricsPath, - pipelineProjectPath, - pipelineIid, - graphqlResourceEtag, - }, - errorCaptured(err, _vm, info) { - reportToSentry('pipeline_details_graph', `error: ${err}, info: ${info}`); - }, - render(createElement) { - return createElement(PipelineGraphWrapper); - }, - }); -}; - -export { createPipelinesDetailApp }; diff --git a/app/assets/javascripts/pipelines/pipeline_details_jobs.js b/app/assets/javascripts/pipelines/pipeline_details_jobs.js deleted file mode 100644 index a1294a484f0..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_jobs.js +++ /dev/null @@ -1,34 +0,0 @@ -import { GlToast } from '@gitlab/ui'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; -import JobsApp from './components/jobs/jobs_app.vue'; - -Vue.use(VueApollo); -Vue.use(GlToast); - -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export const createPipelineJobsApp = (selector) => { - const containerEl = document.querySelector(selector); - - if (!containerEl) { - return false; - } - - const { fullPath, pipelineIid } = containerEl.dataset; - - return new Vue({ - el: containerEl, - apolloProvider, - provide: { - fullPath, - pipelineIid, - }, - render(createElement) { - return createElement(JobsApp); - }, - }); -}; diff --git a/app/assets/javascripts/pipelines/pipeline_test_details.js b/app/assets/javascripts/pipelines/pipeline_test_details.js deleted file mode 100644 index fe4ca8e9529..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_test_details.js +++ /dev/null @@ -1,40 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import Translate from '~/vue_shared/translate'; -import TestReports from './components/test_reports/test_reports.vue'; - -Vue.use(Vuex); -Vue.use(Translate); - -export const createTestDetails = (selector) => { - const el = document.querySelector(selector); - const { - blobPath, - emptyStateImagePath, - hasTestReport, - summaryEndpoint, - suiteEndpoint, - artifactsExpiredImagePath, - } = el?.dataset || {}; - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - TestReports, - }, - provide: { - emptyStateImagePath, - artifactsExpiredImagePath, - hasTestReport: parseBoolean(hasTestReport), - blobPath, - summaryEndpoint, - suiteEndpoint, - }, - store: new Vuex.Store(), - render(createElement) { - return createElement('test-reports'); - }, - }); -}; diff --git a/app/assets/javascripts/popovers/components/popovers.vue b/app/assets/javascripts/popovers/components/popovers.vue index a758503b56b..7ec54231e65 100644 --- a/app/assets/javascripts/popovers/components/popovers.vue +++ b/app/assets/javascripts/popovers/components/popovers.vue @@ -1,5 +1,6 @@ <script> -import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlPopover } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; const newPopover = (element) => { const { content, html, placement, title, triggers = 'focus' } = element.dataset; @@ -19,7 +20,7 @@ export default { GlPopover, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, data() { return { diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index b038b78088f..51e62984715 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -1,6 +1,7 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { escape } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert, VARIANT_INFO } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, s__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue index 52da8aaba4d..a037e721677 100644 --- a/app/assets/javascripts/projects/commit/components/branches_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/branches_dropdown.vue @@ -28,6 +28,11 @@ export default { required: false, default: '', }, + blanked: { + type: Boolean, + required: false, + default: false, + }, }, i18n: { noResultsMessage: I18N_NO_RESULTS_MESSAGE, @@ -36,7 +41,7 @@ export default { }, data() { return { - searchTerm: this.value, + searchTerm: this.blanked ? '' : this.value, }; }, computed: { diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index d9aaa574fec..1febe8ceaab 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -41,6 +41,11 @@ export default { required: false, default: false, }, + isRevert: { + type: Boolean, + required: false, + default: false, + }, primaryActionEventName: { type: String, required: false, @@ -150,7 +155,12 @@ export default { > <input id="start_branch" type="hidden" name="start_branch" :value="branch" /> - <branches-dropdown class="gl-w-half" :value="branch" @selectBranch="setBranch" /> + <branches-dropdown + class="gl-w-half" + :value="branch" + :blanked="isRevert" + @selectBranch="setBranch" + /> </gl-form-group> <gl-form-checkbox diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js index 849b2f4858c..41be71932e5 100644 --- a/app/assets/javascripts/projects/commit/init_revert_commit_modal.js +++ b/app/assets/javascripts/projects/commit/init_revert_commit_modal.js @@ -49,6 +49,7 @@ export default function initInviteMembersModal(primaryActionEventName) { i18n: { ...I18N_REVERT_MODAL, ...I18N_MODAL }, openModal: OPEN_REVERT_MODAL, modalId: REVERT_MODAL_ID, + isRevert: true, primaryActionEventName, }, }), diff --git a/app/assets/javascripts/projects/commits/index.js b/app/assets/javascripts/projects/commits/index.js index 03b94fde0f3..53169f689c9 100644 --- a/app/assets/javascripts/projects/commits/index.js +++ b/app/assets/javascripts/projects/commits/index.js @@ -1,11 +1,13 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import { visitUrl } from '~/lib/utils/url_utility'; +import RefSelector from '~/ref/components/ref_selector.vue'; import AuthorSelectApp from './components/author_select.vue'; import store from './store'; Vue.use(Vuex); -export default (el) => { +export const mountCommits = (el) => { if (!el) { return null; } @@ -24,3 +26,30 @@ export default (el) => { }, }); }; + +export const initCommitsRefSwitcher = () => { + const el = document.getElementById('js-project-commits-ref-switcher'); + const COMMITS_PATH_REGEX = /^(.*?)\/-\/commits/g; + + if (!el) return false; + + const { projectId, ref, commitsPath } = el.dataset; + const commitsPathPrefix = commitsPath.match(COMMITS_PATH_REGEX)?.[0]; + + return new Vue({ + el, + render(createElement) { + return createElement(RefSelector, { + props: { + projectId, + value: ref, + }, + on: { + input(selected) { + visitUrl(`${commitsPathPrefix}/${selected}`); + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue index ba1e00a2b36..c00e75db722 100644 --- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue @@ -57,7 +57,7 @@ export default { <gl-dropdown :text="selectedProject.name" :header-text="s__(`CompareRevisions|Select target project`)" - class="gl-w-full gl-font-monospace gl-sm-pr-3" + class="gl-w-full gl-font-monospace" toggle-class="gl-min-w-0" :disabled="disableRepoDropdown" > diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue index d6ada24604d..162aca44f9d 100644 --- a/app/assets/javascripts/projects/compare/components/revision_card.vue +++ b/app/assets/javascripts/projects/compare/components/revision_card.vue @@ -43,7 +43,7 @@ export default { <h2 class="gl-font-size-h2"> {{ s__(`CompareRevisions|${revisionText}`) }} </h2> - <div class="gl-sm-display-flex gl-align-items-center"> + <div class="gl-sm-display-flex gl-align-items-center gl-gap-3"> <repo-dropdown class="gl-sm-w-half" :params-name="paramsName" diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index 3671b24b502..a44855c14d5 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -113,4 +113,12 @@ export default { text: s__('ProjectTemplates|Jsonnet for Dynamic Child Pipelines'), icon: '.template-option .icon-gitlab_logo', }, + bridgetown: { + text: s__('ProjectTemplates|Pages/Bridgetown'), + icon: '.template-option .icon-gitlab_logo', + }, + typo3_distribution: { + text: s__('ProjectTemplates|TYPO3 Distribution'), + icon: '.template-option .icon-typo3', + }, }; diff --git a/app/assets/javascripts/projects/new/components/app.vue b/app/assets/javascripts/projects/new/components/app.vue index 59ca393fe92..3100029eb31 100644 --- a/app/assets/javascripts/projects/new/components/app.vue +++ b/app/assets/javascripts/projects/new/components/app.vue @@ -3,7 +3,7 @@ import createFromTemplateIllustration from '@gitlab/svgs/dist/illustrations/proj import blankProjectIllustration from '@gitlab/svgs/dist/illustrations/project-create-new-sm.svg'; import importProjectIllustration from '@gitlab/svgs/dist/illustrations/project-import-sm.svg'; import ciCdProjectIllustration from '@gitlab/svgs/dist/illustrations/project-run-CICD-pipelines-sm.svg'; -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue'; import NewProjectPushTipPopover from './new_project_push_tip_popover.vue'; diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue index eccfb3d844c..d6d88b5b297 100644 --- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -46,7 +46,15 @@ export default { debounce: DEBOUNCE_DELAY, }, }, - inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel', 'userNamespaceId'], + inject: [ + 'namespaceFullPath', + 'namespaceId', + 'rootUrl', + 'trackLabel', + 'userNamespaceId', + 'inputName', + 'inputId', + ], data() { return { currentUser: {}, @@ -124,6 +132,11 @@ export default { } : this.$options.emptyNameSpace; }, + trackDropdownShow() { + if (this.trackLabel) { + this.track('activate_form_input', { label: this.trackLabel, property: 'project_path' }); + } + }, }, emptyNameSpace: { id: undefined, @@ -145,7 +158,7 @@ export default { class="js-group-namespace-dropdown gl-flex-grow-1" :toggle-class="`gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20 ${dropdownPlaceholderClass}`" data-qa-selector="select_namespace_dropdown" - @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })" + @show="trackDropdownShow" @shown="handleDropdownShown" > <template #button-text> @@ -173,7 +186,7 @@ export default { {{ group.fullPath }} </gl-dropdown-item> </template> - <template v-if="hasNamespaceMatches"> + <template v-if="hasNamespaceMatches && userNamespaceId"> <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> <gl-dropdown-item @click="handleDropdownItemClick(userNamespace)"> {{ userNamespace.fullPath }} @@ -186,9 +199,9 @@ export default { <input type="hidden" name="project[selected_namespace_id]" :value="selectedNamespace.id" /> <input - id="project_namespace_id" + :id="inputId" type="hidden" - name="project[namespace_id]" + :name="inputName" :value="selectedNamespace.id || userNamespaceId" /> </gl-button-group> diff --git a/app/assets/javascripts/projects/new/constants.js b/app/assets/javascripts/projects/new/constants.js index e52a84dc07e..7b6b2cfc7ca 100644 --- a/app/assets/javascripts/projects/new/constants.js +++ b/app/assets/javascripts/projects/new/constants.js @@ -12,6 +12,8 @@ export const DEPLOYMENT_TARGET_SELECTIONS = [ s__('DeploymentTarget|Registry (package or container)'), s__('DeploymentTarget|Infrastructure provider (Terraform, Cloudformation, and so on)'), s__('DeploymentTarget|Serverless backend (Lambda, Cloud functions)'), + s__('DeploymentTarget|Edge Computing (e.g. Cloudflare Workers)'), + s__('DeploymentTarget|Web Deployment Platform (Netlify, Vercel, Gatsby)'), s__('DeploymentTarget|GitLab Pages'), s__('DeploymentTarget|Other hosting service'), s__('DeploymentTarget|No deployment planned'), diff --git a/app/assets/javascripts/projects/new/index.js b/app/assets/javascripts/projects/new/index.js index a72172a4f5e..910244c657b 100644 --- a/app/assets/javascripts/projects/new/index.js +++ b/app/assets/javascripts/projects/new/index.js @@ -59,6 +59,8 @@ export function initNewProjectUrlSelect() { rootUrl: el.dataset.rootUrl, trackLabel: el.dataset.trackLabel, userNamespaceId: el.dataset.userNamespaceId, + inputId: el.dataset.inputId, + inputName: el.dataset.inputName, }, render: (createElement) => createElement(NewProjectUrlSelect), }), diff --git a/app/assets/javascripts/projects/project_name_rules.js b/app/assets/javascripts/projects/project_name_rules.js new file mode 100644 index 00000000000..eeef1fb5afc --- /dev/null +++ b/app/assets/javascripts/projects/project_name_rules.js @@ -0,0 +1,28 @@ +import { __ } from '~/locale'; + +const rulesReg = [ + { + reg: /^[a-zA-Z0-9\u{00A9}-\u{1f9ff}_]/u, + msg: __("Name must start with a letter, digit, emoji, or '_'"), + }, + { + reg: /^[a-zA-Z0-9\p{Pd}\u{002B}\u{00A9}-\u{1f9ff}_. ]+$/u, + msg: __("Name can contain only letters, digits, emojis, '_', '.', '+', dashes, or spaces"), + }, +]; + +/** + * + * @param {string} text + * @returns {string} msg + */ +function checkRules(text) { + for (const item of rulesReg) { + if (!item.reg.test(text)) { + return item.msg; + } + } + return ''; +} + +export { checkRules }; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 424ea3b61c5..d71e80dffcf 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -12,6 +12,7 @@ import { slugify, convertUnicodeToAscii, } from '../lib/utils/text_utility'; +import { checkRules } from './project_name_rules'; let hasUserDefinedProjectPath = false; let hasUserDefinedProjectName = false; @@ -87,10 +88,23 @@ const validateGroupNamespaceDropdown = (e) => { } }; +const checkProjectName = (projectNameInput) => { + const msg = checkRules(projectNameInput.value); + const projectNameError = document.querySelector('#project_name_error'); + if (!projectNameError) return; + if (msg) { + projectNameError.innerText = msg; + projectNameError.classList.remove('hidden'); + } else { + projectNameError.classList.add('hidden'); + } +}; + const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { const specialRepo = document.querySelector('.js-user-readme-repo'); const projectNameInputListener = () => { onProjectNameChange($projectNameInput, $projectPathInput); + checkProjectName($projectNameInput); hasUserDefinedProjectName = $projectNameInput.value.trim().length > 0; hasUserDefinedProjectPath = $projectPathInput.value.trim().length > 0; }; diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index 335545c802a..dcf7415a444 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -580,7 +580,7 @@ export default class AccessDropdown { return ` <li> <a href="#" class="${isActiveClass} item-${role.type}" data-role-id="${role.id}"> - ${role.text} + ${escape(role.text)} </a> </li> `; diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js index 6da058ebc9c..61c37a2348a 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js @@ -6,6 +6,7 @@ export const I18N = { branchNameOrPattern: s__('BranchRules|Branch name or pattern'), branch: s__('BranchRules|Target Branch'), allBranches: s__('BranchRules|All branches'), + matchingBranchesLinkTitle: s__('BranchRules|%{total} matching %{subject}'), protectBranchTitle: s__('BranchRules|Protect branch'), protectBranchDescription: s__( 'BranchRules|Keep stable branches secure and force developers to use merge requests. %{linkStart}What are protected branches?%{linkEnd}', diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue index eb11e17dd1b..626ed67c466 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -1,9 +1,10 @@ <script> import { GlSprintf, GlLink, GlLoadingIcon } from '@gitlab/ui'; -import { sprintf } from '~/locale'; -import { getParameterByName } from '~/lib/utils/url_utility'; +import { sprintf, n__ } from '~/locale'; +import { getParameterByName, mergeUrlParams } from '~/lib/utils/url_utility'; import { helpPagePath } from '~/helpers/help_page_helper'; import branchRulesQuery from '../../queries/branch_rules_details.query.graphql'; +import { getAccessLevels } from '../../../utils'; import Protection from './protection.vue'; import { I18N, @@ -41,6 +42,9 @@ export default { statusChecksPath: { default: '', }, + branchesPath: { + default: '', + }, }, apollo: { project: { @@ -55,6 +59,7 @@ export default { this.branchProtection = branchRule?.branchProtection; this.approvalRules = branchRule?.approvalRules; this.statusChecks = branchRule?.externalStatusChecks?.nodes || []; + this.matchingBranchesCount = branchRule?.matchingBranchesCount; }, }, }, @@ -64,6 +69,7 @@ export default { branchProtection: {}, approvalRules: {}, statusChecks: [], + matchingBranchesCount: null, }; }, computed: { @@ -115,28 +121,20 @@ export default { ? this.$options.i18n.targetBranch : this.$options.i18n.branchNameOrPattern; }, + matchingBranchesLinkHref() { + return mergeUrlParams({ state: 'all', search: this.branch }, this.branchesPath); + }, + matchingBranchesLinkTitle() { + const total = this.matchingBranchesCount; + const subject = n__('branch', 'branches', total); + return sprintf(this.$options.i18n.matchingBranchesLinkTitle, { total, subject }); + }, approvals() { return this.approvalRules?.nodes || []; }, }, methods: { - getAccessLevels(accessLevels = {}) { - const total = accessLevels.edges?.length; - const accessLevelTypes = { total, users: [], groups: [], roles: [] }; - - accessLevels.edges?.forEach(({ node }) => { - if (node.user) { - const src = node.user.avatarUrl; - accessLevelTypes.users.push({ src, ...node.user }); - } else if (node.group) { - accessLevelTypes.groups.push(node); - } else { - accessLevelTypes.roles.push(node); - } - }); - - return accessLevelTypes; - }, + getAccessLevels, }, }; </script> @@ -161,6 +159,10 @@ export default { </div> <code v-else class="gl-mt-2" data-testid="branch">{{ branch }}</code> + <p v-if="matchingBranchesCount" class="gl-mt-3"> + <gl-link :href="matchingBranchesLinkHref">{{ matchingBranchesLinkTitle }}</gl-link> + </p> + <h4 class="gl-mb-1 gl-mt-5">{{ $options.i18n.protectBranchTitle }}</h4> <gl-sprintf :message="$options.i18n.protectBranchDescription"> <template #link="{ content }"> diff --git a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js index 89cfb1e1c8e..7639acc1181 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js +++ b/app/assets/javascripts/projects/settings/branch_rules/mount_branch_rules.js @@ -14,7 +14,13 @@ export default function mountBranchRules(el) { defaultClient: createDefaultClient(), }); - const { projectPath, protectedBranchesPath, approvalRulesPath, statusChecksPath } = el.dataset; + const { + projectPath, + protectedBranchesPath, + approvalRulesPath, + statusChecksPath, + branchesPath, + } = el.dataset; return new Vue({ el, @@ -24,6 +30,7 @@ export default function mountBranchRules(el) { protectedBranchesPath, approvalRulesPath, statusChecksPath, + branchesPath, }, render(h) { return h(View); diff --git a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql index aa1e4923aa8..a832e59aa67 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql +++ b/app/assets/javascripts/projects/settings/branch_rules/queries/branch_rules_details.query.graphql @@ -68,6 +68,7 @@ query getBranchRulesDetails($projectPath: ID!) { externalUrl } } + matchingBranchesCount } } } diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index a9eb2a53fbf..9b669024a8b 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -1,7 +1,7 @@ <script> import { s__ } from '~/locale'; import { createAlert } from '~/flash'; -import branchRulesQuery from './graphql/queries/branch_rules.query.graphql'; +import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; import BranchRule from './components/branch_rule.vue'; export const i18n = { @@ -51,13 +51,14 @@ export default { <template> <div class="settings-content"> <branch-rule - v-for="rule in branchRules" - :key="rule.name" + v-for="(rule, index) in branchRules" + :key="`${rule.name}-${index}`" :name="rule.name" :is-default="rule.isDefault" :branch-protection="rule.branchProtection" - :status-checks-total="rule.externalStatusChecks.nodes.length" - :approval-rules-total="rule.approvalRules.nodes.length" + :status-checks-total="rule.externalStatusChecks ? rule.externalStatusChecks.nodes.length : 0" + :approval-rules-total="rule.approvalRules ? rule.approvalRules.nodes.length : 0" + :matching-branches-count="rule.matchingBranchesCount" /> <span v-if="!branchRules.length" data-testid="empty">{{ $options.i18n.emptyState }}</span> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue index 78c824c66d1..41947834bdb 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -1,6 +1,7 @@ <script> import { GlBadge, GlButton } from '@gitlab/ui'; import { s__, sprintf, n__ } from '~/locale'; +import { getAccessLevels } from '../../../utils'; export const i18n = { defaultLabel: s__('BranchRules|default'), @@ -9,6 +10,9 @@ export const i18n = { codeOwnerApprovalRequired: s__('BranchRules|Requires CODEOWNERS approval'), statusChecks: s__('BranchRules|%{total} status %{subject}'), approvalRules: s__('BranchRules|%{total} approval %{subject}'), + matchingBranches: s__('BranchRules|%{total} matching %{subject}'), + pushAccessLevels: s__('BranchRules|Allowed to merge'), + mergeAccessLevels: s__('BranchRules|Allowed to push'), }; export default { @@ -48,8 +52,16 @@ export default { required: false, default: 0, }, + matchingBranchesCount: { + type: Number, + required: false, + default: 0, + }, }, computed: { + isWildcard() { + return this.name.includes('*'); + }, hasApprovalDetails() { return this.approvalDetails.length; }, @@ -68,8 +80,31 @@ export default { subject: n__('rule', 'rules', this.approvalRulesTotal), }); }, + matchingBranchesText() { + return sprintf(this.$options.i18n.matchingBranches, { + total: this.matchingBranchesCount, + subject: n__('branch', 'branches', this.matchingBranchesCount), + }); + }, + mergeAccessLevels() { + const { mergeAccessLevels } = this.branchProtection || {}; + return this.getAccessLevels(mergeAccessLevels); + }, + pushAccessLevels() { + const { pushAccessLevels } = this.branchProtection || {}; + return this.getAccessLevels(pushAccessLevels); + }, + pushAccessLevelsText() { + return this.getAccessLevelsText(this.$options.i18n.pushAccessLevels, this.pushAccessLevels); + }, + mergeAccessLevelsText() { + return this.getAccessLevelsText(this.$options.i18n.mergeAccessLevels, this.mergeAccessLevels); + }, approvalDetails() { const approvalDetails = []; + if (this.isWildcard) { + approvalDetails.push(this.matchingBranchesText); + } if (this.branchProtection.allowForcePush) { approvalDetails.push(this.$options.i18n.allowForcePush); } @@ -82,9 +117,31 @@ export default { if (this.approvalRulesTotal) { approvalDetails.push(this.approvalRulesText); } + if (this.mergeAccessLevels.total > 0) { + approvalDetails.push(this.mergeAccessLevelsText); + } + if (this.pushAccessLevels.total > 0) { + approvalDetails.push(this.pushAccessLevelsText); + } return approvalDetails; }, }, + methods: { + getAccessLevels, + getAccessLevelsText(beginString = '', accessLevels) { + const textParts = []; + if (accessLevels.roles.length) { + textParts.push(n__('1 role', '%d roles', accessLevels.roles.length)); + } + if (accessLevels.groups.length) { + textParts.push(n__('1 group', '%d groups', accessLevels.groups.length)); + } + if (accessLevels.users.length) { + textParts.push(n__('1 user', '%d users', accessLevels.users.length)); + } + return `${beginString}: ${textParts.join(', ')}`; + }, + }, }; </script> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql index 49e089e7805..a8cdda5505f 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql @@ -5,18 +5,24 @@ query getBranchRules($projectPath: ID!) { nodes { name isDefault + matchingBranchesCount branchProtection { allowForcePush - codeOwnerApprovalRequired - } - externalStatusChecks { - nodes { - id + mergeAccessLevels { + edges { + node { + accessLevel + accessLevelDescription + } + } } - } - approvalRules { - nodes { - id + pushAccessLevels { + edges { + node { + accessLevel + accessLevelDescription + } + } } } } diff --git a/app/assets/javascripts/projects/settings/utils.js b/app/assets/javascripts/projects/settings/utils.js new file mode 100644 index 00000000000..7bcfde39178 --- /dev/null +++ b/app/assets/javascripts/projects/settings/utils.js @@ -0,0 +1,17 @@ +export const getAccessLevels = (accessLevels = {}) => { + const total = accessLevels.edges?.length; + const accessLevelTypes = { total, users: [], groups: [], roles: [] }; + + accessLevels.edges?.forEach(({ node }) => { + if (node.user) { + const src = node.user.avatarUrl; + accessLevelTypes.users.push({ src, ...node.user }); + } else if (node.group) { + accessLevelTypes.groups.push(node); + } else { + accessLevelTypes.roles.push(node); + } + }); + + return accessLevelTypes; +}; diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 71ff3e892b1..b79b3fa4573 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -1,5 +1,6 @@ <script> -import { GlAlert, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import axios from '~/lib/utils/axios_utils'; import { helpPagePath } from '~/helpers/help_page_helper'; import { __, sprintf } from '~/locale'; @@ -16,7 +17,7 @@ export default { ServiceDeskSetting, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, inject: { initialIsEnabled: { diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js index e3f427b8408..75fd11cd074 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_create.js +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -42,7 +42,7 @@ export default class ProtectedTagCreate { const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes'); this.$form - .find('input[type="submit"]') + .find('button[type="submit"]') .prop('disabled', !($tagInput.val() && $allowedToCreateInput.length)); } diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index 6dc8240e680..1b360b79b0c 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -263,6 +263,7 @@ export default { v-for="(release, index) in releases" :key="getReleaseKey(release, index)" :release="release" + :sort="sort" :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" /> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index b2bd405574f..49c349e7a7b 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -1,12 +1,12 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import $ from 'jquery'; import { isEmpty } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { scrollToElement } from '~/lib/utils/common_utils'; import { slugify } from '~/lib/utils/text_utility'; import { getLocationHash } from '~/lib/utils/url_utility'; +import { CREATED_ASC } from '~/releases/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import '~/behaviors/markdown/render_gfm'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import EvidenceBlock from './evidence_block.vue'; import ReleaseBlockAssets from './release_block_assets.vue'; import ReleaseBlockFooter from './release_block_footer.vue'; @@ -32,6 +32,11 @@ export default { required: true, default: () => ({}), }, + sort: { + type: String, + required: false, + default: CREATED_ASC, + }, }, data() { return { @@ -80,7 +85,7 @@ export default { }, methods: { renderGFM() { - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); }, }, safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, @@ -119,6 +124,8 @@ export default { :tag-path="release.tagPath" :author="release.author" :released-at="release.releasedAt" + :created-at="release.createdAt" + :sort="sort" /> </div> </template> diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue index 3881c83b5c2..85fb7d02a37 100644 --- a/app/assets/javascripts/releases/components/release_block_footer.vue +++ b/app/assets/javascripts/releases/components/release_block_footer.vue @@ -3,6 +3,7 @@ import { GlTooltipDirective, GlLink, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { RELEASED_AT_ASC, RELEASED_AT_DESC } from '~/releases/constants'; export default { name: 'ReleaseBlockFooter', @@ -46,10 +47,26 @@ export default { required: false, default: null, }, + createdAt: { + type: Date, + required: false, + default: null, + }, + sort: { + type: String, + required: false, + default: '', + }, }, computed: { - releasedAtTimeAgo() { - return this.timeFormatted(this.releasedAt); + isSortedByReleaseDate() { + return this.sort === RELEASED_AT_ASC || this.sort === RELEASED_AT_DESC; + }, + timeAt() { + return this.isSortedByReleaseDate ? this.releasedAt : this.createdAt; + }, + atTimeAgo() { + return this.timeFormatted(this.timeAt); }, userImageAltDescription() { return this.author && this.author.username @@ -58,7 +75,10 @@ export default { }, createdTime() { const now = new Date(); - const isFuture = now < new Date(this.releasedAt); + const isFuture = now < new Date(this.timeAt); + if (this.isSortedByReleaseDate) { + return isFuture ? __('Will be released') : __('Released'); + } return isFuture ? __('Will be created') : __('Created'); }, }, @@ -93,17 +113,17 @@ export default { </div> <div - v-if="releasedAt || author" + v-if="timeAt || author" class="gl-float-left gl-display-flex gl-align-items-center js-author-date-info" > <span class="gl-text-secondary">{{ createdTime }} </span> - <template v-if="releasedAt"> + <template v-if="timeAt"> <span v-gl-tooltip.bottom - :title="tooltipTitle(releasedAt)" + :title="tooltipTitle(timeAt)" class="gl-text-secondary gl-flex-shrink-0" > - {{ releasedAtTimeAgo }} + {{ atTimeAgo }} </span> </template> diff --git a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql index 3ad66afa259..177dff1823e 100644 --- a/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql +++ b/app/assets/javascripts/releases/graphql/fragments/release_for_editing.fragment.graphql @@ -4,6 +4,7 @@ fragment ReleaseForEditing on Release { tagName description releasedAt + createdAt tagPath assets { links { diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js index a1027ef08d7..10d7887c0b1 100644 --- a/app/assets/javascripts/releases/util.js +++ b/app/assets/javascripts/releases/util.js @@ -15,7 +15,8 @@ const convertScalarProperties = (graphQLRelease) => 'historicalRelease', ]); -const convertDateProperties = ({ releasedAt }) => ({ +const convertDateProperties = ({ createdAt, releasedAt }) => ({ + createdAt: new Date(createdAt), releasedAt: new Date(releasedAt), }); diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 05d64077866..4d3c1521559 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,12 +1,6 @@ <script> -import { - GlTooltipDirective, - GlLink, - GlButton, - GlButtonGroup, - GlLoadingIcon, - GlSafeHtmlDirective, -} from '@gitlab/ui'; +import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import defaultAvatarUrl from 'images/no_avatar.png'; import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { sprintf, s__ } from '~/locale'; @@ -32,7 +26,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [getRefMixin], apollo: { diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 4935b8029f9..8feac6b8e35 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -1,8 +1,8 @@ <script> -import { GlIcon, GlLink, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { handleLocationHash } from '~/lib/utils/common_utils'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import readmeQuery from '../../queries/readme.query.graphql'; export default { @@ -42,7 +42,7 @@ export default { if (newVal) { this.$nextTick(() => { handleLocationHash(); - $(this.$refs.readme).renderGFM(); + renderGFM(this.$refs.readme); }); } }, diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 99eb167172b..46d546c2ee4 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,6 +1,5 @@ <script> import { GlSkeletonLoader, GlButton } from '@gitlab/ui'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { sprintf, __ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; import getRefMixin from '../../mixins/get_ref'; @@ -17,7 +16,7 @@ export default { ParentRow, GlButton, }, - mixins: [getRefMixin, glFeatureFlagMixin()], + mixins: [getRefMixin], apollo: { projectPath: { query: projectPathQuery, @@ -93,9 +92,6 @@ export default { }, generateRowNumber(path, id, index) { const key = `${path}-${id}-${index}`; - if (!this.glFeatures.lazyLoadCommits) { - return 0; - } if (!this.rowNumbers[key] && this.rowNumbers[key] !== 0) { this.$options.totalRowsLoaded += 1; @@ -105,10 +101,6 @@ export default { return this.rowNumbers[key]; }, getCommit(fileName) { - if (!this.glFeatures.lazyLoadCommits) { - return {}; - } - return this.commits.find( (commitEntry) => commitEntry.filePath === joinPaths(this.path, fileName), ); diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index f3c5ace75fc..27ac11f3c58 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -7,10 +7,10 @@ import { GlLoadingIcon, GlIcon, GlHoverLoadDirective, - GlSafeHtmlDirective, GlIntersectionObserver, } from '@gitlab/ui'; import { escapeRegExp } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import { TREE_PAGE_SIZE, ROW_APPEAR_DELAY } from '~/repository/constants'; @@ -19,7 +19,6 @@ import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import blobInfoQuery from 'shared_queries/repository/blob_info.query.graphql'; import getRefMixin from '../../mixins/get_ref'; -import commitQuery from '../../queries/commit.query.graphql'; export default { components: { @@ -35,23 +34,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, GlHoverLoad: GlHoverLoadDirective, - SafeHtml: GlSafeHtmlDirective, - }, - apollo: { - commit: { - query: commitQuery, - variables() { - return { - fileName: this.name, - path: this.currentPath, - projectPath: this.projectPath, - maxOffset: this.totalEntries, - }; - }, - skip() { - return this.glFeatures.lazyLoadCommits; - }, - }, + SafeHtml, }, mixins: [getRefMixin, glFeatureFlagMixin()], props: { @@ -125,14 +108,13 @@ export default { }, data() { return { - commit: null, hasRowAppeared: false, delayedRowAppear: null, }; }, computed: { commitData() { - return this.glFeatures.lazyLoadCommits ? this.commitInfo : this.commit; + return this.commitInfo; }, routerLinkTo() { const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` }; @@ -200,12 +182,10 @@ export default { return; } - if (this.glFeatures.lazyLoadCommits) { - this.delayedRowAppear = setTimeout( - () => this.$emit('row-appear', this.rowNumber), - ROW_APPEAR_DELAY, - ); - } + this.delayedRowAppear = setTimeout( + () => this.$emit('row-appear', this.rowNumber), + ROW_APPEAR_DELAY, + ); }, rowDisappeared() { clearTimeout(this.delayedRowAppear); diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 8a45a351c35..4a8f83458f4 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -157,7 +157,7 @@ export default { .find(({ hasNextPage }) => hasNextPage); }, handleRowAppear(rowNumber) { - if (!this.glFeatures.lazyLoadCommits || isRequested(rowNumber)) { + if (isRequested(rowNumber)) { return; } diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 3a6d7d2f779..e194bddcc56 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -99,5 +99,4 @@ export const LEGACY_FILE_TYPES = [ 'requirements_txt', 'cargo_toml', 'go_mod', - 'go_sum', ]; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 1d295e18332..e9214e3acff 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -2,11 +2,12 @@ import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; -import { escapeFileUrl } from '~/lib/utils/url_utility'; +import { escapeFileUrl, visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import createStore from '~/code_navigation/store'; +import RefSelector from '~/ref/components/ref_selector.vue'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; @@ -20,6 +21,7 @@ import refsQuery from './queries/ref.query.graphql'; import createRouter from './router'; import { updateFormAction } from './utils/dom'; import { setTitle } from './utils/title'; +import { generateRefDestinationPath } from './utils/ref_switcher_utils'; Vue.use(Vuex); Vue.use(PerformancePlugin, { @@ -89,9 +91,34 @@ export default function setupVueRepositoryList() { }, }); - initLastCommitApp(); + const initRefSwitcher = () => { + const refSwitcherEl = document.getElementById('js-tree-ref-switcher'); + + if (!refSwitcherEl) return false; + + const { projectId, projectRootPath } = refSwitcherEl.dataset; + + return new Vue({ + el: refSwitcherEl, + render(createElement) { + return createElement(RefSelector, { + props: { + projectId, + value: ref, + }, + on: { + input(selectedRef) { + visitUrl(generateRefDestinationPath(projectRootPath, selectedRef)); + }, + }, + }); + }, + }); + }; + initLastCommitApp(); initBlobControlsApp(); + initRefSwitcher(); router.afterEach(({ params: { path } }) => { setTitle(path, ref, fullName); diff --git a/app/assets/javascripts/repository/queries/commit.query.graphql b/app/assets/javascripts/repository/queries/commit.query.graphql deleted file mode 100644 index 1a01462bd19..00000000000 --- a/app/assets/javascripts/repository/queries/commit.query.graphql +++ /dev/null @@ -1,7 +0,0 @@ -#import "ee_else_ce/repository/queries/commit.fragment.graphql" - -query getCommit($fileName: String!, $path: String!, $maxOffset: Number!) { - commit(path: $path, fileName: $fileName, maxOffset: $maxOffset) @client { - ...TreeEntryCommit - } -} diff --git a/app/assets/javascripts/repository/utils/ref_switcher_utils.js b/app/assets/javascripts/repository/utils/ref_switcher_utils.js new file mode 100644 index 00000000000..8ff52104c93 --- /dev/null +++ b/app/assets/javascripts/repository/utils/ref_switcher_utils.js @@ -0,0 +1,30 @@ +import { joinPaths } from '~/lib/utils/url_utility'; + +/** + * Matches the namespace and target directory/blob in a path + * Example: /root/Flight/-/blob/fix/main/test/spec/utils_spec.js + * Group 1: /-/blob + * Group 2: blob + * Group 3: main/test/spec/utils_spec.js + */ +const NAMESPACE_TARGET_REGEX = /(\/-\/(blob|tree))\/.*?\/(.*)/; + +/** + * Generates a ref destination path based on the selected ref and current path. + * A user could either be in the project root, a directory on the blob view. + * @param {string} projectRootPath - The root path for a project. + * @param {string} selectedRef - The selected ref from the ref dropdown. + */ +export function generateRefDestinationPath(projectRootPath, selectedRef) { + const currentPath = window.location.pathname; + let namespace = '/-/tree'; + let target; + const match = NAMESPACE_TARGET_REGEX.exec(currentPath); + if (match) { + [, namespace, , target] = match; + } + + const destinationPath = joinPaths(projectRootPath, namespace, selectedRef, target); + + return `${destinationPath}${window.location.hash}`; +} diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue index 38dccb9675d..4ddf695f61a 100644 --- a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -1,5 +1,5 @@ <script> -import { mapState } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { confidentialFilterData } from '../constants/confidential_filter_data'; import RadioFilter from './radio_filter.vue'; @@ -8,10 +8,10 @@ export default { components: { RadioFilter, }, + mixins: [glFeatureFlagsMixin()], computed: { - ...mapState(['query']), - showDropdown() { - return Object.values(confidentialFilterData.scopes).includes(this.query.scope); + ffBasedXPadding() { + return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; }, }, confidentialFilterData, @@ -19,8 +19,8 @@ export default { </script> <template> - <div v-if="showDropdown"> - <radio-filter :filter-data="$options.confidentialFilterData" /> + <div> + <radio-filter :class="ffBasedXPadding" :filter-data="$options.confidentialFilterData" /> <hr class="gl-my-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/components/results_filters.vue b/app/assets/javascripts/search/sidebar/components/results_filters.vue index 5b53f94bb53..9b993ab9a86 100644 --- a/app/assets/javascripts/search/sidebar/components/results_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/results_filters.vue @@ -2,6 +2,8 @@ import { GlButton, GlLink } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { confidentialFilterData } from '../constants/confidential_filter_data'; +import { stateFilterData } from '../constants/state_filter_data'; import ConfidentialityFilter from './confidentiality_filter.vue'; import StatusFilter from './status_filter.vue'; @@ -22,6 +24,15 @@ export default { searchPageVerticalNavFeatureFlag() { return this.glFeatures.searchPageVerticalNav; }, + showConfidentialityFilter() { + return Object.values(confidentialFilterData.scopes).includes(this.urlQuery.scope); + }, + showStatusFilter() { + return Object.values(stateFilterData.scopes).includes(this.urlQuery.scope); + }, + ffBasedXPadding() { + return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; + }, }, methods: { ...mapActions(['applyQuery', 'resetQuery']), @@ -30,14 +41,14 @@ export default { </script> <template> - <form - :class="searchPageVerticalNavFeatureFlag ? 'gl-px-5' : 'gl-px-0'" - @submit.prevent="applyQuery" - > - <hr v-if="searchPageVerticalNavFeatureFlag" class="gl-my-5 gl-border-gray-100" /> - <status-filter /> - <confidentiality-filter /> - <div class="gl-display-flex gl-align-items-center gl-mt-4"> + <form class="gl-pt-5 gl-md-pt-0" @submit.prevent="applyQuery"> + <hr + v-if="searchPageVerticalNavFeatureFlag" + class="gl-my-5 gl-border-gray-100 gl-display-none gl-md-display-block" + /> + <status-filter v-if="showStatusFilter" /> + <confidentiality-filter v-if="showConfidentialityFilter" /> + <div class="gl-display-flex gl-align-items-center gl-mt-4" :class="ffBasedXPadding"> <gl-button category="primary" variant="confirm" type="submit" :disabled="!sidebarDirty"> {{ __('Apply') }} </gl-button> diff --git a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue index f5e1525090e..7a03306e2f9 100644 --- a/app/assets/javascripts/search/sidebar/components/scope_navigation.vue +++ b/app/assets/javascripts/search/sidebar/components/scope_navigation.vue @@ -1,15 +1,23 @@ <script> -import { GlNav, GlNavItem } from '@gitlab/ui'; +import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; -import { formatNumber } from '~/locale'; +import { formatNumber, s__ } from '~/locale'; import Tracking from '~/tracking'; -import { NAV_LINK_DEFAULT_CLASSES, NUMBER_FORMATING_OPTIONS } from '../constants'; +import { + NAV_LINK_DEFAULT_CLASSES, + NUMBER_FORMATING_OPTIONS, + NAV_LINK_COUNT_DEFAULT_CLASSES, +} from '../constants'; export default { name: 'ScopeNavigation', + i18n: { + countOverLimitLabel: s__('GlobalSearch|Result count is over limit.'), + }, components: { GlNav, GlNavItem, + GlIcon, }, mixins: [Tracking.mixin()], computed: { @@ -20,9 +28,6 @@ export default { }, methods: { ...mapActions(['fetchSidebarCount']), - activeClasses(currentScope) { - return currentScope === this.urlQuery.scope ? 'gl-font-weight-bold' : ''; - }, showFormatedCount(count) { if (!count) { return '0'; @@ -30,17 +35,27 @@ export default { const countNumber = parseInt(count.replace(/,/g, ''), 10); return formatNumber(countNumber, NUMBER_FORMATING_OPTIONS); }, + isCountOverLimit(count) { + return count.includes('+'); + }, handleClick(scope) { this.track('click_menu_item', { label: `vertical_navigation_${scope}` }); }, - linkClasses(scope) { + linkClasses(isHighlighted) { + return [...this.$options.NAV_LINK_DEFAULT_CLASSES, { 'gl-font-weight-bold': isHighlighted }]; + }, + countClasses(isHighlighted) { return [ - { 'gl-font-weight-bold': scope === this.urlQuery.scope }, - ...this.$options.NAV_LINK_DEFAULT_CLASSES, + ...this.$options.NAV_LINK_COUNT_DEFAULT_CLASSES, + isHighlighted ? 'gl-text-gray-900' : 'gl-text-gray-500', ]; }, + isActive(scope, index) { + return this.urlQuery.scope ? this.urlQuery.scope === scope : index === 0; + }, }, NAV_LINK_DEFAULT_CLASSES, + NAV_LINK_COUNT_DEFAULT_CLASSES, }; </script> @@ -50,14 +65,20 @@ export default { <gl-nav-item v-for="(item, scope, index) in navigation" :key="scope" - :link-classes="linkClasses(scope)" + :link-classes="linkClasses(isActive(scope, index))" class="gl-mb-1" :href="item.link" - :active="urlQuery.scope ? urlQuery.scope === scope : index === 0" + :active="isActive(scope, index)" @click="handleClick(scope)" ><span>{{ item.label }}</span - ><span v-if="item.count" class="gl-font-sm gl-font-weight-normal"> - {{ showFormatedCount(item.count) }} + ><span v-if="item.count" :class="countClasses(isActive(scope, index))"> + {{ showFormatedCount(item.count) + }}<gl-icon + v-if="isCountOverLimit(item.count)" + name="plus" + :aria-label="$options.i18n.countOverLimitLabel" + :size="8" + /> </span> </gl-nav-item> </gl-nav> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue index 5cec2090906..eaf7d95822a 100644 --- a/app/assets/javascripts/search/sidebar/components/status_filter.vue +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -1,5 +1,5 @@ <script> -import { mapState } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { stateFilterData } from '../constants/state_filter_data'; import RadioFilter from './radio_filter.vue'; @@ -8,10 +8,10 @@ export default { components: { RadioFilter, }, + mixins: [glFeatureFlagsMixin()], computed: { - ...mapState(['query']), - showDropdown() { - return Object.values(stateFilterData.scopes).includes(this.query.scope); + ffBasedXPadding() { + return this.glFeatures.searchPageVerticalNav ? 'gl-px-5' : 'gl-px-0'; }, }, stateFilterData, @@ -19,8 +19,8 @@ export default { </script> <template> - <div v-if="showDropdown"> - <radio-filter :filter-data="$options.stateFilterData" /> + <div> + <radio-filter :class="ffBasedXPadding" :filter-data="$options.stateFilterData" /> <hr class="gl-my-5 gl-border-gray-100" /> </div> </template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index 3621138afe4..a9c031f91a4 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -9,3 +9,5 @@ export const NAV_LINK_DEFAULT_CLASSES = [ 'gl-justify-content-space-between', 'gl-text-gray-900', ]; + +export const NAV_LINK_COUNT_DEFAULT_CLASSES = ['gl-font-sm', 'gl-font-weight-normal']; diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index d0fcbb0d83b..0629bea3239 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -1,9 +1,11 @@ <script> -import { GlSearchBoxByClick } from '@gitlab/ui'; +import { GlSearchBoxByClick, GlButton } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; +import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; +import { SYNTAX_OPTIONS_DOCUMENT } from '../constants'; import GroupFilter from './group_filter.vue'; import ProjectFilter from './project_filter.vue'; @@ -12,24 +14,45 @@ export default { i18n: { searchPlaceholder: s__(`GlobalSearch|Search for projects, issues, etc.`), searchLabel: s__(`GlobalSearch|What are you searching for?`), + documentFetchErrorMessage: s__( + 'GlobalSearch|There was an error fetching the "Syntax Options" document.', + ), + searchFieldLabel: s__('GlobalSearch|What are you searching for?'), + syntaxOptionsLabel: s__('GlobalSearch|Syntax options'), + groupFieldLabel: s__('GlobalSearch|Group'), + projectFieldLabel: s__('GlobalSearch|Project'), + searchButtonLabel: s__('GlobalSearch|Search'), + closeButtonLabel: s__('GlobalSearch|Close'), }, components: { + GlButton, GlSearchBoxByClick, GroupFilter, ProjectFilter, + MarkdownDrawer, }, mixins: [glFeatureFlagsMixin()], props: { - groupInitialData: { + groupInitialJson: { type: Object, required: false, default: () => ({}), }, - projectInitialData: { + projectInitialJson: { type: Object, required: false, default: () => ({}), }, + elasticsearchEnabled: { + type: Boolean, + required: false, + default: false, + }, + defaultBranchName: { + type: String, + required: false, + default: '', + }, }, computed: { ...mapState(['query']), @@ -44,16 +67,26 @@ export default { showFilters() { return !parseBoolean(this.query.snippets); }, + showSyntaxOptions() { + return this.elasticsearchEnabled && this.isDefaultBranch; + }, hasVerticalNav() { return this.glFeatures.searchPageVerticalNav; }, + isDefaultBranch() { + return !this.query.repository_ref || this.query.repository_ref === this.defaultBranchName; + }, }, created() { this.preloadStoredFrequentItems(); }, methods: { ...mapActions(['applyQuery', 'setQuery', 'preloadStoredFrequentItems']), + onToggleDrawer() { + this.$refs.markdownDrawer.toggleDrawer(); + }, }, + SYNTAX_OPTIONS_DOCUMENT, }; </script> @@ -61,7 +94,25 @@ export default { <section class="search-page-form gl-lg-display-flex gl-flex-direction-column"> <div class="gl-lg-display-flex gl-flex-direction-row gl-align-items-flex-end"> <div class="gl-flex-grow-1 gl-mb-4 gl-lg-mb-0 gl-lg-mr-2"> - <label>{{ $options.i18n.searchLabel }}</label> + <div + class="gl-sm-display-flex gl-flex-direction-row gl-justify-content-space-between gl-mb-4 gl-md-mb-0" + > + <label>{{ $options.i18n.searchLabel }}</label> + <template v-if="showSyntaxOptions"> + <gl-button + category="tertiary" + variant="link" + size="small" + button-text-classes="gl-font-sm!" + @click="onToggleDrawer" + >{{ $options.i18n.syntaxOptionsLabel }} + </gl-button> + <markdown-drawer + ref="markdownDrawer" + :document-path="$options.SYNTAX_OPTIONS_DOCUMENT" + /> + </template> + </div> <gl-search-box-by-click id="dashboard_search" v-model="search" @@ -70,13 +121,13 @@ export default { @submit="applyQuery" /> </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> - <label class="gl-display-block">{{ __('Group') }}</label> - <group-filter :initial-data="groupInitialData" /> + <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-3"> + <label class="gl-display-block">{{ $options.i18n.groupFieldLabel }}</label> + <group-filter :initial-data="groupInitialJson" /> </div> - <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-mx-2"> - <label class="gl-display-block">{{ __('Project') }}</label> - <project-filter :initial-data="projectInitialData" /> + <div v-if="showFilters" class="gl-mb-4 gl-lg-mb-0 gl-lg-ml-3"> + <label class="gl-display-block">{{ $options.i18n.projectFieldLabel }}</label> + <project-filter :initial-data="projectInitialJson" /> </div> </div> <hr v-if="hasVerticalNav" class="gl-mt-5 gl-mb-0 gl-border-gray-100" /> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue index 70156142365..c1e33df3c42 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown_item.vue @@ -1,5 +1,6 @@ <script> -import { GlDropdownItem, GlAvatar, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlDropdownItem, GlAvatar } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js index dc040fdef34..121c15199dd 100644 --- a/app/assets/javascripts/search/topbar/constants.js +++ b/app/assets/javascripts/search/topbar/constants.js @@ -19,3 +19,5 @@ export const PROJECT_DATA = { name: 'name', fullName: 'name_with_namespace', }; + +export const SYNTAX_OPTIONS_DOCUMENT = 'drawers/user/search/advanced_search.md'; diff --git a/app/assets/javascripts/search/topbar/index.js b/app/assets/javascripts/search/topbar/index.js index 87316e10e8d..d6e16085c28 100644 --- a/app/assets/javascripts/search/topbar/index.js +++ b/app/assets/javascripts/search/topbar/index.js @@ -11,10 +11,18 @@ export const initTopbar = (store) => { return false; } - let { groupInitialData, projectInitialData } = el.dataset; + const { + groupInitialJson, + projectInitialJson, + elasticsearchEnabled, + defaultBranchName, + } = el.dataset; - groupInitialData = JSON.parse(groupInitialData); - projectInitialData = JSON.parse(projectInitialData); + const groupInitialJsonParsed = JSON.parse(groupInitialJson); + const projectInitialJsonParsed = JSON.parse(projectInitialJson); + const elasticsearchEnabledParsed = elasticsearchEnabled + ? JSON.parse(elasticsearchEnabled) + : false; return new Vue({ el, @@ -22,8 +30,10 @@ export const initTopbar = (store) => { render(createElement) { return createElement(GlobalSearchTopbar, { props: { - groupInitialData, - projectInitialData, + groupInitialJson: groupInitialJsonParsed, + projectInitialJson: projectInitialJsonParsed, + elasticsearchEnabled: elasticsearchEnabledParsed, + defaultBranchName, }, }); }, diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index 0bcb2bb6720..6dae8e50908 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -8,9 +8,9 @@ import { GlLink, GlSkeletonLoader, GlIcon, - GlSafeHtmlDirective, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; import { __, s__ } from '~/locale'; import { @@ -54,7 +54,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [Tracking.mixin()], inject: ['projectFullPath'], diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue index ffba3aac681..d9e969e2278 100644 --- a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue +++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue @@ -1,15 +1,8 @@ <script> -import { - GlFormGroup, - GlButton, - GlModal, - GlToast, - GlToggle, - GlLink, - GlSafeHtmlDirective, -} from '@gitlab/ui'; +import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle, GlLink } from '@gitlab/ui'; import Vue from 'vue'; import { mapState, mapActions } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { visitUrl, getBaseURL } from '~/lib/utils/url_utility'; @@ -26,7 +19,7 @@ export default { GlLink, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, formLabels: { createProject: __('Self-monitoring'), diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js index 5b9e994290c..7198dbe8b04 100644 --- a/app/assets/javascripts/self_monitor/store/actions.js +++ b/app/assets/javascripts/self_monitor/store/actions.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import { backOff } from '~/lib/utils/common_utils'; -import statusCodes from '~/lib/utils/http_status'; +import statusCodes, { HTTP_STATUS_ACCEPTED } from '~/lib/utils/http_status'; import { __, s__ } from '~/locale'; import * as types from './mutation_types'; @@ -10,7 +10,7 @@ function backOffRequest(makeRequestCallback) { return backOff((next, stop) => { makeRequestCallback() .then((resp) => { - if (resp.status === statusCodes.ACCEPTED) { + if (resp.status === HTTP_STATUS_ACCEPTED) { next(); } else { stop(resp); @@ -31,7 +31,7 @@ export const requestCreateProject = ({ dispatch, state, commit }) => { axios .post(state.createProjectEndpoint) .then((resp) => { - if (resp.status === statusCodes.ACCEPTED) { + if (resp.status === HTTP_STATUS_ACCEPTED) { dispatch('requestCreateProjectStatus', resp.data.job_id); } }) @@ -83,7 +83,7 @@ export const requestDeleteProject = ({ dispatch, state, commit }) => { axios .delete(state.deleteProjectEndpoint) .then((resp) => { - if (resp.status === statusCodes.ACCEPTED) { + if (resp.status === HTTP_STATUS_ACCEPTED) { dispatch('requestDeleteProjectStatus', resp.data.job_id); } }) diff --git a/app/assets/javascripts/sentry/constants.js b/app/assets/javascripts/sentry/constants.js index fd96da5faf6..5531c4f56db 100644 --- a/app/assets/javascripts/sentry/constants.js +++ b/app/assets/javascripts/sentry/constants.js @@ -1,5 +1,6 @@ import { __ } from '~/locale'; +// TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 export const IGNORE_ERRORS = [ // Random plugins/extensions 'top.GLOBALS', diff --git a/app/assets/javascripts/sentry/index.js b/app/assets/javascripts/sentry/index.js index 176745b4177..5539a061726 100644 --- a/app/assets/javascripts/sentry/index.js +++ b/app/assets/javascripts/sentry/index.js @@ -1,26 +1,34 @@ import '../webpack'; +import * as Sentry from 'sentrybrowser7'; import SentryConfig from './sentry_config'; const index = function index() { + // Configuration for newer versions of Sentry SDK (v7) SentryConfig.init({ dsn: gon.sentry_dsn, + environment: gon.sentry_environment, currentUserId: gon.current_user_id, - whitelistUrls: + allowUrls: process.env.NODE_ENV === 'production' ? [gon.gitlab_url] : [gon.gitlab_url, 'webpack-internal://'], - environment: gon.sentry_environment, release: gon.revision, tags: { revision: gon.revision, feature_category: gon.feature_category, }, }); - - return SentryConfig; }; index(); +// The _Sentry object is globally exported so it can be used by +// ./sentry_browser_wrapper.js +// This hack allows us to load a single version of `@sentry/browser` +// in the browser, see app/views/layouts/_head.html.haml to find how it is imported. + +// eslint-disable-next-line no-underscore-dangle +window._Sentry = Sentry; + export default index; diff --git a/app/assets/javascripts/sentry/legacy_index.js b/app/assets/javascripts/sentry/legacy_index.js new file mode 100644 index 00000000000..604b982e128 --- /dev/null +++ b/app/assets/javascripts/sentry/legacy_index.js @@ -0,0 +1,34 @@ +import '../webpack'; + +import * as Sentry5 from 'sentrybrowser5'; +import LegacySentryConfig from './legacy_sentry_config'; + +const index = function index() { + // Configuration for legacy versions of Sentry SDK (v5) + LegacySentryConfig.init({ + dsn: gon.sentry_dsn, + currentUserId: gon.current_user_id, + whitelistUrls: + process.env.NODE_ENV === 'production' + ? [gon.gitlab_url] + : [gon.gitlab_url, 'webpack-internal://'], + environment: gon.sentry_environment, + release: gon.revision, + tags: { + revision: gon.revision, + feature_category: gon.feature_category, + }, + }); +}; + +index(); + +// The _Sentry object is globally exported so it can be used by +// ./sentry_browser_wrapper.js +// This hack allows us to load a single version of `@sentry/browser` +// in the browser, see app/views/layouts/_head.html.haml to find how it is imported. + +// eslint-disable-next-line no-underscore-dangle +window._Sentry = Sentry5; + +export default index; diff --git a/app/assets/javascripts/sentry/legacy_sentry_config.js b/app/assets/javascripts/sentry/legacy_sentry_config.js new file mode 100644 index 00000000000..50a943886db --- /dev/null +++ b/app/assets/javascripts/sentry/legacy_sentry_config.js @@ -0,0 +1,64 @@ +import * as Sentry5 from 'sentrybrowser5'; +import $ from 'jquery'; +import { __ } from '~/locale'; +import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants'; + +const SentryConfig = { + IGNORE_ERRORS, + BLACKLIST_URLS: DENY_URLS, + SAMPLE_RATE, + init(options = {}) { + this.options = options; + + this.configure(); + this.bindSentryErrors(); + if (this.options.currentUserId) this.setUser(); + }, + + configure() { + const { dsn, release, tags, whitelistUrls, environment } = this.options; + + Sentry5.init({ + dsn, + release, + whitelistUrls, + environment, + ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 + blacklistUrls: this.BLACKLIST_URLS, + sampleRate: SAMPLE_RATE, + }); + + Sentry5.setTags(tags); + }, + + setUser() { + Sentry5.setUser({ + id: this.options.currentUserId, + }); + }, + + bindSentryErrors() { + $(document).on('ajaxError.sentry', this.handleSentryErrors); + }, + + handleSentryErrors(event, req, config, err) { + const error = err || req.statusText; + const { responseText = __('Unknown response text') } = req; + const { type, url, data } = config; + const { status } = req; + + Sentry5.captureMessage(error, { + extra: { + type, + url, + data, + status, + response: responseText, + error, + event, + }, + }); + }, +}; + +export default SentryConfig; diff --git a/app/assets/javascripts/sentry/sentry_browser_wrapper.js b/app/assets/javascripts/sentry/sentry_browser_wrapper.js new file mode 100644 index 00000000000..0382827f82c --- /dev/null +++ b/app/assets/javascripts/sentry/sentry_browser_wrapper.js @@ -0,0 +1,27 @@ +// The _Sentry object is globally exported so it can be used here +// This hack allows us to load a single version of `@sentry/browser` +// in the browser (or none). See app/views/layouts/_head.html.haml +// to find how it is imported. + +// This module wraps methods used by our production code. +// Each export is names as we cannot export the entire namespace from *. +export const captureException = (...args) => { + // eslint-disable-next-line no-underscore-dangle + const Sentry = window._Sentry; + + Sentry?.captureException(...args); +}; + +export const captureMessage = (...args) => { + // eslint-disable-next-line no-underscore-dangle + const Sentry = window._Sentry; + + Sentry?.captureMessage(...args); +}; + +export const withScope = (...args) => { + // eslint-disable-next-line no-underscore-dangle + const Sentry = window._Sentry; + + Sentry?.withScope(...args); +}; diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js index 4c5b8dbad5a..ed8a55b7d44 100644 --- a/app/assets/javascripts/sentry/sentry_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -1,30 +1,24 @@ -import * as Sentry from '@sentry/browser'; -import $ from 'jquery'; -import { __ } from '~/locale'; +import * as Sentry from 'sentrybrowser7'; import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from './constants'; const SentryConfig = { - IGNORE_ERRORS, - BLACKLIST_URLS: DENY_URLS, - SAMPLE_RATE, init(options = {}) { this.options = options; this.configure(); - this.bindSentryErrors(); if (this.options.currentUserId) this.setUser(); }, configure() { - const { dsn, release, tags, whitelistUrls, environment } = this.options; + const { dsn, release, tags, allowUrls, environment } = this.options; Sentry.init({ dsn, release, - whitelistUrls, + allowUrls, environment, - ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 - blacklistUrls: this.BLACKLIST_URLS, + ignoreErrors: IGNORE_ERRORS, + denyUrls: DENY_URLS, sampleRate: SAMPLE_RATE, }); @@ -36,29 +30,6 @@ const SentryConfig = { id: this.options.currentUserId, }); }, - - bindSentryErrors() { - $(document).on('ajaxError.sentry', this.handleSentryErrors); - }, - - handleSentryErrors(event, req, config, err) { - const error = err || req.statusText; - const { responseText = __('Unknown response text') } = req; - const { type, url, data } = config; - const { status } = req; - - Sentry.captureMessage(error, { - extra: { - type, - url, - data, - status, - response: responseText, - error, - event, - }, - }); - }, }; export default SentryConfig; diff --git a/app/assets/javascripts/set_status_modal/set_status_form.vue b/app/assets/javascripts/set_status_modal/set_status_form.vue index 86049a2b781..dd27a12cbee 100644 --- a/app/assets/javascripts/set_status_modal/set_status_form.vue +++ b/app/assets/javascripts/set_status_modal/set_status_form.vue @@ -10,9 +10,9 @@ import { GlDropdownItem, GlSprintf, GlFormGroup, - GlSafeHtmlDirective, } from '@gitlab/ui'; import $ from 'jquery'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import * as Emoji from '~/emoji'; import { s__ } from '~/locale'; @@ -33,7 +33,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { defaultEmoji: { diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 80158c55dbc..5becc03646e 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -1,5 +1,5 @@ <script> -import { GlToast, GlTooltipDirective, GlSafeHtmlDirective, GlModal } from '@gitlab/ui'; +import { GlToast, GlTooltipDirective, GlModal } from '@gitlab/ui'; import Vue from 'vue'; import { createAlert } from '~/flash'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; @@ -19,7 +19,6 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, }, mixins: [glFeatureFlagsMixin()], props: { @@ -110,7 +109,6 @@ export default { this.availability = value; }, }, - safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, actionPrimary: { text: s__('SetStatusModal|Set status') }, actionSecondary: { text: s__('SetStatusModal|Remove status') }, }; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index 78d12ac113b..93fcf2cf1c9 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue @@ -1,7 +1,7 @@ <script> import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issues/constants'; -import { assigneesQueries } from '~/sidebar/constants'; +import { assigneesQueries } from '../../constants'; export default { subscription: null, diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index 4408ebb881b..fd51cd5bb16 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -1,7 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import { n__ } from '~/locale'; -import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue'; +import UncollapsedAssigneeList from './uncollapsed_assignee_list.vue'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 15fd365b4da..7979f450fdd 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -2,9 +2,9 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import eventHub from '~/sidebar/event_hub'; -import Store from '~/sidebar/stores/sidebar_store'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import eventHub from '../../event_hub'; +import Store from '../../stores/sidebar_store'; import AssigneeTitle from './assignee_title.vue'; import Assignees from './assignees.vue'; import AssigneesRealtime from './assignees_realtime.vue'; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 395dcf73693..d6c679f2f07 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -4,12 +4,12 @@ import Vue from 'vue'; import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { __, n__ } from '~/locale'; -import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; -import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { assigneesQueries } from '~/sidebar/constants'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { assigneesQueries } from '../../constants'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; +import SidebarAssigneesRealtime from './assignees_realtime.vue'; +import IssuableAssignees from './issuable_assignees.vue'; import SidebarInviteMembers from './sidebar_invite_members.vue'; export const assigneesWidget = Vue.observable({ diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue index 3532b75b6e7..dbedfe57325 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -3,7 +3,7 @@ import { GlSprintf, GlButton } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; -import { confidentialityQueries } from '~/sidebar/constants'; +import { confidentialityQueries } from '../../constants'; export default { i18n: { diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue index f3bd58c11d4..c2f239b56c7 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -3,8 +3,8 @@ import produce from 'immer'; import Vue from 'vue'; import { createAlert } from '~/flash'; import { __, sprintf } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { confidentialityQueries, Tracking } from '~/sidebar/constants'; +import { confidentialityQueries, Tracking } from '../../constants'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue'; import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue'; diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy/copy_email_to_clipboard.vue index fd652583f76..96ecdc84ef5 100644 --- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue +++ b/app/assets/javascripts/sidebar/components/copy/copy_email_to_clipboard.vue @@ -1,5 +1,5 @@ <script> -import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; +import CopyableField from './copyable_field.vue'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/sidebar/components/copy/copyable_field.vue index 6538de085b0..6538de085b0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue +++ b/app/assets/javascripts/sidebar/components/copy/copyable_field.vue diff --git a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue index d07c6e0cbd2..3287539e502 100644 --- a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue +++ b/app/assets/javascripts/sidebar/components/copy/sidebar_reference_widget.vue @@ -1,7 +1,7 @@ <script> import { __ } from '~/locale'; -import { referenceQueries } from '~/sidebar/constants'; -import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; +import { referenceQueries } from '../../constants'; +import CopyableField from './copyable_field.vue'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue index 81090bfa062..0660e4f58e4 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue +++ b/app/assets/javascripts/sidebar/components/crm_contacts/crm_contacts.vue @@ -4,8 +4,8 @@ import { __, n__, sprintf } from '~/locale'; import { createAlert } from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; -import getIssueCrmContactsQuery from './queries/get_issue_crm_contacts.query.graphql'; -import issueCrmContactsSubscription from './queries/issue_crm_contacts.subscription.graphql'; +import getIssueCrmContactsQuery from '../../queries/get_issue_crm_contacts.query.graphql'; +import issueCrmContactsSubscription from '../../queries/issue_crm_contacts.subscription.graphql'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue index c262d65f6ce..eb48732f558 100644 --- a/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue +++ b/app/assets/javascripts/sidebar/components/date/sidebar_date_widget.vue @@ -4,14 +4,8 @@ import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; import { __, sprintf } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { - dateFields, - dateTypes, - dueDateQueries, - startDateQueries, - Tracking, -} from '~/sidebar/constants'; +import { dateFields, dateTypes, dueDateQueries, startDateQueries, Tracking } from '../../constants'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; import SidebarFormattedDate from './sidebar_formatted_date.vue'; import SidebarInheritDate from './sidebar_inherit_date.vue'; diff --git a/app/assets/javascripts/sidebar/components/incidents/constants.js b/app/assets/javascripts/sidebar/components/incidents/constants.js deleted file mode 100644 index cd05a6099fd..00000000000 --- a/app/assets/javascripts/sidebar/components/incidents/constants.js +++ /dev/null @@ -1,25 +0,0 @@ -import { s__ } from '~/locale'; - -export const STATUS_TRIGGERED = 'TRIGGERED'; -export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED'; -export const STATUS_RESOLVED = 'RESOLVED'; - -export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered'); -export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged'); -export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved'); - -export const STATUS_LABELS = { - [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL, - [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL, - [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL, -}; - -export const i18n = { - fetchError: s__( - 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.', - ), - title: s__('IncidentManagement|Status'), - updateError: s__( - 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.', - ), -}; diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue index 9c41db98c63..72a572087c7 100644 --- a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue +++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue @@ -1,7 +1,12 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { i18n, STATUS_ACKNOWLEDGED, STATUS_TRIGGERED, STATUS_RESOLVED } from './constants'; -import { getStatusLabel } from './utils'; +import { + INCIDENTS_I18N as i18n, + STATUS_ACKNOWLEDGED, + STATUS_TRIGGERED, + STATUS_RESOLVED, +} from '../../constants'; +import { getStatusLabel } from '../../utils'; const STATUS_LIST = [STATUS_TRIGGERED, STATUS_ACKNOWLEDGED, STATUS_RESOLVED]; diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue index 67ae1e6fcab..f7daad63f45 100644 --- a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue +++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue @@ -1,12 +1,15 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants'; import { createAlert } from '~/flash'; import { logError } from '~/lib/logger'; import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue'; +import { + escalationStatusQuery, + escalationStatusMutation, + INCIDENTS_I18N as i18n, +} from '../../constants'; +import { getStatusLabel } from '../../utils'; import SidebarEditableItem from '../sidebar_editable_item.vue'; -import { i18n } from './constants'; -import { getStatusLabel } from './utils'; export default { i18n, diff --git a/app/assets/javascripts/sidebar/components/incidents/utils.js b/app/assets/javascripts/sidebar/components/incidents/utils.js deleted file mode 100644 index 59bf1ea466c..00000000000 --- a/app/assets/javascripts/sidebar/components/incidents/utils.js +++ /dev/null @@ -1,5 +0,0 @@ -import { s__ } from '~/locale'; - -import { STATUS_LABELS } from './constants'; - -export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None'); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js index 00c54313292..00c54313292 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/constants.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue index 9388ef4ba45..864d9b308e7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_button.vue @@ -4,7 +4,7 @@ import { mapActions, mapGetters } from 'vuex'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget` instead. export default { components: { GlButton, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue index 1064cbc26e3..89a976d45fa 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents.vue @@ -6,7 +6,7 @@ import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue` instead. export default { components: { DropdownContentsLabelsView, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue index 3ff3755de46..b8afa67a947 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue @@ -4,7 +4,7 @@ import { mapState, mapActions } from 'vuex'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue` instead. export default { components: { GlButton, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue index e235bfde394..ee6b531c1ca 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue @@ -15,7 +15,7 @@ import LabelItem from './label_item.vue'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue` instead. export default { components: { GlIntersectionObserver, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue index e4325492334..1e9edd222c5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_title.vue @@ -4,7 +4,7 @@ import { mapState, mapActions } from 'vuex'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue` instead. export default { components: { GlButton, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue index e59d150dd43..583f060be8a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue @@ -7,7 +7,7 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue` instead. export default { components: { GlLabel, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue index 5966c78aa51..e84da6ee12b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue @@ -4,7 +4,7 @@ import { s__, sprintf } from '~/locale'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget` instead. export default { directives: { GlTooltip: GlTooltipDirective, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue index 154e3013acd..135fa9f6228 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/label_item.vue @@ -4,7 +4,7 @@ import { __ } from '~/locale'; // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue` instead. export default { functional: true, props: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue index e6c29e24f0c..2a78db352d7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/labels_select_root.vue @@ -17,7 +17,7 @@ Vue.use(Vuex); // @deprecated This component should only be used when there is no GraphQL API. // In most cases you should use -// `app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue` instead. +// `app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue` instead. export default { store: new Vuex.Store(labelsSelectModule()), components: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js index 2dab97826b9..2dab97826b9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/actions.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js index ef3eedd9bb2..ef3eedd9bb2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/getters.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/index.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/index.js index 5f61cb732c8..5f61cb732c8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/index.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/index.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutation_types.js index f26e36031f4..f26e36031f4 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutation_types.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutation_types.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js index c85d9befcbb..c85d9befcbb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/mutations.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/state.js index 0185d5f88e1..0185d5f88e1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/store/state.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js index cd671b4d8f5..cd671b4d8f5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue index 27186281c42..83df9056af2 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue @@ -110,6 +110,9 @@ export default { isStandalone() { return isDropdownVariantStandalone(this.variant); }, + isSidebar() { + return isDropdownVariantSidebar(this.variant); + }, }, watch: { localSelectedLabels: { @@ -129,7 +132,7 @@ export default { } }, selectedLabels(newVal) { - if (!this.isDirty) { + if (!this.isDirty || !this.isSidebar) { this.localSelectedLabels = newVal; } }, @@ -159,7 +162,7 @@ export default { }, handleDropdownHide() { this.$emit('closeDropdown'); - if (!isDropdownVariantSidebar(this.variant)) { + if (!this.isSidebar) { this.setLabels(); } }, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue index ce93ad216ec..aa1184ed314 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue @@ -10,7 +10,7 @@ import { import produce from 'immer'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import { workspaceLabelsQueries } from '~/sidebar/constants'; +import { workspaceLabelsQueries } from '../../../constants'; import createLabelMutation from './graphql/create_label.mutation.graphql'; import { LabelType } from './constants'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue index 1d854505d11..c1939dc7785 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue @@ -4,7 +4,7 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { __ } from '~/locale'; -import { workspaceLabelsQueries } from '~/sidebar/constants'; +import { workspaceLabelsQueries } from '../../../constants'; import LabelItem from './label_item.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue index e67e704ffb8..e67e704ffb8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue index 154a8e866d0..154a8e866d0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue index 57e3ee4aaa5..57e3ee4aaa5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue new file mode 100644 index 00000000000..3a93fc7f3b2 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue @@ -0,0 +1,73 @@ +<script> +import { GlLabel } from '@gitlab/ui'; +import { sortBy } from 'lodash'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + GlLabel, + }, + inject: ['allowScopedLabels'], + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + selectedLabels: { + type: Array, + required: true, + }, + allowLabelRemove: { + type: Boolean, + required: true, + }, + labelsFilterBasePath: { + type: String, + required: true, + }, + labelsFilterParam: { + type: String, + required: true, + }, + }, + computed: { + sortedSelectedLabels() { + return sortBy(this.selectedLabels, (label) => isScopedLabel(label)); + }, + }, + methods: { + buildFilterUrl({ title }) { + const { labelsFilterBasePath: basePath, labelsFilterParam: filterParam } = this; + + return `${basePath}?${filterParam}[]=${encodeURIComponent(title)}`; + }, + showScopedLabel(label) { + return this.allowScopedLabels && isScopedLabel(label); + }, + removeLabel(labelId) { + this.$emit('onLabelRemove', labelId); + }, + }, +}; +</script> + +<template> + <div> + <gl-label + v-for="label in sortedSelectedLabels" + :key="label.id" + class="gl-mr-2 gl-mb-2" + :data-qa-label-name="label.title" + :title="label.title" + :description="label.description" + :background-color="label.color" + :target="buildFilterUrl(label)" + :scoped="showScopedLabel(label)" + :show-close-button="allowLabelRemove" + :disabled="disabled" + tooltip-placement="top" + @close="removeLabel(label.id)" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql index a9c791091fc..a9c791091fc 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql index c442c17eb88..c442c17eb88 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql index cb054e2968f..cb054e2968f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql index ce1a69f84c0..ce1a69f84c0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql index 2904857270e..2904857270e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql index e0cdfd91658..e0cdfd91658 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql index a7c24620aad..a7c24620aad 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue index 314ffbaf84c..314ffbaf84c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue index 2c27a69d587..b7b4bbac661 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue @@ -7,11 +7,12 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IssuableType } from '~/issues/constants'; import { __ } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { issuableLabelsQueries } from '~/sidebar/constants'; +import { issuableLabelsQueries } from '../../../constants'; +import SidebarEditableItem from '../../sidebar_editable_item.vue'; import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants'; import DropdownContents from './dropdown_contents.vue'; import DropdownValue from './dropdown_value.vue'; +import EmbeddedLabelsList from './embedded_labels_list.vue'; import { isDropdownVariantSidebar, isDropdownVariantStandalone, @@ -22,6 +23,7 @@ export default { components: { DropdownValue, DropdownContents, + EmbeddedLabelsList, SidebarEditableItem, }, mixins: [glFeatureFlagsMixin()], @@ -50,6 +52,11 @@ export default { required: false, default: false, }, + showEmbeddedLabelsList: { + type: Boolean, + required: false, + default: false, + }, variant: { type: String, required: false, @@ -106,6 +113,11 @@ export default { type: String, required: true, }, + selectedLabels: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -124,11 +136,21 @@ export default { return this.issuableLabels.map((label) => label.id); }, issuableLabels() { - return this.issuable?.labels.nodes || []; + if (this.iid !== '') { + return this.issuable?.labels.nodes || []; + } + + return this.selectedLabels || []; }, issuableId() { return this.issuable?.id; }, + isRealtimeEnabled() { + return this.glFeatures.realtimeLabels; + }, + isLabelListEnabled() { + return this.showEmbeddedLabelsList && isDropdownVariantEmbedded(this.variant); + }, }, apollo: { issuable: { @@ -311,7 +333,10 @@ export default { } }, handleLabelRemove(labelId) { - this.updateSelectedLabels(this.getRemoveVariables(labelId)); + if (this.iid !== '') { + this.updateSelectedLabels(this.getRemoveVariables(labelId)); + } + this.$emit('onLabelRemove', labelId); }, isDropdownVariantSidebar, @@ -385,22 +410,32 @@ export default { </template> </sidebar-editable-item> </template> - <dropdown-contents - v-else - ref="dropdownContents" - :allow-multiselect="allowMultiselect" - :dropdown-button-text="dropdownButtonText" - :labels-list-title="labelsListTitle" - :footer-create-label-title="footerCreateLabelTitle" - :footer-manage-label-title="footerManageLabelTitle" - :labels-create-title="labelsCreateTitle" - :selected-labels="issuableLabels" - :variant="variant" - :full-path="fullPath" - :workspace-type="workspaceType" - :attr-workspace-path="attrWorkspacePath" - :label-create-type="labelCreateType" - @setLabels="handleDropdownClose" - /> + <template v-else> + <dropdown-contents + ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :dropdown-button-text="dropdownButtonText" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + :labels-create-title="labelsCreateTitle" + :selected-labels="issuableLabels" + :variant="variant" + :full-path="fullPath" + :workspace-type="workspaceType" + :attr-workspace-path="attrWorkspacePath" + :label-create-type="labelCreateType" + @setLabels="handleDropdownClose" + /> + <embedded-labels-list + v-if="isLabelListEnabled" + :disabled="labelsSelectInProgress" + :selected-labels="issuableLabels" + :allow-label-remove="allowLabelRemove" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" + @onLabelRemove="handleLabelRemove" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js index b5cd946a189..b5cd946a189 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index d32d8a7b044..cdce6617591 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -4,8 +4,8 @@ import { mapGetters, mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { createAlert } from '~/flash'; -import eventHub from '~/sidebar/event_hub'; import toast from '~/vue_shared/plugins/global_toast'; +import eventHub from '../../event_hub'; import EditForm from './edit_form.vue'; export default { @@ -111,9 +111,9 @@ export default { </script> <template> - <li v-if="isMergeRequest" class="gl-new-dropdown-item"> + <li v-if="isMergeRequest" class="gl-dropdown-item"> <button type="button" class="dropdown-item" @click="toggleLocked"> - <span class="gl-new-dropdown-item-text-wrapper"> + <span class="gl-dropdown-item-text-wrapper"> <template v-if="isLocked"> {{ __('Unlock merge request') }} </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue index 02323e5a0c6..02323e5a0c6 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/move_issues_button.vue b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue index 6e287ac3bb7..ab4ac9500ad 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/components/move_issues_button.vue +++ b/app/assets/javascripts/sidebar/components/move/move_issues_button.vue @@ -1,7 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { logError } from '~/lib/logger'; import { s__ } from '~/locale'; import { @@ -13,7 +12,8 @@ import { import issuableEventHub from '~/issues/list/eventhub'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; -import moveIssueMutation from './graphql/mutations/move_issue.mutation.graphql'; +import moveIssueMutation from '../../queries/move_issue.mutation.graphql'; +import IssuableMoveDropdown from './issuable_move_dropdown.vue'; export default { name: 'MoveIssuesButton', @@ -130,7 +130,7 @@ export default { this.moveInProgress = false; issuableEventHub.$emit('issuables:bulkMoveEnded'); - createFlash({ + createAlert({ message: s__(`Issues|There was an error while moving the issues.`), }); }); diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue index 46a04725a49..b0556e22a8d 100644 --- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue @@ -1,6 +1,6 @@ <script> import { __ } from '~/locale'; -import { participantsQueries } from '~/sidebar/constants'; +import { participantsQueries } from '../../constants'; import Participants from './participants.vue'; export default { diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index 5e1172ad835..7af8dcb4e3e 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -58,11 +58,21 @@ export default { <collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" /> <div class="value hide-collapsed"> - <template v-if="hasNoUsers"> - <span class="no-value"> - {{ __('None') }} - </span> - </template> + <span v-if="hasNoUsers" class="no-value" data-testid="no-value"> + {{ __('None') }} + <template v-if="editable"> + - + <button + type="button" + class="gl-button btn-link gl-reset-color!" + data-testid="assign-yourself" + data-qa-selector="assign_yourself_button" + @click="assignSelf" + > + {{ __('assign yourself') }} + </button> + </template> + </span> <uncollapsed-reviewer-list v-else diff --git a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue index 5f1350690eb..faa36f3d8d2 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/sidebar_reviewers.vue @@ -5,12 +5,12 @@ import Vue from 'vue'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import eventHub from '~/sidebar/event_hub'; -import Store from '~/sidebar/stores/sidebar_store'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import getMergeRequestReviewersQuery from '~/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql'; -import mergeRequestReviewersUpdatedSubscription from '~/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import eventHub from '../../event_hub'; +import getMergeRequestReviewersQuery from '../../queries/get_merge_request_reviewers.query.graphql'; +import mergeRequestReviewersUpdatedSubscription from '../../queries/merge_request_reviewers.subscription.graphql'; +import Store from '../../stores/sidebar_store'; import ReviewerTitle from './reviewer_title.vue'; import Reviewers from './reviewers.vue'; @@ -143,6 +143,13 @@ export default { eventHub.$off('sidebar.saveReviewers', this.saveReviewers); }, methods: { + reviewBySelf() { + // Notify gl dropdown that we are now assigning to current user + this.$el.parentElement.dispatchEvent(new Event('assignYourself')); + + this.mediator.addSelfReview(); + this.saveReviewers(); + }, saveReviewers() { this.loading = true; @@ -181,6 +188,7 @@ export default { :editable="canUpdate" :issuable-type="issuableType" @request-review="requestReview" + @assign-self="reviewBySelf" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/severity/constants.js b/app/assets/javascripts/sidebar/components/severity/constants.js deleted file mode 100644 index 4f58ff38121..00000000000 --- a/app/assets/javascripts/sidebar/components/severity/constants.js +++ /dev/null @@ -1,41 +0,0 @@ -import { __, s__ } from '~/locale'; - -export const INCIDENT_SEVERITY = { - CRITICAL: { - value: 'CRITICAL', - icon: 'critical', - label: s__('IncidentManagement|Critical - S1'), - }, - HIGH: { - value: 'HIGH', - icon: 'high', - label: s__('IncidentManagement|High - S2'), - }, - MEDIUM: { - value: 'MEDIUM', - icon: 'medium', - label: s__('IncidentManagement|Medium - S3'), - }, - LOW: { - value: 'LOW', - icon: 'low', - label: s__('IncidentManagement|Low - S4'), - }, - UNKNOWN: { - value: 'UNKNOWN', - icon: 'unknown', - label: s__('IncidentManagement|Unknown'), - }, -}; - -export const ISSUABLE_TYPES = { - INCIDENT: 'incident', -}; - -export const I18N = { - UPDATE_SEVERITY_ERROR: s__('SeverityWidget|There was an error while updating severity.'), - TRY_AGAIN: __('Please try again'), - EDIT: __('Edit'), - SEVERITY: s__('SeverityWidget|Severity'), - SEVERITY_VALUE: s__('SeverityWidget|Severity: %{severity}'), -}; diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue index f02e0c783e1..5b624c17b0c 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity.vue @@ -8,8 +8,8 @@ import { GlButton, } from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { INCIDENT_SEVERITY, ISSUABLE_TYPES, I18N } from './constants'; -import updateIssuableSeverity from './graphql/mutations/update_issuable_severity.mutation.graphql'; +import updateIssuableSeverity from '../../queries/update_issuable_severity.mutation.graphql'; +import { INCIDENT_SEVERITY, ISSUABLE_TYPES, SEVERITY_I18N as I18N } from '../../constants'; import SeverityToken from './severity.vue'; export default { diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index a685929cdea..35667495ace 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -6,7 +6,6 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { IssuableType } from '~/issues/constants'; import { timeFor } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { dropdowni18nText, @@ -17,6 +16,7 @@ import { Tracking, } from 'ee_else_ce/sidebar/constants'; import SidebarDropdown from './sidebar_dropdown.vue'; +import SidebarEditableItem from './sidebar_editable_item.vue'; export default { i18n: { diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_dropdown.vue b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue index ba94932289e..7763ec00091 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/components/status_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/status/status_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; -import { statusDropdownOptions } from '../constants'; +import { statusDropdownOptions } from '../../constants'; export default { components: { diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue index 99e7c825b72..0fba1cb5e4e 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue @@ -4,10 +4,10 @@ import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __, sprintf } from '~/locale'; -import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import toast from '~/vue_shared/plugins/global_toast'; -import { subscribedQueries, Tracking } from '~/sidebar/constants'; +import { subscribedQueries, Tracking } from '../../constants'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; const ICON_ON = 'notifications'; const ICON_OFF = 'notifications-off'; @@ -182,7 +182,7 @@ export default { </script> <template> - <gl-dropdown-form v-if="isMergeRequest" class="gl-new-dropdown-item"> + <gl-dropdown-form v-if="isMergeRequest" class="gl-dropdown-item"> <div class="gl-px-5 gl-pb-2 gl-pt-1"> <gl-toggle :value="subscribed" diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/subscriptions_dropdown.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue index 8774b065c22..4c3ba76d12d 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/components/subscriptions_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; -import { subscriptionsDropdownOptions } from '../constants'; +import { subscriptionsDropdownOptions } from '../../constants'; export default { subscriptionsDropdownOptions, diff --git a/app/assets/javascripts/sidebar/components/time_tracking/constants.js b/app/assets/javascripts/sidebar/components/time_tracking/constants.js new file mode 100644 index 00000000000..56e986e3b27 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/constants.js @@ -0,0 +1 @@ +export const CREATE_TIMELOG_MODAL_ID = 'create-timelog-modal'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue new file mode 100644 index 00000000000..ec8e1ee9952 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/create_timelog_form.vue @@ -0,0 +1,227 @@ +<script> +import { + GlFormGroup, + GlFormInput, + GlDatepicker, + GlFormTextarea, + GlModal, + GlAlert, + GlLink, + GlSprintf, +} from '@gitlab/ui'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { formatDate } from '~/lib/utils/datetime_utility'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; +import createTimelogMutation from '../../queries/create_timelog.mutation.graphql'; +import { CREATE_TIMELOG_MODAL_ID } from './constants'; + +export default { + components: { + GlDatepicker, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlModal, + GlAlert, + GlLink, + GlSprintf, + }, + inject: ['issuableType'], + props: { + issuableId: { + type: String, + required: true, + }, + }, + data() { + return { + timeSpent: '', + spentAt: null, + summary: '', + isLoading: false, + saveError: '', + }; + }, + computed: { + submitDisabled() { + return this.isLoading || this.timeSpent.length === 0; + }, + primaryProps() { + return { + text: s__('CreateTimelogForm|Save'), + attributes: [ + { + variant: 'confirm', + disabled: this.submitDisabled, + loading: this.isLoading, + }, + ], + }; + }, + cancelProps() { + return { + text: s__('CreateTimelogForm|Cancel'), + }; + }, + timeTrackingDocsPath() { + return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md'); + }, + issuableTypeName() { + return this.isIssue() + ? s__('CreateTimelogForm|issue') + : s__('CreateTimelogForm|merge request'); + }, + }, + methods: { + resetModal() { + this.isLoading = false; + this.timeSpent = ''; + this.spentAt = null; + this.summary = ''; + this.saveError = ''; + }, + close() { + this.resetModal(); + this.$refs.modal.close(); + }, + registerTimeSpent(event) { + event.preventDefault(); + + if (this.timeSpent.length === 0) { + return; + } + + this.isLoading = true; + this.saveError = ''; + + this.$apollo + .mutate({ + mutation: createTimelogMutation, + variables: { + input: { + timeSpent: this.timeSpent, + spentAt: this.spentAt + ? formatDate(this.spentAt, 'isoDateTime') + : formatDate(Date.now(), 'isoDateTime'), + summary: this.summary, + issuableId: this.getIssuableId(), + }, + }, + }) + .then(({ data }) => { + if (data.timelogCreate?.errors.length) { + this.saveError = data.timelogCreate.errors[0].message || data.timelogCreate.errors[0]; + } else { + this.close(); + } + }) + .catch((error) => { + this.saveError = + error?.message || + s__('CreateTimelogForm|An error occurred while saving the time entry.'); + }) + .finally(() => { + this.isLoading = false; + }); + }, + isIssue() { + return this.issuableType === 'issue'; + }, + getGraphQLEntityType() { + return this.isIssue() ? TYPE_ISSUE : TYPE_MERGE_REQUEST; + }, + updateSpentAtDate(val) { + this.spentAt = val; + }, + getIssuableId() { + return convertToGraphQLId(this.getGraphQLEntityType(), this.issuableId); + }, + }, + CREATE_TIMELOG_MODAL_ID, +}; +</script> + +<template> + <gl-modal + ref="modal" + :title="s__('CreateTimelogForm|Add time entry')" + :modal-id="$options.CREATE_TIMELOG_MODAL_ID" + size="sm" + data-testid="create-timelog-modal" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="registerTimeSpent" + @cancel="close" + @close="close" + @hide="close" + > + <p data-testid="timetracking-docs-link"> + <gl-sprintf + :message=" + s__( + 'CreateTimelogForm|Track time spent on this %{issuableTypeNameStart}%{issuableTypeNameEnd}. %{timeTrackingDocsLinkStart}%{timeTrackingDocsLinkEnd}', + ) + " + > + <template #issuableTypeName>{{ issuableTypeName }}</template> + <template #timeTrackingDocsLink> + <gl-link :href="timeTrackingDocsPath" target="_blank">{{ + s__('CreateTimelogForm|How do I track and estimate time?') + }}</gl-link> + </template> + </gl-sprintf> + </p> + <form + class="gl-display-flex gl-flex-direction-column js-quick-submit" + @submit.prevent="registerTimeSpent" + > + <div class="gl-display-flex gl-gap-3"> + <gl-form-group + key="time-spent" + label-for="time-spent" + :label="s__(`CreateTimelogForm|Time spent`)" + :description="s__(`CreateTimelogForm|Example: 1h 30m`)" + > + <gl-form-input + id="time-spent" + ref="timeSpent" + v-model="timeSpent" + class="gl-form-input-sm" + autocomplete="off" + /> + </gl-form-group> + <gl-form-group + key="spent-at" + optional + label-for="spent-at" + :label="s__(`CreateTimelogForm|Spent at`)" + > + <gl-datepicker + :target="null" + :value="spentAt" + show-clear-button + autocomplete="off" + size="small" + @input="updateSpentAtDate" + @clear="updateSpentAtDate(null)" + /> + </gl-form-group> + </div> + <gl-form-group + :label="s__('CreateTimelogForm|Summary')" + optional + label-for="summary" + class="gl-mb-0" + > + <gl-form-textarea id="summary" v-model="summary" rows="3" :no-resize="true" /> + </gl-form-group> + <gl-alert v-if="saveError" variant="danger" class="gl-mt-5" :dismissible="false"> + {{ saveError }} + </gl-alert> + <!-- This is needed to have the quick-submit behaviour (with Ctrl + Enter or Cmd + Enter) --> + <input type="submit" hidden /> + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index 91c15061fb9..6cd9596e43f 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { joinPaths } from '~/lib/utils/url_utility'; import { sprintf, s__ } from '~/locale'; @@ -9,7 +10,7 @@ export default { GlButton, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, computed: { href() { diff --git a/app/assets/javascripts/sidebar/components/time_tracking/report.vue b/app/assets/javascripts/sidebar/components/time_tracking/report.vue index 124464088cf..6f4ced06ddf 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/report.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/report.vue @@ -5,8 +5,8 @@ import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { formatDate, parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility'; import { __, s__ } from '~/locale'; -import { timelogQueries } from '~/sidebar/constants'; -import deleteTimelogMutation from './graphql/mutations/delete_timelog.mutation.graphql'; +import { timelogQueries } from '../../constants'; +import deleteTimelogMutation from '../../queries/delete_timelog.mutation.graphql'; const TIME_DATE_FORMAT = 'mmmm d, yyyy, HH:MM ("UTC:" o)'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 62b05421884..06adc048942 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -30,6 +30,11 @@ export default { required: false, default: false, }, + canAddTimeEntries: { + type: Boolean, + required: false, + default: true, + }, }, mounted() { this.listenForQuickActions(); @@ -67,6 +72,7 @@ export default { :issuable-id="issuableId" :issuable-iid="issuableIid" :limit-to-hours="limitToHours" + :can-add-time-entries="canAddTimeEntries" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 13981c477c6..b32836dc87d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -9,15 +9,17 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { IssuableType } from '~/issues/constants'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { s__, __ } from '~/locale'; -import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '~/sidebar/constants'; +import { HOW_TO_TRACK_TIME, timeTrackingQueries } from '../../constants'; import eventHub from '../../event_hub'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; -import TimeTrackingHelpState from './help_state.vue'; import TimeTrackingReport from './report.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; +import { CREATE_TIMELOG_MODAL_ID } from './constants'; +import CreateTimelogForm from './create_timelog_form.vue'; export default { name: 'IssuableTimeTracker', @@ -34,8 +36,8 @@ export default { TimeTrackingCollapsedState, TimeTrackingSpentOnlyPane, TimeTrackingComparisonPane, - TimeTrackingHelpState, TimeTrackingReport, + CreateTimelogForm, }, directives: { GlModal: GlModalDirective, @@ -87,6 +89,11 @@ export default { default: true, required: false, }, + canAddTimeEntries: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -192,12 +199,12 @@ export default { eventHub.$on('timeTracker:refresh', this.refresh); }, methods: { - toggleHelpState(show) { - this.showHelp = show; - }, refresh() { this.$apollo.queries.issuableTimeTracking.refetch(); }, + openRegisterTimeSpentModal() { + this.$root.$emit(BV_SHOW_MODAL, CREATE_TIMELOG_MODAL_ID); + }, }, }; </script> @@ -215,24 +222,21 @@ export default { :time-estimate-human-readable="humanTimeEstimate" /> <div - class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center gl-font-weight-bold gl-mr-3" + class="hide-collapsed gl-line-height-20 gl-text-gray-900 gl-display-flex gl-align-items-center gl-font-weight-bold" > {{ __('Time tracking') }} <gl-loading-icon v-if="isTimeTrackingInfoLoading" size="sm" class="gl-ml-2" inline /> <gl-button - :data-testid="showHelpState ? 'closeHelpButton' : 'helpButton'" + v-if="canAddTimeEntries" + v-gl-tooltip.left category="tertiary" size="small" - variant="link" class="gl-ml-auto" - @click="toggleHelpState(!showHelpState)" + data-testid="add-time-entry-button" + :title="__('Add time entry')" + @click="openRegisterTimeSpentModal()" > - <gl-icon - v-gl-tooltip.left - :title="timeTrackingIconTitle" - :name="timeTrackingIconName" - class="gl-text-gray-900!" - /> + <gl-icon name="plus" class="gl-text-gray-900!" /> </gl-button> </div> <div v-if="!isTimeTrackingInfoLoading" class="hide-collapsed"> @@ -272,9 +276,7 @@ export default { <time-tracking-report :limit-to-hours="limitToHours" :issuable-id="issuableId" /> </gl-modal> </template> - <transition name="help-state-toggle"> - <time-tracking-help-state v-if="showHelpState" /> - </transition> + <create-timelog-form :issuable-id="issuableId" /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue index 5da2d65723a..b86ff279fd8 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/sidebar_todo_widget.vue @@ -3,11 +3,11 @@ import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { produce } from 'immer'; import { createAlert } from '~/flash'; import { __, sprintf } from '~/locale'; -import { todoQueries, TodoMutationTypes, todoMutations } from '~/sidebar/constants'; -import { todoLabel } from '~/vue_shared/components/sidebar/todo_toggle//utils'; -import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import Tracking from '~/tracking'; +import { todoQueries, TodoMutationTypes, todoMutations } from '../../constants'; +import { todoLabel } from '../../utils'; +import TodoButton from './todo_button.vue'; const trackingMixin = Tracking.mixin(); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue index cdc7422c7df..b49b8fc389b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo_button.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { todoLabel, updateGlobalTodoCount } from './utils'; +import { todoLabel, updateGlobalTodoCount } from '../../utils'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue index 6dacf4e10d3..6dacf4e10d3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 67b9b540e91..825a89daf58 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -4,55 +4,55 @@ import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutatio import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; import { IssuableType, WorkspaceType } from '~/issues/constants'; -import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; -import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; -import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; -import epicReferenceQuery from '~/sidebar/queries/epic_reference.query.graphql'; -import epicStartDateQuery from '~/sidebar/queries/epic_start_date.query.graphql'; -import epicSubscribedQuery from '~/sidebar/queries/epic_subscribed.query.graphql'; -import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql'; -import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; -import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; -import issueDueDateQuery from '~/sidebar/queries/issue_due_date.query.graphql'; -import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; -import issueSubscribedQuery from '~/sidebar/queries/issue_subscribed.query.graphql'; -import issueTimeTrackingQuery from '~/sidebar/queries/issue_time_tracking.query.graphql'; -import issueTodoQuery from '~/sidebar/queries/issue_todo.query.graphql'; -import mergeRequestMilestone from '~/sidebar/queries/merge_request_milestone.query.graphql'; -import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; -import mergeRequestSubscribed from '~/sidebar/queries/merge_request_subscribed.query.graphql'; -import mergeRequestTimeTrackingQuery from '~/sidebar/queries/merge_request_time_tracking.query.graphql'; -import mergeRequestTodoQuery from '~/sidebar/queries/merge_request_todo.query.graphql'; -import todoCreateMutation from '~/sidebar/queries/todo_create.mutation.graphql'; -import todoMarkDoneMutation from '~/sidebar/queries/todo_mark_done.mutation.graphql'; -import updateEpicConfidentialMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; -import updateEpicDueDateMutation from '~/sidebar/queries/update_epic_due_date.mutation.graphql'; -import updateEpicStartDateMutation from '~/sidebar/queries/update_epic_start_date.mutation.graphql'; -import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; -import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql'; -import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; -import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subscription.mutation.graphql'; -import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql'; -import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; -import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; -import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql'; -import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; -import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql'; -import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; -import mergeRequestLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/merge_request_labels.query.graphql'; -import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; -import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql'; -import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; -import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; -import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql'; -import getMergeRequestAssignees from '~/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql'; -import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql'; -import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; -import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; -import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; -import getEscalationStatusQuery from '~/sidebar/queries/escalation_status.query.graphql'; -import updateEscalationStatusMutation from '~/sidebar/queries/update_escalation_status.mutation.graphql'; +import epicLabelsQuery from './components/labels/labels_select_widget/graphql/epic_labels.query.graphql'; +import updateEpicLabelsMutation from './components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; +import groupLabelsQuery from './components/labels/labels_select_widget/graphql/group_labels.query.graphql'; +import issueLabelsQuery from './components/labels/labels_select_widget/graphql/issue_labels.query.graphql'; +import mergeRequestLabelsQuery from './components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql'; +import projectLabelsQuery from './components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import epicConfidentialQuery from './queries/epic_confidential.query.graphql'; +import epicDueDateQuery from './queries/epic_due_date.query.graphql'; +import epicParticipantsQuery from './queries/epic_participants.query.graphql'; +import epicReferenceQuery from './queries/epic_reference.query.graphql'; +import epicStartDateQuery from './queries/epic_start_date.query.graphql'; +import epicSubscribedQuery from './queries/epic_subscribed.query.graphql'; +import epicTodoQuery from './queries/epic_todo.query.graphql'; +import issuableAssigneesSubscription from './queries/issuable_assignees.subscription.graphql'; +import issueConfidentialQuery from './queries/issue_confidential.query.graphql'; +import issueDueDateQuery from './queries/issue_due_date.query.graphql'; +import issueReferenceQuery from './queries/issue_reference.query.graphql'; +import issueSubscribedQuery from './queries/issue_subscribed.query.graphql'; +import issueTimeTrackingQuery from './queries/issue_time_tracking.query.graphql'; +import issueTodoQuery from './queries/issue_todo.query.graphql'; +import mergeRequestMilestone from './queries/merge_request_milestone.query.graphql'; +import mergeRequestReferenceQuery from './queries/merge_request_reference.query.graphql'; +import mergeRequestSubscribed from './queries/merge_request_subscribed.query.graphql'; +import mergeRequestTimeTrackingQuery from './queries/merge_request_time_tracking.query.graphql'; +import mergeRequestTodoQuery from './queries/merge_request_todo.query.graphql'; +import todoCreateMutation from './queries/todo_create.mutation.graphql'; +import todoMarkDoneMutation from './queries/todo_mark_done.mutation.graphql'; +import updateEpicConfidentialMutation from './queries/update_epic_confidential.mutation.graphql'; +import updateEpicDueDateMutation from './queries/update_epic_due_date.mutation.graphql'; +import updateEpicStartDateMutation from './queries/update_epic_start_date.mutation.graphql'; +import updateEpicSubscriptionMutation from './queries/update_epic_subscription.mutation.graphql'; +import updateIssueConfidentialMutation from './queries/update_issue_confidential.mutation.graphql'; +import updateIssueDueDateMutation from './queries/update_issue_due_date.mutation.graphql'; +import updateIssueSubscriptionMutation from './queries/update_issue_subscription.mutation.graphql'; +import mergeRequestMilestoneMutation from './queries/update_merge_request_milestone.mutation.graphql'; +import updateMergeRequestLabelsMutation from './queries/update_merge_request_labels.mutation.graphql'; +import updateMergeRequestSubscriptionMutation from './queries/update_merge_request_subscription.mutation.graphql'; +import getAlertAssignees from './queries/get_alert_assignees.query.graphql'; +import getIssueAssignees from './queries/get_issue_assignees.query.graphql'; +import issueParticipantsQuery from './queries/get_issue_participants.query.graphql'; +import getIssueTimelogsQuery from './queries/get_issue_timelogs.query.graphql'; +import getMergeRequestAssignees from './queries/get_mr_assignees.query.graphql'; +import getMergeRequestParticipants from './queries/get_mr_participants.query.graphql'; +import getMrTimelogsQuery from './queries/get_mr_timelogs.query.graphql'; +import updateIssueAssigneesMutation from './queries/update_issue_assignees.mutation.graphql'; +import updateMergeRequestAssigneesMutation from './queries/update_mr_assignees.mutation.graphql'; +import getEscalationStatusQuery from './queries/escalation_status.query.graphql'; +import updateEscalationStatusMutation from './queries/update_escalation_status.mutation.graphql'; import groupMilestonesQuery from './queries/group_milestones.query.graphql'; import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql'; import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; @@ -350,3 +350,94 @@ export const escalationStatusQuery = getEscalationStatusQuery; export const escalationStatusMutation = updateEscalationStatusMutation; export const HOW_TO_TRACK_TIME = __('How to track time'); + +export const statusDropdownOptions = [ + { + text: __('Open'), + value: 'reopen', + }, + { + text: __('Closed'), + value: 'close', + }, +]; + +export const subscriptionsDropdownOptions = [ + { + text: __('Subscribe'), + value: 'subscribe', + }, + { + text: __('Unsubscribe'), + value: 'unsubscribe', + }, +]; + +export const INCIDENT_SEVERITY = { + CRITICAL: { + value: 'CRITICAL', + icon: 'critical', + label: s__('IncidentManagement|Critical - S1'), + }, + HIGH: { + value: 'HIGH', + icon: 'high', + label: s__('IncidentManagement|High - S2'), + }, + MEDIUM: { + value: 'MEDIUM', + icon: 'medium', + label: s__('IncidentManagement|Medium - S3'), + }, + LOW: { + value: 'LOW', + icon: 'low', + label: s__('IncidentManagement|Low - S4'), + }, + UNKNOWN: { + value: 'UNKNOWN', + icon: 'unknown', + label: s__('IncidentManagement|Unknown'), + }, +}; + +export const ISSUABLE_TYPES = { + INCIDENT: 'incident', +}; + +export const MILESTONE_STATE = { + ACTIVE: 'active', + CLOSED: 'closed', +}; + +export const SEVERITY_I18N = { + UPDATE_SEVERITY_ERROR: s__('SeverityWidget|There was an error while updating severity.'), + TRY_AGAIN: __('Please try again'), + EDIT: __('Edit'), + SEVERITY: s__('SeverityWidget|Severity'), + SEVERITY_VALUE: s__('SeverityWidget|Severity: %{severity}'), +}; + +export const STATUS_TRIGGERED = 'TRIGGERED'; +export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED'; +export const STATUS_RESOLVED = 'RESOLVED'; + +export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered'); +export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged'); +export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved'); + +export const STATUS_LABELS = { + [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL, + [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL, + [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL, +}; + +export const INCIDENTS_I18N = { + fetchError: s__( + 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.', + ), + title: s__('IncidentManagement|Status'), + updateError: s__( + 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.', + ), +}; diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index afce59d304f..b908cf0cd9e 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -39,6 +39,7 @@ export default class SidebarMilestone { humanTimeEstimate, humanTotalTimeSpent: humanTimeSpent, }, + canAddTimeEntries: false, }, }), }); diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index b37486283ca..a308dc8d13c 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -6,6 +6,7 @@ import { convertToGraphQLId } from '~/graphql_shared/utils'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { IssuableType } from '~/issues/constants'; +import { gqlClient } from '~/issues/list/graphql'; import { isInIssuePage, isInDesignPage, @@ -14,33 +15,36 @@ import { parseBoolean, } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue'; -import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; -import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; -import SidebarDueDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue'; -import MilestoneDropdown from '~/sidebar/components/milestone/milestone_dropdown.vue'; -import SidebarParticipantsWidget from '~/sidebar/components/participants/sidebar_participants_widget.vue'; -import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; -import SidebarDropdownWidget from '~/sidebar/components/sidebar_dropdown_widget.vue'; -import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import { apolloProvider } from '~/graphql_shared/issuable_client'; -import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; -import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import Translate from '../vue_shared/translate'; +import Translate from '~/vue_shared/translate'; +import CollapsedAssigneeList from './components/assignees/collapsed_assignee_list.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; -import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; +import SidebarAssigneesWidget from './components/assignees/sidebar_assignees_widget.vue'; +import SidebarConfidentialityWidget from './components/confidential/sidebar_confidentiality_widget.vue'; +import CopyEmailToClipboard from './components/copy/copy_email_to_clipboard.vue'; +import SidebarDueDateWidget from './components/date/sidebar_date_widget.vue'; import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue'; +import { DropdownVariant } from './components/labels/labels_select_vue/constants'; +import { LabelType } from './components/labels/labels_select_widget/constants'; +import LabelsSelectWidget from './components/labels/labels_select_widget/labels_select_root.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; +import MilestoneDropdown from './components/milestone/milestone_dropdown.vue'; +import MoveIssuesButton from './components/move/move_issues_button.vue'; +import SidebarParticipantsWidget from './components/participants/sidebar_participants_widget.vue'; +import SidebarReferenceWidget from './components/copy/sidebar_reference_widget.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarReviewersInputs from './components/reviewers/sidebar_reviewers_inputs.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; +import SidebarDropdownWidget from './components/sidebar_dropdown_widget.vue'; +import StatusDropdown from './components/status/status_dropdown.vue'; import SidebarSubscriptionsWidget from './components/subscriptions/sidebar_subscriptions_widget.vue'; +import SubscriptionsDropdown from './components/subscriptions/subscriptions_dropdown.vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue'; +import SidebarTodoWidget from './components/todo_toggle/sidebar_todo_widget.vue'; import { IssuableAttributeType } from './constants'; -import SidebarMoveIssue from './lib/sidebar_move_issue'; import CrmContacts from './components/crm_contacts/crm_contacts.vue'; +import SidebarMoveIssue from './lib/sidebar_move_issue'; +import trackShowInviteMemberLink from './track_invite_members'; Vue.use(Translate); Vue.use(VueApollo); @@ -540,7 +544,15 @@ function mountSidebarSubscriptionsWidget() { function mountSidebarTimeTracking() { const el = document.querySelector('.js-sidebar-time-tracking-root'); - const { id, iid, fullPath, issuableType, timeTrackingLimitToHours } = getSidebarOptions(); + + const { + id, + iid, + fullPath, + issuableType, + timeTrackingLimitToHours, + canCreateTimelogs, + } = getSidebarOptions(); if (!el) { return null; @@ -558,6 +570,7 @@ function mountSidebarTimeTracking() { issuableId: id.toString(), issuableIid: iid.toString(), limitToHours: timeTrackingLimitToHours, + canAddTimeEntries: canCreateTimelogs, }, }), }); @@ -635,6 +648,59 @@ function mountCopyEmailToClipboard() { }); } +export function mountMoveIssuesButton() { + const el = document.querySelector('.js-move-issues'); + + if (!el) { + return null; + } + + Vue.use(VueApollo); + + return new Vue({ + el, + name: 'MoveIssuesRoot', + apolloProvider: new VueApollo({ + defaultClient: gqlClient, + }), + render: (createElement) => + createElement(MoveIssuesButton, { + props: { + projectFullPath: el.dataset.projectFullPath, + projectsFetchPath: el.dataset.projectsFetchPath, + }, + }), + }); +} + +export function mountStatusDropdown() { + const el = document.querySelector('.js-status-dropdown'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'StatusDropdownRoot', + render: (createElement) => createElement(StatusDropdown), + }); +} + +export function mountSubscriptionsDropdown() { + const el = document.querySelector('.js-subscriptions-dropdown'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'SubscriptionsDropdownRoot', + render: (createElement) => createElement(SubscriptionsDropdown), + }); +} + const isAssigneesWidgetShown = (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; diff --git a/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql b/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql new file mode 100644 index 00000000000..a8692387a46 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/create_timelog.mutation.graphql @@ -0,0 +1,17 @@ +#import "~/graphql_shared/fragments/issue_time_tracking.fragment.graphql" +#import "~/graphql_shared/fragments/merge_request_time_tracking.fragment.graphql" + +mutation createTimelog($input: TimelogCreateInput!) { + timelogCreate(input: $input) { + errors + timelog { + id + issue { + ...IssueTimeTrackingFragment + } + mergeRequest { + ...MergeRequestTimeTrackingFragment + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql b/app/assets/javascripts/sidebar/queries/delete_timelog.mutation.graphql index 6e916893b5a..6e916893b5a 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/delete_timelog.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/sidebar/queries/get_alert_assignees.query.graphql index bb6c7181e5c..171eca50eab 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_alert_assignees.query.graphql @@ -9,6 +9,7 @@ query alertAssignees( workspace: project(fullPath: $fullPath) { id issuable: alertManagementAlert(domain: $domain, iid: $iid) { + id iid assignees { nodes { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_assignees.query.graphql index 4af07366a6d..4af07366a6d 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_issue_assignees.query.graphql diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_crm_contacts.query.graphql index 30a0af10d56..30a0af10d56 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_issue_crm_contacts.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_participants.query.graphql index eae5e96ac46..eae5e96ac46 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_issue_participants.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql b/app/assets/javascripts/sidebar/queries/get_issue_timelogs.query.graphql index b127b8ec5a9..b127b8ec5a9 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_issue_timelogs.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql b/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql index f087ca6c982..f087ca6c982 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_merge_request_reviewers.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_merge_request_reviewers.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/sidebar/queries/get_mr_assignees.query.graphql index f70cd723f2e..f70cd723f2e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_mr_assignees.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/sidebar/queries/get_mr_participants.query.graphql index 2781ac71f31..2781ac71f31 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_mr_participants.query.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql b/app/assets/javascripts/sidebar/queries/get_mr_timelogs.query.graphql index 17f548b44b5..17f548b44b5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql +++ b/app/assets/javascripts/sidebar/queries/get_mr_timelogs.query.graphql diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.fragment.graphql index 750e1f1d1af..750e1f1d1af 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.fragment.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.fragment.graphql diff --git a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.subscription.graphql index f3b6e4ec06f..f3b6e4ec06f 100644 --- a/app/assets/javascripts/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql +++ b/app/assets/javascripts/sidebar/queries/issue_crm_contacts.subscription.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql b/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql index a1b16b378b3..a1b16b378b3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/merge_request_reviewers.subscription.graphql +++ b/app/assets/javascripts/sidebar/queries/merge_request_reviewers.subscription.graphql diff --git a/app/assets/javascripts/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql index d350072425b..d350072425b 100644 --- a/app/assets/javascripts/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/move_issue.mutation.graphql diff --git a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issuable_severity.mutation.graphql index c9d36dfdb67..c9d36dfdb67 100644 --- a/app/assets/javascripts/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issuable_severity.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_assignees.mutation.graphql index 24de5ea4fe3..24de5ea4fe3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issue_assignees.mutation.graphql diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_lock.mutation.graphql index cb9ee6abc9b..cb9ee6abc9b 100644 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issue_lock.mutation.graphql diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_lock.mutation.graphql index 11eb3611006..11eb3611006 100644 --- a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_lock.mutation.graphql diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_mr_assignees.mutation.graphql index 5fec2ccbdfb..5fec2ccbdfb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_mr_assignees.mutation.graphql diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 912f0fdcbef..c6a66ab2275 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,9 +1,9 @@ -import Store from '~/sidebar/stores/sidebar_store'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import toast from '~/vue_shared/plugins/global_toast'; -import { visitUrl } from '../lib/utils/url_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; import Service from './services/sidebar_service'; +import Store from './stores/sidebar_store'; export default class SidebarMediator { constructor(options) { @@ -31,6 +31,9 @@ export default class SidebarMediator { assignYourself() { this.store.addAssignee(this.store.currentUser); } + addSelfReview() { + this.store.addReviewer(this.store.currentUser); + } async saveAssignees(field) { const selected = this.store.assignees.map((u) => u.id); @@ -56,12 +59,14 @@ export default class SidebarMediator { } async saveReviewers(field) { - const selected = this.store.reviewers.map((u) => u.id); + const selectedReviewers = this.store.reviewers; + const selectedIds = selectedReviewers.map((u) => u.id); + const suggestedSelectedIds = selectedReviewers.filter((u) => u.suggested).map((u) => u.id); // If there are no ids, that means we have to unassign (which is id = 0) // And it only accepts an array, hence [0] - const reviewers = selected.length === 0 ? [0] : selected; - const data = { reviewer_ids: reviewers }; + const reviewers = selectedIds.length === 0 ? [0] : selectedIds; + const data = { reviewer_ids: reviewers, suggested_reviewer_ids: suggestedSelectedIds }; try { const res = await this.service.update(field, data); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js b/app/assets/javascripts/sidebar/utils.js index 098ab72dfb5..6b90fb80abf 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/utils.js +++ b/app/assets/javascripts/sidebar/utils.js @@ -1,4 +1,7 @@ -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { STATUS_LABELS } from './constants'; + +export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None'); export const todoLabel = (hasTodo) => { return hasTodo ? __('Mark as done') : __('Add a to do'); diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue index 737a131ce7c..ab2ff6e0ef8 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; export default { diff --git a/app/assets/javascripts/surveys/merge_request_experience/app.vue b/app/assets/javascripts/surveys/merge_request_experience/app.vue index df114c27908..6e90ad2e0fd 100644 --- a/app/assets/javascripts/surveys/merge_request_experience/app.vue +++ b/app/assets/javascripts/surveys/merge_request_experience/app.vue @@ -1,6 +1,7 @@ <script> -import { GlButton, GlSprintf, GlSafeHtmlDirective, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import gitlabLogo from '@gitlab/svgs/dist/illustrations/gitlab_logo.svg'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, __ } from '~/locale'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import SatisfactionRate from '~/surveys/components/satisfaction_rate.vue'; @@ -30,7 +31,7 @@ export default { SatisfactionRate, }, directives: { - safeHtml: GlSafeHtmlDirective, + SafeHtml, tooltip: GlTooltipDirective, }, mixins: [Tracking.mixin()], diff --git a/app/assets/javascripts/tags/init_new_tag_ref_selector.js b/app/assets/javascripts/tags/init_new_tag_ref_selector.js new file mode 100644 index 00000000000..11c7516f16c --- /dev/null +++ b/app/assets/javascripts/tags/init_new_tag_ref_selector.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import RefSelector from '~/ref/components/ref_selector.vue'; + +export default function initNewTagRefSelector() { + const el = document.querySelector('.js-new-tag-ref-selector'); + + if (el) { + const { projectId, defaultBranchName, hiddenInputName } = el.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + render(createComponent) { + return createComponent(RefSelector, { + props: { + value: defaultBranchName, + name: hiddenInputName, + projectId, + }, + }); + }, + }); + } +} diff --git a/app/assets/javascripts/terms/components/app.vue b/app/assets/javascripts/terms/components/app.vue index a54a198faed..eecf32f83df 100644 --- a/app/assets/javascripts/terms/components/app.vue +++ b/app/assets/javascripts/terms/components/app.vue @@ -1,13 +1,13 @@ <script> -import $ from 'jquery'; -import { GlButton, GlIntersectionObserver, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlIntersectionObserver } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import csrf from '~/lib/utils/csrf'; -import '~/behaviors/markdown/render_gfm'; import { trackTrialAcceptTerms } from '~/google_tag_manager'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { name: 'TermsApp', @@ -54,7 +54,7 @@ export default { }, methods: { renderGFM() { - $(this.$refs.gfmContainer).renderGFM(); + renderGFM(this.$refs.gfmContainer); }, handleBottomReached() { this.acceptDisabled = false; @@ -81,7 +81,7 @@ export default { <template> <div> - <div class="gl-card-body gl-relative gl-pb-0 gl-px-0" data-qa-selector="terms_content"> + <div class="gl-relative gl-pb-0 gl-px-0" data-qa-selector="terms_content"> <div class="terms-fade gl-absolute gl-left-5 gl-right-5 gl-bottom-0 gl-h-11 gl-pointer-events-none" ></div> @@ -96,7 +96,7 @@ export default { </gl-intersection-observer> </div> </div> - <div v-if="isLoggedIn" class="gl-card-footer gl-display-flex gl-justify-content-end"> + <div v-if="isLoggedIn" class="gl-display-flex gl-justify-content-end"> <form v-if="permissions.canDecline" method="post" :action="paths.decline"> <gl-button type="submit">{{ $options.i18n.decline }}</gl-button> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> diff --git a/app/assets/javascripts/terraform/components/init_command_modal.vue b/app/assets/javascripts/terraform/components/init_command_modal.vue index 2cb10d4ae23..0d8a883972f 100644 --- a/app/assets/javascripts/terraform/components/init_command_modal.vue +++ b/app/assets/javascripts/terraform/components/init_command_modal.vue @@ -39,11 +39,13 @@ export default { }, methods: { getModalInfoCopyStr() { + const stateNameEncoded = encodeURIComponent(this.stateName); + return `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN> terraform init \\ - -backend-config="address=${this.terraformApiUrl}/${this.stateName}" \\ - -backend-config="lock_address=${this.terraformApiUrl}/${this.stateName}/lock" \\ - -backend-config="unlock_address=${this.terraformApiUrl}/${this.stateName}/lock" \\ + -backend-config="address=${this.terraformApiUrl}/${stateNameEncoded}" \\ + -backend-config="lock_address=${this.terraformApiUrl}/${stateNameEncoded}/lock" \\ + -backend-config="unlock_address=${this.terraformApiUrl}/${stateNameEncoded}/lock" \\ -backend-config="username=${this.username}" \\ -backend-config="password=$GITLAB_ACCESS_TOKEN" \\ -backend-config="lock_method=POST" \\ diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue index 1ad18508294..a4dc783f1e4 100644 --- a/app/assets/javascripts/tooltips/components/tooltips.vue +++ b/app/assets/javascripts/tooltips/components/tooltips.vue @@ -1,6 +1,7 @@ <script> -import { GlTooltip, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlTooltip } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; const getTooltipTitle = (element) => { return element.getAttribute('title') || element.dataset.title; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 2cfeb7a4bcb..eb93f42e2f3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -189,8 +189,11 @@ export default { .then((data) => { this.mr.setApprovals(data); - eventHub.$emit('MRWidgetUpdateRequested'); - eventHub.$emit('ApprovalUpdated'); + if (!window.gon?.features?.realtimeMrStatusChange) { + eventHub.$emit('MRWidgetUpdateRequested'); + eventHub.$emit('ApprovalUpdated'); + } + this.$emit('updated'); }) .catch(errFn) diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue index 1256b3a8e52..c7d34d45f06 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon, GlSprintf, GlLink } from '@gitlab/ui'; import { backOff } from '~/lib/utils/common_utils'; -import statusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; import { bytesToMiB } from '~/lib/utils/number_utils'; import { s__ } from '~/locale'; import MemoryGraph from '~/vue_shared/components/memory_graph.vue'; @@ -107,7 +107,7 @@ export default { backOff((next, stop) => { MRWidgetService.fetchMetrics(this.metricsUrl) .then((res) => { - if (res.status === statusCodes.NO_CONTENT) { + if (res.status === HTTP_STATUS_NO_CONTENT) { this.backOffRequestCounter += 1; /* eslint-disable no-unused-expressions */ this.backOffRequestCounter < 3 ? next() : stop(res); @@ -118,7 +118,7 @@ export default { .catch(stop); }) .then((res) => { - if (res.status === statusCodes.NO_CONTENT) { + if (res.status === HTTP_STATUS_NO_CONTENT) { return res; } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 3d03dbd9db3..e8cc9b2eb2a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -1,12 +1,7 @@ <script> -import { - GlButton, - GlLoadingIcon, - GlSafeHtmlDirective, - GlTooltipDirective, - GlIntersectionObserver, -} from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIntersectionObserver } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; import { sprintf, s__, __ } from '~/locale'; import Poll from '~/lib/utils/poll'; @@ -40,7 +35,7 @@ export default { StateContainer, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, GlTooltip: GlTooltipDirective, }, data() { @@ -323,19 +318,23 @@ export default { @mouseup="onRowMouseUp" > <div + :class="{ 'gl-h-full': isLoadingSummary }" class="media-body gl-display-flex gl-flex-direction-row! gl-w-full" data-testid="widget-extension-top-level" > - <div class="gl-flex-grow-1" data-testid="widget-extension-top-level-summary"> + <div + class="gl-flex-grow-1 gl-display-flex gl-align-items-center" + data-testid="widget-extension-top-level-summary" + > <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <template v-else-if="hasFetchError">{{ widgetErrorText }}</template> - <div v-else> + <template v-else> <span v-safe-html="hydratedSummary.subject"></span> <template v-if="hydratedSummary.meta"> <br /> <span v-safe-html="hydratedSummary.meta" class="gl-font-sm"></span> </template> - </div> + </template> </div> <actions :widget="$options.label || $options.name" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue index a10e5efa0e7..fa369d23b6c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/child_content.vue @@ -1,6 +1,7 @@ <script> -import { GlBadge, GlLink, GlSafeHtmlDirective, GlModalDirective } from '@gitlab/ui'; +import { GlBadge, GlLink, GlModalDirective } from '@gitlab/ui'; import { isArray } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Actions from '../action_buttons.vue'; import StatusIcon from './status_icon.vue'; import { generateText } from './utils'; @@ -14,7 +15,7 @@ export default { Actions, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, GlModal: GlModalDirective, }, props: { @@ -97,7 +98,12 @@ export default { <div v-if="data.supportingText"> <p v-safe-html="generateText(data.supportingText)" class="gl-m-0"></p> </div> - <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> + <gl-badge + v-if="data.badge" + :variant="data.badge.variant || 'info'" + size="sm" + class="gl-ml-2" + > {{ data.badge.text }} </gl-badge> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue index f71b1fbc539..79ea2624ec5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue @@ -1,8 +1,11 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; export default { name: 'MrWidgetAuthor', + components: { + GlLink, + }, directives: { GlTooltip: GlTooltipDirective, }, @@ -28,13 +31,16 @@ export default { }; </script> <template> - <a + <gl-link v-gl-tooltip :href="authorUrl" :title="showAuthorName ? null : author.name" - class="author-link inline" + class="mr-widget-author" > - <img :src="avatarUrl" class="avatar avatar-inline s16" /> - <span v-if="showAuthorName" class="author">{{ author.name }}</span> - </a> + <img :src="avatarUrl" :alt="author.name" class="avatar avatar-inline s16" /><span + v-if="showAuthorName" + class="author" + >{{ author.name }}</span + > + </gl-link> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 97c6de37054..d8a361066f4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -7,8 +7,8 @@ import { GlSprintf, GlTooltip, GlTooltipDirective, - GlSafeHtmlDirective, } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; @@ -33,7 +33,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { pipeline: { @@ -190,7 +190,7 @@ export default { </template> <template v-else-if="hasPipeline"> <a :href="status.details_path" class="gl-align-self-center gl-mr-3"> - <ci-icon :status="status" :size="24" /> + <ci-icon :status="status" :size="24" class="gl-display-flex" /> </a> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> @@ -277,9 +277,9 @@ export default { v-if="pipeline.details.stages" :downstream-pipelines="pipeline.triggered" :is-merge-train="isMergeTrain" + :pipeline-path="pipeline.path" :stages="pipeline.details.stages" :upstream-pipeline="pipeline.triggered_by" - stages-class="mr-widget-pipeline-stages" /> <pipeline-artifacts :pipeline-id="pipeline.id" :artifacts="artifacts" class="gl-ml-3" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue index 870972156c5..1fd1e264c25 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue @@ -1,5 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlLink } from '@gitlab/ui'; +import { GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; export default { @@ -54,16 +55,16 @@ export default { </script> <template> <section> - <p v-if="relatedLinks.closing" class="gl-display-inline gl-m-0"> + <p v-if="relatedLinks.closing" class="gl-display-inline gl-m-0 gl-font-sm!"> {{ closesText }} <span v-safe-html="relatedLinks.closing"></span> </p> - <p v-if="relatedLinks.mentioned" class="gl-display-inline gl-m-0"> + <p v-if="relatedLinks.mentioned" class="gl-display-inline gl-m-0 gl-font-sm!"> <span v-if="relatedLinks.closing">·</span> {{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }} <span v-safe-html="relatedLinks.mentioned"></span> </p> - <p v-if="shouldShowAssignToMeLink" class="gl-display-inline gl-m-0"> + <p v-if="shouldShowAssignToMeLink" class="gl-display-inline gl-m-0 gl-font-sm!"> <span> <gl-link rel="nofollow" data-method="post" :href="relatedLinks.assignToMe">{{ assignIssueText diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue index 66e33a08a12..9a3555d3e11 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -54,7 +54,7 @@ export default { <template> <div - class="mr-widget-body media mr-widget-body-line-height-1 gl-line-height-normal" + class="mr-widget-body media gl-display-flex gl-align-items-center" :class="wrapperClasses" v-on="$listeners" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index 38b99dae264..e5688091cc7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -1,6 +1,6 @@ <script> import { s__ } from '~/locale'; -import StatusIcon from '../mr_widget_status_icon.vue'; +import StateContainer from '../state_container.vue'; import { DETAILED_MERGE_STATUS } from '../../constants'; export default { @@ -12,7 +12,7 @@ export default { externalStatusChecksFailed: s__('mrWidget|Merge blocked: all status checks must pass.'), }, components: { - StatusIcon, + StateContainer, }, props: { mr: { @@ -37,10 +37,11 @@ export default { </script> <template> - <div class="mr-widget-body media gl-flex-wrap"> - <status-icon status="failed" /> - <p class="media-body gl-m-0! gl-font-weight-bold gl-text-black-normal!"> + <state-container :mr="mr" status="failed"> + <span + class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" + > {{ failedText }} - </p> - </div> + </span> + </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 806f8f939a6..6bcf88713a5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -1,7 +1,17 @@ <script> +import api from '~/api'; +import showGlobalToast from '~/vue_shared/plugins/global_toast'; + import MrWidgetAuthorTime from '../mr_widget_author_time.vue'; import StateContainer from '../state_container.vue'; +import { + MR_WIDGET_CLOSED_REOPEN, + MR_WIDGET_CLOSED_REOPENING, + MR_WIDGET_CLOSED_RELOADING, + MR_WIDGET_CLOSED_REOPEN_FAILURE, +} from '../../i18n'; + export default { name: 'MRWidgetClosed', components: { @@ -14,10 +24,62 @@ export default { required: true, }, }, + data() { + return { + isPending: false, + isReloading: false, + }; + }, + computed: { + reopenText() { + let text = MR_WIDGET_CLOSED_REOPEN; + + if (this.isPending) { + text = MR_WIDGET_CLOSED_REOPENING; + } else if (this.isReloading) { + text = MR_WIDGET_CLOSED_RELOADING; + } + + return text; + }, + actions() { + if (!window.gon?.current_user_id) { + return []; + } + + return [ + { + text: this.reopenText, + loading: this.isPending || this.isReloading, + onClick: this.reopen, + testId: 'extension-actions-reopen-button', + }, + ]; + }, + }, + methods: { + reopen() { + this.isPending = true; + + api + .updateMergeRequest(this.mr.targetProjectId, this.mr.iid, { state_event: 'reopen' }) + .then(() => { + this.isReloading = true; + + window.location.reload(); + }) + .catch(() => { + showGlobalToast(MR_WIDGET_CLOSED_REOPEN_FAILURE); + }) + .finally(() => { + this.isPending = false; + }); + }, + }, }; </script> <template> - <state-container :mr="mr" status="closed"> + <state-container :mr="mr" status="closed" :actions="actions"> <mr-widget-author-time :action-text="s__('mrWidget|Closed by')" :author="mr.metrics.closedBy" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index 4902c9b45e8..850a4e2fd56 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import emptyStateSVG from 'icons/_mr_widget_empty_state.svg'; import api from '~/api'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -12,7 +13,7 @@ export default { GlLink, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { mr: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index c54672cd0f8..23b163e2c6a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -20,6 +20,8 @@ import simplePoll from '~/lib/utils/simple_poll'; import { __, s__, n__ } from '~/locale'; import SmartInterval from '~/smart_interval'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql'; import { AUTO_MERGE_STRATEGIES, WARNING, @@ -87,6 +89,31 @@ export default { this.initPolling(); } }, + subscribeToMore: { + document() { + return readyToMergeSubscription; + }, + skip() { + return !this.mr?.id || this.loading || !window.gon?.features?.realtimeMrStatusChange; + }, + variables() { + return { + issuableId: convertToGraphQLId('MergeRequest', this.mr?.id), + }; + }, + updateQuery( + _, + { + subscriptionData: { + data: { mergeRequestMergeStatusUpdated }, + }, + }, + ) { + if (mergeRequestMergeStatusUpdated) { + this.state = mergeRequestMergeStatusUpdated; + } + }, + }, }, }, components: { @@ -295,7 +322,7 @@ export default { return this.mr.divergedCommitsCount > 0; }, showMergeDetailsHeader() { - return ['readyToMerge'].indexOf(this.mr.state) >= 0; + return !['readyToMerge'].includes(this.mr.state); }, }, mounted() { @@ -467,8 +494,9 @@ export default { <template> <div + :class="{ 'gl-bg-gray-10': mr.state !== 'closed' && mr.state !== 'merged' }" data-testid="ready_to_merge_state" - class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-pl-7" + class="gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-pl-7" > <div v-if="loading" class="mr-widget-body"> <div class="gl-w-full mr-ready-to-merge-loader"> @@ -481,7 +509,9 @@ export default { </div> </div> <template v-else> - <div class="mr-widget-body mr-widget-body-ready-merge media mr-widget-body-line-height-1"> + <div + class="mr-widget-body mr-widget-body-ready-merge media gl-display-flex gl-align-items-center" + > <div class="media-body"> <div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap"> <template v-if="shouldShowMergeControls"> @@ -555,7 +585,19 @@ export default { </li> </ul> </div> - <div class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5"> + <div + class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5 mr-widget-merge-details" + > + <template v-if="sourceHasDivergedFromTarget"> + <gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText"> + <template #link> + <gl-link :href="mr.targetBranchPath">{{ + $options.i18n.divergedCommits(mr.divergedCommitsCount) + }}</gl-link> + </template> + </gl-sprintf> + · + </template> <added-commit-message :is-squash-enabled="squashBeforeMerge" :is-fast-forward-enabled="!shouldShowMergeEdit" @@ -631,7 +673,7 @@ export default { class="gl-w-full gl-order-n1 mr-widget-merge-details" data-qa-selector="merged_status_content" > - <p v-if="showMergeDetailsHeader" class="gl-mb-3 gl-text-gray-900"> + <p v-if="showMergeDetailsHeader" class="gl-mb-2 gl-text-gray-900"> {{ __('Merge details') }} </p> <ul class="gl-pl-4 gl-mb-0 gl-ml-3 gl-text-gray-600"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 074758e33b2..9f3748599dc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -26,7 +26,7 @@ export default { <template> <state-container :mr="mr" status="failed"> <span - class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body! gl-align-self-start" + class="gl-ml-3 gl-font-weight-bold gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!" > {{ s__('mrWidget|Merge blocked: all threads must be resolved.') }} </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index ef5be0fbfcd..01f9b4757a0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -94,6 +94,7 @@ export default { errors: [], mergeRequest: { __typename: 'MergeRequest', + id: this.mr.issuableId, mergeableDiscussionsState: true, title: this.mr.title, draft: false, @@ -111,7 +112,10 @@ export default { }) => { toast(__('Marked as ready. Merging is now allowed.')); $('.merge-request .detail-page-description .title').text(title); - eventHub.$emit('MRWidgetUpdateRequested'); + + if (!window.gon?.features?.realtimeMrStatusChange) { + eventHub.$emit('MRWidgetUpdateRequested'); + } }, ) .catch(() => diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue new file mode 100644 index 00000000000..6655af92a55 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/action_buttons.vue @@ -0,0 +1,134 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; + +export default { + components: { + GlButton, + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + widget: { + type: String, + required: false, + default: '', + }, + tertiaryButtons: { + type: Array, + required: false, + default: () => [], + }, + }, + data: () => { + return { + timeout: null, + updatingTooltip: false, + }; + }, + computed: { + dropdownLabel() { + if (!this.widget) return undefined; + + return sprintf(__('%{widget} options'), { widget: this.widget }); + }, + }, + methods: { + onClickAction(action) { + this.$emit('clickedAction', action); + + if (action.onClick) { + action.onClick(); + } + + if (action.tooltipOnClick) { + this.updatingTooltip = true; + this.$root.$emit('bv::show::tooltip', action.id); + + clearTimeout(this.timeout); + + this.timeout = setTimeout(() => { + this.updatingTooltip = false; + this.$root.$emit('bv::hide::tooltip', action.id); + }, 1000); + } + }, + setTooltip(btn) { + if (this.updatingTooltip && btn.tooltipOnClick) { + return btn.tooltipOnClick; + } + + return btn.tooltipText; + }, + actionButtonQaSelector(btn) { + if (btn.dataQaSelector) { + return btn.dataQaSelector; + } + return 'mr_widget_extension_actions_button'; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-flex-start"> + <gl-dropdown + v-if="tertiaryButtons.length" + v-gl-tooltip + :title="__('Options')" + :text="dropdownLabel" + icon="ellipsis_v" + no-caret + category="tertiary" + right + lazy + text-sr-only + size="small" + toggle-class="gl-p-2!" + class="gl-display-block gl-md-display-none!" + > + <gl-dropdown-item + v-for="(btn, index) in tertiaryButtons" + :key="index" + :href="btn.href" + :target="btn.target" + :data-clipboard-text="btn.dataClipboardText" + :data-method="btn.dataMethod" + @click="onClickAction(btn)" + > + {{ btn.text }} + </gl-dropdown-item> + </gl-dropdown> + <template v-if="tertiaryButtons.length"> + <gl-button + v-for="(btn, index) in tertiaryButtons" + :id="btn.id" + :key="index" + v-gl-tooltip.hover + :title="setTooltip(btn)" + :href="btn.href" + :target="btn.target" + :class="[{ 'gl-mr-3': index !== tertiaryButtons.length - 1 }, btn.class]" + :data-clipboard-text="btn.dataClipboardText" + :data-qa-selector="actionButtonQaSelector(btn)" + :data-method="btn.dataMethod" + :icon="btn.icon" + :data-testid="btn.testId || 'extension-actions-button'" + :variant="btn.variant || 'confirm'" + :loading="btn.loading" + :disabled="btn.loading" + category="tertiary" + size="small" + class="gl-display-none gl-md-display-block gl-float-left" + @click="onClickAction(btn)" + > + <template v-if="btn.text"> + {{ btn.text }} + </template> + </gl-button> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue index 2f52ac70833..18aa85484ea 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue @@ -20,13 +20,14 @@ export default { role="region" :aria-label="__('Merge request reports')" data-testid="mr-widget-app" + class="mr-widget-section" > <component :is="widget" v-for="(widget, index) in widgets" :key="widget.name || index" :mr="mr" - :class="{ 'mr-widget-border-top': index === 0 }" + class="mr-widget-section" /> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue index 4d66c75719b..cdce7c6625a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue @@ -1,8 +1,9 @@ <script> -import { GlBadge, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; -import Actions from '../action_buttons.vue'; +import { GlBadge, GlLink } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { generateText } from '../extensions/utils'; import ContentRow from './widget_content_row.vue'; +import Actions from './action_buttons.vue'; export default { name: 'DynamicContent', @@ -13,7 +14,7 @@ export default { ContentRow, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { data: { @@ -81,10 +82,8 @@ export default { v-if="data.children && data.children.length > 0 && level === 2" class="gl-m-0 gl-p-0 gl-list-style-none" > - <li> + <li v-for="(childData, index) in data.children" :key="childData.id || index"> <dynamic-content - v-for="(childData, index) in data.children" - :key="childData.id || index" :data="childData" :widget-name="widgetName" :level="3" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue index 181b8cfad9a..6d17ac98d7f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/status_icon.vue @@ -48,9 +48,9 @@ export default { :class="{ [iconClassNameText]: !isLoading, [`mr-widget-status-icon-level-${level}`]: !isLoading, - 'gl-mr-3': level === 1, + 'gl-w-6 gl-h-6 gl--flex-center': level === 1, }" - class="gl-relative gl-w-6 gl-h-6 gl-rounded-full gl--flex-center" + class="gl-relative gl-rounded-full gl-mr-3" > <gl-loading-icon v-if="isLoading" size="md" inline /> <gl-icon diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index cea7fb8260a..cdf35033021 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -1,22 +1,18 @@ <script> -import { - GlButton, - GlLink, - GlTooltipDirective, - GlLoadingIcon, - GlSafeHtmlDirective, -} from '@gitlab/ui'; +import { GlButton, GlLink, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { normalizeHeaders } from '~/lib/utils/common_utils'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { sprintf, __ } from '~/locale'; import Poll from '~/lib/utils/poll'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import ActionButtons from '../action_buttons.vue'; +import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; import { EXTENSION_ICONS } from '../../constants'; import { createTelemetryHub } from '../extensions/telemetry'; import ContentRow from './widget_content_row.vue'; import DynamicContent from './dynamic_content.vue'; import StatusIcon from './status_icon.vue'; +import ActionButtons from './action_buttons.vue'; const FETCH_TYPE_COLLAPSED = 'collapsed'; const FETCH_TYPE_EXPANDED = 'expanded'; @@ -31,11 +27,13 @@ export default { GlLoadingIcon, ContentRow, DynamicContent, + DynamicScroller, + DynamicScrollerItem, HelpPopover, }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { /** @@ -258,6 +256,7 @@ export default { <div class="gl-display-flex"> <help-popover v-if="helpPopover" + icon="information-o" :options="helpPopover.options" :class="{ 'gl-mr-3': actionButtons.length > 0 }" > @@ -309,7 +308,7 @@ export default { <div v-if="isLoadingExpandedContent" class="report-block-container gl-text-center"> <gl-loading-icon size="sm" inline /> {{ loadingText }} </div> - <div v-else class="gl-px-5 gl-display-flex"> + <div v-else class="gl-pl-5 gl-display-flex" :class="{ 'gl-pr-5': $scopedSlots.content }"> <content-row v-if="contentError" :level="2" @@ -322,12 +321,25 @@ export default { </content-row> <div v-else class="gl-w-full"> <slot name="content"> - <dynamic-content - v-for="(data, index) in content" - :key="data.id || index" - :data="data" - :widget-name="widgetName" - /> + <dynamic-scroller + v-if="content" + :items="content" + :min-item-size="32" + :style="{ maxHeight: '170px' }" + data-testid="dynamic-content-scroller" + class="gl-pr-5" + > + <template #default="{ item, index, active }"> + <dynamic-scroller-item :item="item" :active="active"> + <dynamic-content + :key="item.id || index" + :data="item" + :widget-name="widgetName" + :level="2" + /> + </dynamic-scroller-item> + </template> + </dynamic-scroller> </slot> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue index 1fd1e325863..543136dc659 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget_content_row.vue @@ -1,10 +1,11 @@ <script> -import { GlSafeHtmlDirective, GlLink } from '@gitlab/ui'; +import { GlLink } from '@gitlab/ui'; import { __ } from '~/locale'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import ActionButtons from '../action_buttons.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { EXTENSION_ICONS } from '../../constants'; import { generateText } from '../extensions/utils'; +import ActionButtons from './action_buttons.vue'; import StatusIcon from './status_icon.vue'; export default { @@ -15,7 +16,7 @@ export default { ActionButtons, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { level: { @@ -67,6 +68,9 @@ export default { shouldShowHeaderActions() { return Boolean(this.helpPopover) || this.actionButtons?.length > 0; }, + hasActionButtons() { + return this.actionButtons.length > 0; + }, }, i18n: { learnMore: __('Learn more'), @@ -75,10 +79,15 @@ export default { </script> <template> <div - class="gl-w-full gl-display-flex mr-widget-content-row gl-align-items-baseline" + class="gl-w-full gl-display-flex gl-align-items-baseline" :class="{ 'gl-border-t gl-py-3 gl-pl-7': level === 2 }" > - <status-icon v-if="statusIconName" :level="2" :name="widgetName" :icon-name="statusIconName" /> + <status-icon + v-if="statusIconName && !header" + :level="2" + :name="widgetName" + :icon-name="statusIconName" + /> <div class="gl-w-full"> <div class="gl-display-flex"> <slot name="header"> @@ -95,7 +104,12 @@ export default { v-if="shouldShowHeaderActions" class="gl-ml-auto gl-display-flex gl-align-items-baseline" > - <help-popover v-if="helpPopover" :options="helpPopover.options"> + <help-popover + v-if="helpPopover" + :options="helpPopover.options" + :class="{ 'gl-mr-3': hasActionButtons }" + icon="information-o" + > <template v-if="helpPopover.content"> <p v-if="helpPopover.content.text" @@ -112,14 +126,19 @@ export default { </template> </help-popover> <action-buttons - v-if="actionButtons.length > 0" + v-if="hasActionButtons" :widget="widgetName" :tertiary-buttons="actionButtons" - :class="{ 'gl-ml-2': helpPopover }" /> </div> </div> <div class="gl-display-flex gl-align-items-baseline gl-w-full"> + <status-icon + v-if="statusIconName && header" + :level="2" + :name="widgetName" + :icon-name="statusIconName" + /> <slot name="body"></slot> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js new file mode 100644 index 00000000000..03af21a5019 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/constants.js @@ -0,0 +1,31 @@ +import { n__, s__, sprintf } from '~/locale'; + +export const i18n = { + label: s__('ciReport|Code Quality'), + loading: s__('ciReport|Code Quality is loading'), + error: s__('ciReport|Code Quality failed to load results'), + noChanges: s__(`ciReport|Code Quality hasn't changed.`), + prependText: s__(`ciReport|in`), + fixed: s__(`ciReport|Fixed`), + pluralReport: (errors) => + sprintf( + n__( + '%{strong_start}%{errors}%{strong_end} point', + '%{strong_start}%{errors}%{strong_end} points', + errors.length, + ), + { + errors: errors.length, + }, + false, + ), + singularReport: (errors) => n__('%d point', '%d points', errors.length), + improvementAndDegradationCopy: (improvement, degradation) => + sprintf( + s__(`ciReport|Code Quality improved on ${improvement} and degraded on ${degradation}.`), + ), + improvedCopy: (improvements) => + sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`)), + degradedCopy: (degradations) => + sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`)), +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js index 68347ac269e..394f8979a53 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js @@ -1,54 +1,33 @@ -import { n__, s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; -import { SEVERITY_ICONS_EXTENSION } from '~/reports/codequality_report/constants'; -import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser'; +import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/constants'; +import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; +import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { i18n } from './constants'; export default { name: 'WidgetCodeQuality', + enablePolling: true, props: ['codeQuality', 'blobPath'], - i18n: { - label: s__('ciReport|Code Quality'), - loading: s__('ciReport|Code Quality test metrics results are being parsed'), - error: s__('ciReport|Code Quality failed loading results'), - }, + i18n, computed: { - summary() { - const { newErrors, resolvedErrors, errorSummary } = this.collapsedData; - if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) { - const improvements = sprintf( - n__( - '%{strong_start}%{errors}%{strong_end} point', - '%{strong_start}%{errors}%{strong_end} points', - resolvedErrors.length, - ), - { - errors: resolvedErrors.length, - }, - false, - ); + summary(data) { + const { newErrors, resolvedErrors, errorSummary, parsingInProgress } = data; - const degradations = sprintf( - n__( - '%{strong_start}%{errors}%{strong_end} point', - '%{strong_start}%{errors}%{strong_end} points', - newErrors.length, - ), - { errors: newErrors.length }, - false, - ); - return sprintf( - s__(`ciReport|Code Quality improved on ${improvements} and degraded on ${degradations}.`), + if (parsingInProgress) { + return i18n.loading; + } else if (errorSummary.errored >= 1 && errorSummary.resolved >= 1) { + return i18n.improvementAndDegradationCopy( + i18n.pluralReport(resolvedErrors), + i18n.pluralReport(newErrors), ); } else if (errorSummary.resolved >= 1) { - const improvements = n__('%d point', '%d points', resolvedErrors.length); - return sprintf(s__(`ciReport|Code Quality improved on ${improvements}.`)); + return i18n.improvedCopy(i18n.singularReport(resolvedErrors)); } else if (errorSummary.errored >= 1) { - const degradations = n__('%d point', '%d points', newErrors.length); - return sprintf(s__(`ciReport|Code Quality degraded on ${degradations}.`)); + return i18n.degradedCopy(i18n.singularReport(newErrors)); } - return s__(`ciReport|No changes to Code Quality.`); + return i18n.noChanges; }, statusIcon() { if (this.collapsedData.errorSummary?.errored >= 1) { @@ -59,18 +38,17 @@ export default { }, methods: { fetchCollapsedData() { - return Promise.all([this.fetchReport(this.codeQuality)]).then((values) => { + return axios.get(this.codeQuality).then((response) => { + const { data = {}, status } = response; return { - resolvedErrors: parseCodeclimateMetrics( - values[0].resolved_errors, - this.blobPath.head_path, - ), - newErrors: parseCodeclimateMetrics(values[0].new_errors, this.blobPath.head_path), - existingErrors: parseCodeclimateMetrics( - values[0].existing_errors, - this.blobPath.head_path, - ), - errorSummary: values[0].summary, + ...response, + data: { + parsingInProgress: status === HTTP_STATUS_NO_CONTENT, + resolvedErrors: parseCodeclimateMetrics(data.resolved_errors, this.blobPath.head_path), + newErrors: parseCodeclimateMetrics(data.new_errors, this.blobPath.head_path), + existingErrors: parseCodeclimateMetrics(data.existing_errors, this.blobPath.head_path), + errorSummary: data.summary, + }, }; }); }, @@ -81,12 +59,12 @@ export default { return fullData.push({ text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, subtext: { - prependText: s__(`ciReport|in`), + prependText: i18n.prependText, text: `${e.file_path}:${e.line}`, href: e.urlPath, }, icon: { - name: SEVERITY_ICONS_EXTENSION[e.severity], + name: SEVERITY_ICONS_MR_WIDGET[e.severity], }, }); }); @@ -95,12 +73,16 @@ export default { return fullData.push({ text: `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, subtext: { - prependText: s__(`ciReport|in`), + prependText: i18n.prependText, text: `${e.file_path}:${e.line}`, href: e.urlPath, }, icon: { - name: SEVERITY_ICONS_EXTENSION[e.severity], + name: SEVERITY_ICONS_MR_WIDGET[e.severity], + }, + badge: { + variant: 'neutral', + text: i18n.fixed, }, }); }); diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js index 454a14faabb..5380bcae003 100644 --- a/app/assets/javascripts/vue_merge_request_widget/i18n.js +++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js @@ -25,3 +25,10 @@ export const MERGE_TRAIN_BUTTON_TEXT = { failed: __('Start merge train...'), passed: __('Start merge train'), }; + +export const MR_WIDGET_CLOSED_REOPEN = __('Reopen'); +export const MR_WIDGET_CLOSED_REOPENING = __('Reopening...'); +export const MR_WIDGET_CLOSED_RELOADING = __('Refreshing...'); +export const MR_WIDGET_CLOSED_REOPEN_FAILURE = __( + 'An error occurred. Unable to reopen this merge request.', +); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index b96bdcb3833..00024a594dc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -1,10 +1,10 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { registerExtension, registeredExtensions, } from '~/vue_merge_request_widget/components/extensions'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; @@ -15,6 +15,7 @@ import notify from '~/lib/utils/notify'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { setFaviconOverlay } from '../lib/utils/favicon'; import Loading from './components/loading.vue'; import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; @@ -46,18 +47,20 @@ import { STATE_MACHINE, stateToComponentMap } from './constants'; import eventHub from './event_hub'; import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; import getStateQuery from './queries/get_state.query.graphql'; +import getStateSubscription from './queries/get_state.subscription.graphql'; import terraformExtension from './extensions/terraform'; import accessibilityExtension from './extensions/accessibility'; import codeQualityExtension from './extensions/code_quality'; import testReportExtension from './extensions/test_report'; import ReportWidgetContainer from './components/report_widget_container.vue'; +import MrWidgetReadyToMerge from './components/states/new_ready_to_merge.vue'; export default { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 // eslint-disable-next-line @gitlab/require-i18n-strings name: 'MRWidget', directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { Loading, @@ -76,7 +79,7 @@ export default { MrWidgetNothingToMerge: NothingToMergeState, MrWidgetNotAllowed: NotAllowedState, MrWidgetMissingBranch: MissingBranchState, - MrWidgetReadyToMerge: () => import('./components/states/new_ready_to_merge.vue'), + MrWidgetReadyToMerge, ShaMismatch, MrWidgetChecking: CheckingState, MrWidgetUnresolvedDiscussions: UnresolvedDiscussionsState, @@ -108,6 +111,31 @@ export default { this.loading = false; } }, + subscribeToMore: { + document() { + return getStateSubscription; + }, + skip() { + return !this.mr?.id || this.loading || !window.gon?.features?.realtimeMrStatusChange; + }, + variables() { + return { + issuableId: convertToGraphQLId('MergeRequest', this.mr?.id), + }; + }, + updateQuery( + _, + { + subscriptionData: { + data: { mergeRequestMergeStatusUpdated }, + }, + }, + ) { + if (mergeRequestMergeStatusUpdated) { + this.mr.setGraphqlSubscriptionData(mergeRequestMergeStatusUpdated); + } + }, + }, }, }, mixins: [mergeRequestQueryVariablesMixin], @@ -128,6 +156,7 @@ export default { machineState: store?.machineValue || STATE_MACHINE.definition.initial, loading: true, recomputeComponentName: 0, + issuableId: false, }; }, computed: { @@ -545,6 +574,7 @@ export default { <mr-widget-approvals v-if="shouldRenderApprovals" :mr="mr" :service="service" /> <report-widget-container> <extensions-container v-if="hasExtensions" :mr="mr" /> + <widget-container v-if="mr && shouldShowSecurityExtension" :mr="mr" /> <security-reports-app v-if="shouldRenderSecurityReport && !shouldShowSecurityExtension" :pipeline-id="mr.pipeline.id" @@ -580,8 +610,6 @@ export default { </mr-widget-alert-message> </div> - <widget-container v-if="mr" :mr="mr" /> - <div class="mr-widget-section" data-qa-selector="mr_widget_content"> <component :is="componentName" :mr="mr" :service="service" /> <ready-to-merge diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql new file mode 100644 index 00000000000..c7b53db1221 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql @@ -0,0 +1,7 @@ +subscription getStateSubscription($issuableId: IssuableID!) { + mergeRequestMergeStatusUpdated(issuableId: $issuableId) { + ... on MergeRequest { + detailedMergeStatus + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql index 54770e6579a..9b0420cc7fa 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.fragment.graphql @@ -1,44 +1,11 @@ +#import "./ready_to_merge_merge_request.fragment.graphql" + fragment ReadyToMerge on Project { id onlyAllowMergeIfPipelineSucceeds mergeRequestsFfOnlyEnabled squashReadOnly mergeRequest(iid: $iid) { - id - autoMergeEnabled - shouldRemoveSourceBranch - forceRemoveSourceBranch - defaultMergeCommitMessage - defaultSquashCommitMessage - squash - squashOnMerge - availableAutoMergeStrategies - hasCi - mergeable - mergeWhenPipelineSucceeds - commitCount - diffHeadSha - userPermissions { - canMerge - removeSourceBranch - updateMergeRequest - } - targetBranch - mergeError - commitsWithoutMergeCommits { - nodes { - id - sha - shortId - title - message - } - } - headPipeline { - id - status - path - active - } + ...ReadyToMergeMergeRequest } } diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql new file mode 100644 index 00000000000..8aba172e09c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql @@ -0,0 +1,9 @@ +#import "./ready_to_merge_merge_request.fragment.graphql" + +subscription readyToMergeSubscription($issuableId: IssuableID!) { + mergeRequestMergeStatusUpdated(issuableId: $issuableId) { + ... on MergeRequest { + ...ReadyToMergeMergeRequest + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge_merge_request.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge_merge_request.fragment.graphql new file mode 100644 index 00000000000..276e2d4d63f --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/ready_to_merge_merge_request.fragment.graphql @@ -0,0 +1,39 @@ +fragment ReadyToMergeMergeRequest on MergeRequest { + id + detailedMergeStatus + autoMergeEnabled + shouldRemoveSourceBranch + forceRemoveSourceBranch + defaultMergeCommitMessage + defaultSquashCommitMessage + squash + squashOnMerge + availableAutoMergeStrategies + hasCi + mergeable + mergeWhenPipelineSucceeds + commitCount + diffHeadSha + userPermissions { + canMerge + removeSourceBranch + updateMergeRequest + } + targetBranch + mergeError + commitsWithoutMergeCommits { + nodes { + id + sha + shortId + title + message + } + } + headPipeline { + id + status + path + active + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 86ce032ea3d..85df2ea63c8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -30,6 +30,7 @@ export default class MergeRequestStore { this.machineValue = this.stateMachine.value; this.mergeDetailsCollapsed = window.innerWidth < 768; this.mergeError = data.mergeError; + this.id = data.id; this.setPaths(data); @@ -177,6 +178,7 @@ export default class MergeRequestStore { this.updateStatusState(mergeRequest.state); + this.issuableId = mergeRequest.id; this.projectArchived = project.archived; this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds; this.allowMergeOnSkippedPipeline = project.allowMergeOnSkippedPipeline; @@ -206,6 +208,12 @@ export default class MergeRequestStore { this.setState(); } + setGraphqlSubscriptionData(data) { + this.detailedMergeStatus = data.detailedMergeStatus; + + this.setState(); + } + updateStatusState(state) { if (this.mergeRequestState !== state && badgeState.updateStatus) { badgeState.updateStatus(); diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index 96c2ffa929c..6803d609dbc 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -9,9 +9,9 @@ import { GlTabs, GlTab, GlButton, - GlSafeHtmlDirective, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import { fetchPolicies } from '~/lib/graphql'; import { toggleContainerClasses } from '~/lib/utils/dom_utils'; @@ -41,7 +41,7 @@ export default { reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, severityLabels: SEVERITY_LEVELS, tabsConfig: [ @@ -369,10 +369,10 @@ export default { <alert-details-table :alert="alert" :loading="loading" :statuses="statuses" /> </gl-tab> - <metric-images-tab - :data-testid="$options.tabsConfig[1].id" - :title="$options.tabsConfig[1].title" - /> + <gl-tab :title="$options.tabsConfig[1].title"> + <metric-images-tab :data-testid="$options.tabsConfig[1].id" /> + </gl-tab> + <gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title"> <div v-if="alert.notes.nodes.length > 0" class="issuable-discussion"> <ul class="notes main-notes-list timeline"> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue index 672761af1cf..8d2ef20b381 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue @@ -106,7 +106,7 @@ export default { @keydown.esc.native="$emit('hide-dropdown')" @hide="$emit('hide-dropdown')" > - <p v-if="isSidebar" class="gl-new-dropdown-header-top" data-testid="dropdown-header"> + <p v-if="isSidebar" class="gl-dropdown-header-top" data-testid="dropdown-header"> {{ s__('AlertManagement|Assign status') }} </p> <div class="dropdown-content dropdown-body"> 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 72dcc16b57a..4ec301b946b 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 @@ -242,7 +242,7 @@ export default { @keydown.esc.native="hideDropdown" @hide="hideDropdown" > - <p class="gl-new-dropdown-header-top"> + <p class="gl-dropdown-header-top"> {{ __('Assign To') }} </p> <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" /> 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 832b154b312..b3ee01f3a24 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 @@ -1,5 +1,5 @@ <script> -import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; +import ToggleSidebar from '~/sidebar/components/toggle/toggle_sidebar.vue'; import SidebarTodo from './sidebar_todo.vue'; export default { diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue index 6b774b2a734..3c73f42b6b1 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue @@ -1,5 +1,6 @@ <script> -import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import NoteHeader from '~/notes/components/note_header.vue'; export default { @@ -8,7 +9,7 @@ export default { GlIcon, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { note: { diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql index 33091f1ba5e..b04d5773a37 100644 --- a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql @@ -8,6 +8,7 @@ mutation alertSetAssignees($fullPath: ID!, $assigneeUsernames: [String!]!, $iid: ) { errors issuable: alert { + id iid assignees { nodes { diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue index c6c22f9c61f..175aef59ae5 100644 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ b/app/assets/javascripts/vue_shared/components/actions_button.vue @@ -1,11 +1,5 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlButton, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlButton, GlTooltip } from '@gitlab/ui'; export default { components: { @@ -13,11 +7,14 @@ export default { GlDropdownItem, GlDropdownDivider, GlButton, - }, - directives: { - GlTooltip: GlTooltipDirective, + GlTooltip, }, props: { + id: { + type: String, + required: false, + default: '', + }, actions: { type: Array, required: true, @@ -37,6 +34,11 @@ export default { required: false, default: 'default', }, + showActionTooltip: { + type: Boolean, + required: false, + default: true, + }, }, computed: { hasMultipleActions() { @@ -51,6 +53,7 @@ export default { this.$emit('select', action.key); }, handleClick(action, evt) { + this.$emit('actionClicked', { action }); return action.handle?.(evt); }, }, @@ -58,46 +61,51 @@ export default { </script> <template> - <gl-dropdown - v-if="hasMultipleActions" - v-gl-tooltip="selectedAction.tooltip" - :text="selectedAction.text" - :split-href="selectedAction.href" - :variant="variant" - :category="category" - split - data-qa-selector="action_dropdown" - @click="handleClick(selectedAction, $event)" - > - <template #button-content> - <span class="gl-new-dropdown-button-text" v-bind="selectedAction.attrs"> - {{ selectedAction.text }} - </span> - </template> - <template v-for="(action, index) in actions"> - <gl-dropdown-item - :key="action.key" - is-check-item - :is-checked="action.key === selectedAction.key" - :secondary-text="action.secondaryText" - :data-qa-selector="`${action.key}_menu_item`" - :data-testid="`action_${action.key}`" - @click="handleItemClick(action)" - > - <span class="gl-font-weight-bold">{{ action.text }}</span> - </gl-dropdown-item> - <gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" /> - </template> - </gl-dropdown> - <gl-button - v-else-if="selectedAction" - v-gl-tooltip="selectedAction.tooltip" - v-bind="selectedAction.attrs" - :variant="variant" - :category="category" - :href="selectedAction.href" - @click="handleClick(selectedAction, $event)" - > - {{ selectedAction.text }} - </gl-button> + <span> + <gl-dropdown + v-if="hasMultipleActions" + :id="id" + :text="selectedAction.text" + :split-href="selectedAction.href" + :variant="variant" + :category="category" + split + data-qa-selector="action_dropdown" + @click="handleClick(selectedAction, $event)" + > + <template #button-content> + <span class="gl-dropdown-button-text" v-bind="selectedAction.attrs"> + {{ selectedAction.text }} + </span> + </template> + <template v-for="(action, index) in actions"> + <gl-dropdown-item + :key="action.key" + is-check-item + :is-checked="action.key === selectedAction.key" + :secondary-text="action.secondaryText" + :data-qa-selector="`${action.key}_menu_item`" + :data-testid="`action_${action.key}`" + @click="handleItemClick(action)" + > + <span class="gl-font-weight-bold">{{ action.text }}</span> + </gl-dropdown-item> + <gl-dropdown-divider v-if="index != actions.length - 1" :key="action.key + '_divider'" /> + </template> + </gl-dropdown> + <gl-button + v-else-if="selectedAction" + :id="id" + v-bind="selectedAction.attrs" + :variant="variant" + :category="category" + :href="selectedAction.href" + @click="handleClick(selectedAction, $event)" + > + {{ selectedAction.text }} + </gl-button> + <gl-tooltip v-if="selectedAction.tooltip && showActionTooltip" :target="id"> + {{ selectedAction.tooltip }} + </gl-tooltip> + </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index f5d8811e83c..cb38b3e13bb 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -1,6 +1,7 @@ <script> -import { GlIcon, GlButton, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { groupBy } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import EmojiPicker from '~/emoji/components/picker.vue'; import { __, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -17,7 +18,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], props: { @@ -158,10 +159,7 @@ export default { return; } - // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string - const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName; - - this.$emit('award', parsedName); + this.$emit('award', awardName); if (document.activeElement) document.activeElement.blur(); }, diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue index ed0eb9cc0b8..49181bb847d 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { handleBlobRichViewer } from '~/blob/viewer'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; import ViewerMixin from './mixins'; 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 0117c06c3d5..c7a76af7f74 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 @@ -1,5 +1,6 @@ <script> -import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { HIGHLIGHT_CLASS_NAME } from './constants'; import ViewerMixin from './mixins'; @@ -9,7 +10,7 @@ export default { GlIcon, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [ViewerMixin], inject: ['blobHash'], diff --git a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue index 65b08b608e8..352d03befc3 100644 --- a/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue +++ b/app/assets/javascripts/vue_shared/components/code_block_highlighted.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; import CodeBlock from './code_block.vue'; @@ -7,7 +7,7 @@ import CodeBlock from './code_block.vue'; export default { name: 'CodeBlockHighlighted', directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, components: { CodeBlock, diff --git a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue index 7a982bc035a..d0a634d8e54 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_danger/confirm_danger_modal.vue @@ -1,12 +1,6 @@ <script> -import { - GlAlert, - GlModal, - GlFormGroup, - GlFormInput, - GlSafeHtmlDirective as SafeHtml, - GlSprintf, -} from '@gitlab/ui'; +import { GlAlert, GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { CONFIRM_DANGER_MODAL_BUTTON, CONFIRM_DANGER_MODAL_TITLE, diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue index 72504e5bc50..664c3578785 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue @@ -1,6 +1,7 @@ <script> -import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlModal } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import csrf from '~/lib/utils/csrf'; import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from './confirm_modal_eventhub'; import DomElementListener from './dom_element_listener.vue'; diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 3ecfac10f9c..00d12654ee3 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -1,10 +1,10 @@ <script> -import { GlSkeletonLoader, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { GlSkeletonLoader } from '@gitlab/ui'; import { forEach, escape } from 'lodash'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; const { CancelToken } = axios; let axiosSource; @@ -96,7 +96,7 @@ export default { this.isLoading = false; this.$nextTick(() => { - $(this.$refs.markdownPreview).renderGFM(); + renderGFM(this.$refs.markdownPreview); }); }) .catch(() => { diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index 181c1b89e31..d8a2789a419 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -265,7 +265,6 @@ export default { <gl-dropdown-item v-for="(option, index) in options" :key="index" - data-qa-selector="quick_range_item" :active="isOptionActive(option)" active-class="active" @click="setQuickRange(option)" diff --git a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue index 0621ec14c6c..8395bc89790 100644 --- a/app/assets/javascripts/vue_shared/components/dismissible_alert.vue +++ b/app/assets/javascripts/vue_shared/components/dismissible_alert.vue @@ -1,5 +1,6 @@ <script> -import { GlAlert, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlAlert } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { name: 'DismissibleAlert', 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 755ce004aa9..993b4c11c0e 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 @@ -8,52 +8,44 @@ export const FILTER_ANY = 'Any'; export const FILTER_CURRENT = 'Current'; export const FILTER_UPCOMING = 'Upcoming'; export const FILTER_STARTED = 'Started'; -export const FILTER_NONE_ANY = [FILTER_NONE, FILTER_ANY]; + +export const FILTERS_NONE_ANY = [FILTER_NONE, FILTER_ANY]; export const OPERATOR_IS = '='; export const OPERATOR_IS_TEXT = __('is'); -export const OPERATOR_IS_NOT = '!='; -export const OPERATOR_IS_NOT_TEXT = __('is not one of'); +export const OPERATOR_NOT = '!='; +export const OPERATOR_NOT_TEXT = __('is not one of'); export const OPERATOR_OR = '||'; export const OPERATOR_OR_TEXT = __('is one of'); -export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; -export const OPERATOR_IS_NOT_ONLY = [{ value: OPERATOR_IS_NOT, description: OPERATOR_IS_NOT_TEXT }]; -export const OPERATOR_OR_ONLY = [{ value: OPERATOR_OR, description: OPERATOR_OR_TEXT }]; -export const OPERATOR_IS_AND_IS_NOT = [...OPERATOR_IS_ONLY, ...OPERATOR_IS_NOT_ONLY]; -export const OPERATOR_IS_NOT_OR = [ - ...OPERATOR_IS_ONLY, - ...OPERATOR_IS_NOT_ONLY, - ...OPERATOR_OR_ONLY, -]; - -export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') }; -export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; -export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; +export const OPERATORS_IS = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; +export const OPERATORS_NOT = [{ value: OPERATOR_NOT, description: OPERATOR_NOT_TEXT }]; +export const OPERATORS_OR = [{ value: OPERATOR_OR, description: OPERATOR_OR_TEXT }]; +export const OPERATORS_IS_NOT = [...OPERATORS_IS, ...OPERATORS_NOT]; +export const OPERATORS_IS_NOT_OR = [...OPERATORS_IS, ...OPERATORS_NOT, ...OPERATORS_OR]; -export const DEFAULT_MILESTONE_UPCOMING = { +export const OPTION_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') }; +export const OPTION_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; +export const OPTION_CURRENT = { value: FILTER_CURRENT, text: __('Current') }; +export const OPTION_STARTED = { value: FILTER_STARTED, text: __('Started'), title: __('Started') }; +export const OPTION_UPCOMING = { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming'), }; -export const DEFAULT_MILESTONE_STARTED = { - value: FILTER_STARTED, - text: __('Started'), - title: __('Started'), -}; -export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([ - DEFAULT_MILESTONE_UPCOMING, - DEFAULT_MILESTONE_STARTED, -]); -export const SortDirection = { +export const OPTIONS_NONE_ANY = [OPTION_NONE, OPTION_ANY]; + +export const DEFAULT_MILESTONES = OPTIONS_NONE_ANY.concat([OPTION_UPCOMING, OPTION_STARTED]); + +export const SORT_DIRECTION = { descending: 'descending', ascending: 'ascending', }; -export const FILTERED_SEARCH_LABELS = 'labels'; export const FILTERED_SEARCH_TERM = 'filtered-search-term'; +export const TOKEN_TITLE_APPROVED_BY = __('Approved-By'); export const TOKEN_TITLE_ASSIGNEE = s__('SearchToken|Assignee'); export const TOKEN_TITLE_AUTHOR = __('Author'); export const TOKEN_TITLE_CONFIDENTIAL = __('Confidential'); @@ -63,11 +55,14 @@ export const TOKEN_TITLE_MILESTONE = __('Milestone'); export const TOKEN_TITLE_MY_REACTION = __('My-Reaction'); export const TOKEN_TITLE_ORGANIZATION = s__('Crm|Organization'); export const TOKEN_TITLE_RELEASE = __('Release'); +export const TOKEN_TITLE_REVIEWER = s__('SearchToken|Reviewer'); export const TOKEN_TITLE_SOURCE_BRANCH = __('Source Branch'); export const TOKEN_TITLE_STATUS = __('Status'); export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch'); export const TOKEN_TITLE_TYPE = __('Type'); +export const TOKEN_TITLE_SEARCH_WITHIN = __('Search Within'); +export const TOKEN_TYPE_APPROVED_BY = 'approved-by'; export const TOKEN_TYPE_ASSIGNEE = 'assignee'; export const TOKEN_TYPE_AUTHOR = 'author'; export const TOKEN_TYPE_CONFIDENTIAL = 'confidential'; @@ -84,5 +79,11 @@ export const TOKEN_TYPE_MILESTONE = 'milestone'; export const TOKEN_TYPE_MY_REACTION = 'my-reaction'; export const TOKEN_TYPE_ORGANIZATION = 'organization'; export const TOKEN_TYPE_RELEASE = 'release'; +export const TOKEN_TYPE_REVIEWER = 'reviewer'; +export const TOKEN_TYPE_SOURCE_BRANCH = 'source-branch'; +export const TOKEN_TYPE_STATUS = 'status'; +export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_WEIGHT = 'weight'; + +export const TOKEN_TYPE_SEARCH_WITHIN = 'in'; 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 0d0787e7033..34f64dddc41 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 @@ -15,7 +15,7 @@ import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store' import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import { SortDirection } from './constants'; +import { SORT_DIRECTION } from './constants'; import { filterEmptySearchTerm, stripQuotes, uniqueTokens } from './filtered_search_utils'; export default { @@ -107,7 +107,7 @@ export default { recentSearches: [], filterValue: this.initialFilterValue, selectedSortOption: this.sortOptions[0], - selectedSortDirection: SortDirection.descending, + selectedSortDirection: SORT_DIRECTION.descending, }; }, computed: { @@ -130,12 +130,12 @@ export default { ); }, sortDirectionIcon() { - return this.selectedSortDirection === SortDirection.ascending + return this.selectedSortDirection === SORT_DIRECTION.ascending ? 'sort-lowest' : 'sort-highest'; }, sortDirectionTooltip() { - return this.selectedSortDirection === SortDirection.ascending + return this.selectedSortDirection === SORT_DIRECTION.ascending ? __('Sort direction: Ascending') : __('Sort direction: Descending'); }, @@ -267,9 +267,9 @@ export default { }, handleSortDirectionClick() { this.selectedSortDirection = - this.selectedSortDirection === SortDirection.ascending - ? SortDirection.descending - : SortDirection.ascending; + this.selectedSortDirection === SORT_DIRECTION.ascending + ? SORT_DIRECTION.descending + : SORT_DIRECTION.ascending; this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); }, handleHistoryItemSelected(filters) { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index 6a4ff07c999..b0fa3e4c27e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -9,7 +9,7 @@ import { } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { DEBOUNCE_DELAY, FILTER_NONE_ANY, OPERATOR_IS_NOT } from '../constants'; +import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT } from '../constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed, @@ -100,9 +100,9 @@ export default { return this.getActiveTokenValue(this.suggestions, this.value.data); }, availableDefaultSuggestions() { - if (this.value.operator === OPERATOR_IS_NOT) { + if (this.value.operator === OPERATOR_NOT) { return this.defaultSuggestions.filter( - (suggestion) => !FILTER_NONE_ANY.includes(suggestion.value), + (suggestion) => !FILTERS_NONE_ANY.includes(suggestion.value), ); } return this.defaultSuggestions; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue index d34cfb922a9..e0fa06c159e 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue @@ -8,7 +8,7 @@ import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import searchCrmContactsQuery from '../queries/search_crm_contacts.query.graphql'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -39,7 +39,7 @@ export default { }, computed: { defaultContacts() { - return this.config.defaultContacts || DEFAULT_NONE_ANY; + return this.config.defaultContacts || OPTIONS_NONE_ANY; }, namespace() { return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue index c7c9350ee93..3f030c8698c 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue @@ -8,7 +8,7 @@ import { isPositiveInteger } from '~/lib/utils/number_utils'; import { __ } from '~/locale'; import searchCrmOrganizationsQuery from '../queries/search_crm_organizations.query.graphql'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -39,7 +39,7 @@ export default { }, computed: { defaultOrganizations() { - return this.config.defaultOrganizations || DEFAULT_NONE_ANY; + return this.config.defaultOrganizations || OPTIONS_NONE_ANY; }, namespace() { return this.config.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP; 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 929823f7308..74905dc2ae0 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 @@ -3,7 +3,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; export default { @@ -33,7 +33,7 @@ export default { }, computed: { defaultEmojis() { - return this.config.defaultEmojis || DEFAULT_NONE_ANY; + return this.config.defaultEmojis || OPTIONS_NONE_ANY; }, }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue index bce0c11aafd..71c50ef292a 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -5,7 +5,7 @@ import { createAlert } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import { stripQuotes } from '../filtered_search_utils'; import BaseToken from './base_token.vue'; @@ -38,7 +38,7 @@ export default { }, computed: { defaultLabels() { - return this.config.defaultLabels || DEFAULT_NONE_ANY; + return this.config.defaultLabels || OPTIONS_NONE_ANY; }, }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue index 59701b4959e..6d681aab3ca 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/release_token.vue @@ -3,7 +3,7 @@ import { GlFilteredSearchSuggestion } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; export default { components: { @@ -32,7 +32,7 @@ export default { }, computed: { defaultReleases() { - return this.config.defaultReleases || DEFAULT_NONE_ANY; + return this.config.defaultReleases || OPTIONS_NONE_ANY; }, }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue index 7c184a3c391..28e65c1185f 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue @@ -4,7 +4,7 @@ import { compact } from 'lodash'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; -import { DEFAULT_NONE_ANY } from '../constants'; +import { OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -30,30 +30,30 @@ export default { }, data() { return { - authors: this.config.initialAuthors || [], + users: this.config.initialUsers || [], loading: false, }; }, computed: { - defaultAuthors() { - return this.config.defaultAuthors || DEFAULT_NONE_ANY; + defaultUsers() { + return this.config.defaultUsers || OPTIONS_NONE_ANY; }, - preloadedAuthors() { - return this.config.preloadedAuthors || []; + preloadedUsers() { + return this.config.preloadedUsers || []; }, }, methods: { - getActiveAuthor(authors, data) { - return authors.find((author) => author.username.toLowerCase() === data.toLowerCase()); + getActiveUser(users, data) { + return users.find((user) => user.username.toLowerCase() === data.toLowerCase()); }, - getAvatarUrl(author) { - return author.avatarUrl || author.avatar_url; + getAvatarUrl(user) { + return user.avatarUrl || user.avatar_url; }, - fetchAuthors(searchTerm) { + fetchUsers(searchTerm) { this.loading = true; const fetchPromise = this.config.fetchPath - ? this.config.fetchAuthors(this.config.fetchPath, searchTerm) - : this.config.fetchAuthors(searchTerm); + ? this.config.fetchUsers(this.config.fetchPath, searchTerm) + : this.config.fetchUsers(searchTerm); fetchPromise .then((res) => { @@ -62,7 +62,7 @@ export default { // return response differently // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756 - this.authors = Array.isArray(res) ? compact(res) : compact(res.data); + this.users = Array.isArray(res) ? compact(res) : compact(res.data); }) .catch(() => createAlert({ @@ -83,12 +83,12 @@ export default { :value="value" :active="active" :suggestions-loading="loading" - :suggestions="authors" - :get-active-token-value="getActiveAuthor" - :default-suggestions="defaultAuthors" - :preloaded-suggestions="preloadedAuthors" + :suggestions="users" + :get-active-token-value="getActiveUser" + :default-suggestions="defaultUsers" + :preloaded-suggestions="preloadedUsers" v-bind="$attrs" - @fetch-suggestions="fetchAuthors" + @fetch-suggestions="fetchUsers" v-on="$listeners" > <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> @@ -102,15 +102,15 @@ export default { </template> <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion - v-for="author in suggestions" - :key="author.username" - :value="author.username" + v-for="user in suggestions" + :key="user.username" + :value="user.username" > <div class="gl-display-flex"> - <gl-avatar :size="32" :src="getAvatarUrl(author)" /> + <gl-avatar :size="32" :src="getAvatarUrl(user)" /> <div> - <div>{{ author.name }}</div> - <div>@{{ author.username }}</div> + <div>{{ user.name }}</div> + <div>@{{ user.username }}</div> </div> </div> </gl-filtered-search-suggestion> diff --git a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue index 1de6c0121bc..5db723e1e5a 100644 --- a/app/assets/javascripts/vue_shared/components/group_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/group_select/group_select.vue @@ -1,6 +1,6 @@ <script> import { debounce } from 'lodash'; -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import Api from '~/api'; import { __ } from '~/locale'; @@ -18,7 +18,7 @@ const MINIMUM_QUERY_LENGTH = 3; export default { components: { - GlListbox, + GlCollapsibleListbox, }, props: { inputName: { @@ -167,7 +167,7 @@ export default { <template> <div> - <gl-listbox + <gl-collapsible-listbox ref="listbox" v-model="selected" :header-text="$options.i18n.selectGroup" @@ -188,7 +188,7 @@ export default { </div> <div class="gl-text-gray-300">{{ item.full_path }}</div> </template> - </gl-listbox> + </gl-collapsible-listbox> <div class="flash-container"></div> <input :id="inputId" data-testid="input" type="hidden" :name="inputName" :value="inputValue" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 96f7427dda1..3c4ae08d2f7 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -1,12 +1,6 @@ <script> -import { - GlTooltipDirective, - GlButton, - GlSafeHtmlDirective, - GlAvatarLink, - GlAvatarLabeled, - GlTooltip, -} from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlAvatarLink, GlAvatarLabeled, GlTooltip } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '~/emoji'; import { __, sprintf } from '~/locale'; @@ -31,7 +25,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, EMOJI_REF: 'EMOJI_REF', props: { diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index f349aa78bac..92d468cf970 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -1,5 +1,6 @@ <script> -import { GlButton, GlPopover, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlButton, GlPopover } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; /** * Render a button with a question mark icon @@ -12,7 +13,7 @@ export default { GlPopover, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { options: { diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.stories.js b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.stories.js new file mode 100644 index 00000000000..4106de371cb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.stories.js @@ -0,0 +1,26 @@ +import ListboxInput from './listbox_input.vue'; + +export default { + component: ListboxInput, + title: 'vue_shared/listbox_input', +}; + +const Template = (args, { argTypes }) => ({ + components: { ListboxInput }, + data() { + return { selected: null }; + }, + props: Object.keys(argTypes), + template: '<listbox-input v-model="selected" v-bind="$props" />', +}); + +export const Default = Template.bind({}); +Default.args = { + name: 'input_name', + defaultToggleText: 'Select an option', + items: [ + { text: 'Option 1', value: '1' }, + { text: 'Option 2', value: '2' }, + { text: 'Option 3', value: '3' }, + ], +}; diff --git a/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue new file mode 100644 index 00000000000..b1809e6a9f3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/listbox_input/listbox_input.vue @@ -0,0 +1,110 @@ +<script> +import { GlListbox } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const MIN_ITEMS_COUNT_FOR_SEARCHING = 20; + +export default { + i18n: { + noResultsText: __('No results found'), + }, + components: { + GlListbox, + }, + model: GlListbox.model, + props: { + name: { + type: String, + required: true, + }, + defaultToggleText: { + type: String, + required: true, + }, + selected: { + type: String, + required: false, + default: null, + }, + items: { + type: GlListbox.props.items.type, + required: true, + }, + }, + data() { + return { + searchString: '', + }; + }, + computed: { + allOptions() { + const allOptions = []; + + const getOptions = (options) => { + for (let i = 0; i < options.length; i += 1) { + const option = options[i]; + if (option.options) { + getOptions(option.options); + } else { + allOptions.push(option); + } + } + }; + getOptions(this.items); + + return allOptions; + }, + isGrouped() { + return this.items.some((item) => item.options !== undefined); + }, + isSearchable() { + return this.allOptions.length > MIN_ITEMS_COUNT_FOR_SEARCHING; + }, + filteredItems() { + const searchString = this.searchString.toLowerCase(); + + if (!searchString) { + return this.items; + } + + if (this.isGrouped) { + return this.items + .map(({ text, options }) => { + return { + text, + options: options.filter((option) => option.text.toLowerCase().includes(searchString)), + }; + }) + .filter(({ options }) => options.length); + } + + return this.items.filter((item) => item.text.toLowerCase().includes(searchString)); + }, + toggleText() { + return this.selected + ? this.allOptions.find((option) => option.value === this.selected).text + : this.defaultToggleText; + }, + }, + methods: { + search(searchString) { + this.searchString = searchString; + }, + }, +}; +</script> + +<template> + <div> + <gl-listbox + :selected="selected" + :toggle-text="toggleText" + :items="filteredItems" + :searchable="isSearchable" + :no-results-text="$options.i18n.noResultsText" + @search="search" + @select="$emit($options.model.event, $event)" + /> + <input ref="input" type="hidden" :name="name" :value="selected" /> + </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 caec49c557a..f51ec715678 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -74,7 +74,7 @@ export default { @submit="onApply" /> <gl-button - class="gl-w-auto! gl-mt-3 gl-text-center! gl-hover-text-white! gl-transition-medium! float-right" + class="gl-w-auto! gl-mt-3 gl-text-center! gl-transition-medium! float-right" category="primary" variant="confirm" data-qa-selector="commit_with_custom_message_button" diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 657e4498b53..b5f2602af5e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -1,15 +1,16 @@ <script> -import { GlIcon, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; import { debounce, unescape } from 'lodash'; import { createAlert } from '~/flash'; import GLForm from '~/gl_form'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import axios from '~/lib/utils/axios_utils'; import { stripHtml } from '~/lib/utils/text_utility'; import { __, sprintf } from '~/locale'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import MarkdownHeader from './header.vue'; import MarkdownToolbar from './toolbar.vue'; @@ -25,7 +26,7 @@ export default { Suggestions, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagsMixin()], props: { @@ -313,7 +314,9 @@ export default { this.markdownPreview = data.body || __('Nothing to preview.'); this.$nextTick() - .then(() => $(this.$refs['markdown-preview']).renderGFM()) + .then(() => { + renderGFM(this.$refs['markdown-preview']); + }) .catch(() => createAlert({ message: __('Error rendering Markdown preview'), diff --git a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue index d77123371f2..84d40db07bb 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue @@ -1,15 +1,9 @@ <script> -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { mounted() { - this.renderGFM(); - }, - methods: { - renderGFM() { - $(this.$el).renderGFM(); - }, + renderGFM(this.$el); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index c0712e46613..d01eae0308f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -82,6 +82,11 @@ export default { required: false, default: false, }, + useBottomToolbar: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -197,6 +202,7 @@ export default { :uploads-path="uploadsPath" :markdown="value" :autofocus="contentEditorAutofocused" + :use-bottom-toolbar="useBottomToolbar" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" @loading="disableSwitchEditingControl" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue index a04f8616acb..0b598d3acaf 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { name: 'SuggestionDiffRow', diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 30d72332c90..c307601e670 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -1,6 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import Vue from 'vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { createAlert } from '~/flash'; import { __ } from '~/locale'; import SuggestionDiff from './suggestion_diff.vue'; diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.stories.js index 03bd64e2a57..03bd64e2a57 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/makrdown_drawer.stories.js +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.stories.js diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue index a4b509f8656..379f22fdc6f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/markdown_drawer.vue @@ -1,9 +1,9 @@ <script> -import { GlSafeHtmlDirective as SafeHtml, GlDrawer, GlAlert, GlSkeletonLoader } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { GlDrawer, GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__ } from '~/locale'; import { contentTop } from '~/lib/utils/common_utils'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { getRenderedMarkdown } from './utils/fetch'; export const cache = {}; @@ -34,13 +34,9 @@ export default { title: '', body: null, open: false, + drawerTop: '0px', }; }, - computed: { - drawerOffsetTop() { - return `${contentTop()}px`; - }, - }, watch: { documentPath: { immediate: true, @@ -76,18 +72,23 @@ export default { cache[this.documentPath] = { title, body }; } }, + getDrawerTop() { + this.drawerTop = `${contentTop()}px`; + }, renderGLFM() { this.$nextTick(() => { - $(this.$refs['content-element']).renderGFM(); + renderGFM(this.$refs['content-element']); }); }, closeDrawer() { this.open = false; }, toggleDrawer() { + this.getDrawerTop(); this.open = !this.open; }, openDrawer() { + this.getDrawerTop(); this.open = true; }, }, @@ -97,7 +98,7 @@ export default { }; </script> <template> - <gl-drawer :header-height="drawerOffsetTop" :open="open" header-sticky @close="closeDrawer"> + <gl-drawer :header-height="drawerTop" :open="open" header-sticky @close="closeDrawer"> <template #title> <h4 data-testid="title-element" class="gl-m-0">{{ title }}</h4> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js index 7c8e1bc160a..27237f2f16b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js @@ -16,7 +16,7 @@ export const getRenderedMarkdown = (documentPath) => { return axios .get(helpPagePath(documentPath)) .then(({ data }) => { - const { body, title } = splitDocument(data.html); + const { body, title } = splitDocument(data); return { body, title, diff --git a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue index e23721da223..2cadc87eca3 100644 --- a/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue +++ b/app/assets/javascripts/vue_shared/components/metric_images/metric_images_tab.vue @@ -1,5 +1,5 @@ <script> -import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal, GlTab } from '@gitlab/ui'; +import { GlFormGroup, GlFormInput, GlLoadingIcon, GlModal } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { __, s__ } from '~/locale'; import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; @@ -11,7 +11,6 @@ export default { GlFormInput, GlLoadingIcon, GlModal, - GlTab, MetricImagesTable, UploadDropzone, }, @@ -82,7 +81,7 @@ export default { </script> <template> - <gl-tab :title="s__('Incident|Metrics')" data-testid="metrics-tab"> + <div> <div v-if="isLoadingMetricImages"> <gl-loading-icon class="gl-p-5" size="sm" /> </div> @@ -117,5 +116,5 @@ export default { :drop-description-message="$options.i18n.dropDescription" @change="openMetricDialog" /> - </gl-tab> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index cf34a60c363..748d6082abd 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -16,8 +16,9 @@ * :note="{body: 'This is a note'}" * /> */ -import { GlSafeHtmlDirective as SafeHtml, GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { renderMarkdown } from '~/notes/utils'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 1ae5045b34f..1cbbdf0deb0 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -16,22 +16,17 @@ * }" * /> */ -import { - GlButton, - GlSkeletonLoader, - GlTooltipDirective, - GlIcon, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import $ from 'jquery'; import { mapGetters, mapActions, mapState } from 'vuex'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; -import '~/behaviors/markdown/render_gfm'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import NoteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { spriteIcon } from '~/lib/utils/common_utils'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import TimelineEntryItem from './timeline_entry_item.vue'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; @@ -94,7 +89,7 @@ export default { }, }, mounted() { - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); }, methods: { ...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']), @@ -205,7 +200,7 @@ export default { <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder"> <td :class="line.type" - class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0!" + class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0! gl-border-0! gl-rounded-0!" > {{ line.old_line }} </td> @@ -217,7 +212,7 @@ export default { </td> <td :class="line.type" - class="line_content gl-display-table-cell!" + class="line_content gl-display-table-cell! gl-border-0! gl-rounded-0!" v-html="line.rich_text /* eslint-disable-line vue/no-v-html */" ></td> </tr> 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 867222279b2..57e3a97244e 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 @@ -1,22 +1,19 @@ <script> -import { - GlAlert, - GlBadge, - GlPagination, - GlTab, - GlTabs, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlAlert, GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Api from '~/api'; import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import Tracking from '~/tracking'; import { - OPERATOR_IS_ONLY, + FILTERED_SEARCH_TERM, + OPERATORS_IS, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, } 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 UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import { initialPaginationState, defaultI18n, defaultPageSize } from './constants'; import { isAny } from './utils'; @@ -95,7 +92,7 @@ export default { filterSearchTokens: { type: Array, required: false, - default: () => ['author_username', 'assignee_username'], + default: () => [TOKEN_TYPE_AUTHOR, TOKEN_TYPE_ASSIGNEE], }, }, data() { @@ -113,26 +110,26 @@ export default { defaultTokens() { return [ { - type: 'author_username', + type: TOKEN_TYPE_AUTHOR, icon: 'user', title: TOKEN_TITLE_AUTHOR, unique: true, symbol: '@', - token: AuthorToken, - operators: OPERATOR_IS_ONLY, + token: UserToken, + operators: OPERATORS_IS, fetchPath: this.projectPath, - fetchAuthors: Api.projectUsers.bind(Api), + fetchUsers: Api.projectUsers.bind(Api), }, { - type: 'assignee_username', + type: TOKEN_TYPE_ASSIGNEE, icon: 'user', title: TOKEN_TITLE_ASSIGNEE, unique: true, symbol: '@', - token: AuthorToken, - operators: OPERATOR_IS_ONLY, + token: UserToken, + operators: OPERATORS_IS, fetchPath: this.projectPath, - fetchAuthors: Api.projectUsers.bind(Api), + fetchUsers: Api.projectUsers.bind(Api), }, ]; }, @@ -144,14 +141,14 @@ export default { if (this.authorUsername) { value.push({ - type: 'author_username', + type: TOKEN_TYPE_AUTHOR, value: { data: this.authorUsername }, }); } if (this.assigneeUsername) { value.push({ - type: 'assignee_username', + type: TOKEN_TYPE_ASSIGNEE, value: { data: this.assigneeUsername }, }); } @@ -226,13 +223,13 @@ export default { filters.forEach((filter) => { if (typeof filter === 'object') { switch (filter.type) { - case 'author_username': + case TOKEN_TYPE_AUTHOR: filterParams.authorUsername = isAny(filter.value.data); break; - case 'assignee_username': + case TOKEN_TYPE_ASSIGNEE: filterParams.assigneeUsername = isAny(filter.value.data); break; - case 'filtered-search-term': + case FILTERED_SEARCH_TERM: if (filter.value.data !== '') filterParams.search = filter.value.data; break; default: diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue index 66643ff4026..16bc8070dc1 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue @@ -1,9 +1,10 @@ <script> -import { GlButton, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { isString } from 'lodash'; import highlight from '~/lib/utils/highlight'; import { truncateNamespace } from '~/lib/utils/text_utility'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; export default { name: 'ProjectListItem', diff --git a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue index 8c9c7c63db1..c990baaa2f3 100644 --- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue +++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue @@ -1,7 +1,7 @@ <script> import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { SORT_DIRECTION_UI } from '~/search/sort/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; const ASCENDING_ORDER = 'asc'; const DESCENDING_ORDER = 'desc'; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js b/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js deleted file mode 100644 index 465ee9aa0d4..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/todo_toggle/todo_button.stories.js +++ /dev/null @@ -1,21 +0,0 @@ -import TodoButton from './todo_button.vue'; - -export default { - component: TodoButton, - title: 'vue_shared/sidebar/todo_toggle/todo_button', -}; - -const Template = (args, { argTypes }) => ({ - components: { TodoButton }, - props: Object.keys(argTypes), - template: '<todo-button v-bind="$props" v-on="$props" />', -}); - -export const Default = Template.bind({}); -Default.argTypes = { - isTodo: { - description: 'True if to-do is unresolved (i.e. not "done")', - control: { type: 'boolean' }, - }, - click: { action: 'clicked' }, -}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue index a2d8b7cbd15..28a16cd846a 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk.vue @@ -1,6 +1,6 @@ <script> -import { GlIntersectionObserver, GlSafeHtmlDirective } from '@gitlab/ui'; -import { scrollToElement } from '~/lib/utils/common_utils'; +import { GlIntersectionObserver } from '@gitlab/ui'; +import LineHighlighter from '~/blob/line_highlighter'; import ChunkLine from './chunk_line.vue'; /* @@ -20,9 +20,6 @@ export default { ChunkLine, GlIntersectionObserver, }, - directives: { - SafeHtml: GlSafeHtmlDirective, - }, props: { isFirstChunk: { type: Boolean, @@ -84,12 +81,14 @@ export default { return; } - window.requestIdleCallback(() => { + window.requestIdleCallback(async () => { this.isLoading = false; const { hash } = this.$route; if (hash && this.totalChunks > 0 && this.totalChunks === this.chunkIndex + 1) { // when the last chunk is loaded scroll to the hash - scrollToElement(hash, { behavior: 'auto' }); + await this.$nextTick(); + const lineHighlighter = new LineHighlighter({ scrollBehavior: 'auto' }); + lineHighlighter.highlightHash(hash); } }); }, diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue index 0bf19f83d86..ce6741f33b1 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_line.vue @@ -1,11 +1,11 @@ <script> -import { GlSafeHtmlDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getPageParamValue, getPageSearchString } from '~/blob/utils'; export default { directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [glFeatureFlagMixin()], props: { diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js index fca2616f069..cd15916851c 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/link_dependencies.js @@ -4,6 +4,7 @@ import godepsJsonLinker from './utils/godeps_json_linker'; import gemfileLinker from './utils/gemfile_linker'; import podspecJsonLinker from './utils/podspec_json_linker'; import composerJsonLinker from './utils/composer_json_linker'; +import goSumLinker from './utils/go_sum_linker'; const DEPENDENCY_LINKERS = { package_json: packageJsonLinker, @@ -12,6 +13,7 @@ const DEPENDENCY_LINKERS = { gemfile: gemfileLinker, podspec_json: podspecJsonLinker, composer_json: composerJsonLinker, + go_sum: goSumLinker, }; /** diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/go_sum_linker.js b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/go_sum_linker.js new file mode 100644 index 00000000000..b290dfa78b9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/plugins/utils/go_sum_linker.js @@ -0,0 +1,34 @@ +import { createLink } from './dependency_linker_util'; + +const openTag = '<span class="">'; +const closeTag = '</span>'; +const TAG_URL = 'https://sum.golang.org/lookup/'; +const GO_PACKAGE_URL = 'https://pkg.go.dev/'; + +const DEPENDENCY_REGEX = new RegExp( + /* + * Detects dependencies inside of content that is highlighted by Highlight.js + * Example: '<span class="">cloud.google.com/Go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=</span>' + * Group 1 (packagePath): 'cloud.google.com/Go/bigquery' + * Group 2 (version): 'v1.0.1/go.mod' + * Group 3 (base64url): 'i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=' + */ + `${openTag}(.*) (v.*) h1:(.*)${closeTag}`, + 'gm', +); + +const handleReplace = (packagePath, version, tag) => { + const lowercasePath = packagePath.toLowerCase(); + const packageHref = `${GO_PACKAGE_URL}${lowercasePath}`; + const packageLink = createLink(packageHref, packagePath); + const tagHref = `${TAG_URL}${lowercasePath}@${version.split('/go.mod')[0]}`; + const tagLink = createLink(tagHref, tag); + + return `${openTag}${packageLink} ${version} h1:${tagLink}${closeTag}`; +}; + +export default (result) => { + return result.value.replace(DEPENDENCY_REGEX, (_, packagePath, version, tag) => + handleReplace(packagePath, version, tag), + ); +}; diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue index f621a23734a..0cfee93ce5d 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective, GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon } from '@gitlab/ui'; import LineHighlighter from '~/blob/line_highlighter'; import eventHub from '~/notes/event_hub'; import languageLoader from '~/content_editor/services/highlight_js_language_loader'; @@ -28,9 +28,6 @@ export default { GlLoadingIcon, Chunk, }, - directives: { - SafeHtml: GlSafeHtmlDirective, - }, mixins: [Tracking.mixin()], props: { blob: { 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 80c1fcbacfa..d06bc7b8f98 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 @@ -4,11 +4,11 @@ import { GlLink, GlSkeletonLoader, GlIcon, - GlSafeHtmlDirective, GlSprintf, GlButton, GlAvatarLabeled, } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { glEmojiTag } from '~/emoji'; import { createAlert } from '~/flash'; import { followUser, unfollowUser } from '~/rest_api'; @@ -44,7 +44,7 @@ export default { GlAvatarLabeled, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, mixins: [Tracking.mixin()], props: { diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 6d179b3dc92..383dc27ea5e 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -1,14 +1,16 @@ <script> -import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlModal, GlSprintf, GlLink, GlPopover } from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -const KEY_EDIT = 'edit'; -const KEY_WEB_IDE = 'webide'; -const KEY_GITPOD = 'gitpod'; -const KEY_PIPELINE_EDITOR = 'pipeline_editor'; +export const KEY_EDIT = 'edit'; +export const KEY_WEB_IDE = 'webide'; +export const KEY_GITPOD = 'gitpod'; +export const KEY_PIPELINE_EDITOR = 'pipeline_editor'; export const i18n = { modal: { @@ -25,6 +27,9 @@ export const i18n = { ), }; +export const PREFERRED_EDITOR_KEY = 'gl-web-ide-button-selected'; +export const PREFERRED_EDITOR_RESET_KEY = 'gl-web-ide-button-selected-reset'; + export default { components: { ActionsButton, @@ -32,9 +37,12 @@ export default { GlModal, GlSprintf, GlLink, + GlPopover, ConfirmForkModal, + UserCalloutDismisser, }, i18n, + mixins: [glFeatureFlagsMixin()], props: { isFork: { type: Boolean, @@ -131,6 +139,11 @@ export default { required: false, default: '', }, + webIdePromoPopoverImg: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -296,6 +309,12 @@ export default { }, }; }, + displayVscodeWebIdeCallout() { + return this.glFeatures.vscodeWebIde && !this.showEditButton; + }, + }, + mounted() { + this.resetPreferredEditor(); }, methods: { select(key) { @@ -304,41 +323,109 @@ export default { showModal(dataKey) { this[dataKey] = true; }, + resetPreferredEditor() { + if (!this.glFeatures.vscodeWebIde || this.showEditButton) { + return; + } + + if (localStorage.getItem(PREFERRED_EDITOR_RESET_KEY) === 'true') { + return; + } + + localStorage.setItem(PREFERRED_EDITOR_KEY, KEY_WEB_IDE); + localStorage.setItem(PREFERRED_EDITOR_RESET_KEY, true); + + this.select(KEY_WEB_IDE); + }, + dismissCalloutOnActionClicked(dismiss) { + if (this.displayVscodeWebIdeCallout) { + dismiss(); + } + }, }, + webIdeButtonId: 'web-ide-link', + PREFERRED_EDITOR_KEY, }; </script> <template> - <div class="gl-sm-ml-3"> - <actions-button - :actions="actions" - :selected-key="selection" - :variant="isBlob ? 'confirm' : 'default'" - :category="isBlob ? 'primary' : 'secondary'" - @select="select" - /> - <local-storage-sync - storage-key="gl-web-ide-button-selected" - :value="selection" - as-string - @input="select" - /> - <gl-modal - v-if="computedShowGitpodButton && !gitpodEnabled" - v-model="showEnableGitpodModal" - v-bind="enableGitpodModalProps" - > - <gl-sprintf :message="$options.i18n.modal.content"> - <template #link="{ content }"> - <gl-link :href="userPreferencesGitpodPath">{{ content }}</gl-link> - </template> - </gl-sprintf> - </gl-modal> - <confirm-fork-modal - v-if="showWebIdeButton || showEditButton" - v-model="showForkModal" - :modal-id="forkModalId" - :fork-path="forkPath" - /> - </div> + <user-callout-dismisser + :skip-query="!displayVscodeWebIdeCallout" + feature-name="vscode_web_ide_callout" + > + <template #default="{ dismiss, shouldShowCallout }"> + <div class="gl-sm-ml-3"> + <actions-button + :id="$options.webIdeButtonId" + :actions="actions" + :selected-key="selection" + :variant="isBlob ? 'confirm' : 'default'" + :category="isBlob ? 'primary' : 'secondary'" + :show-action-tooltip="!displayVscodeWebIdeCallout || !shouldShowCallout" + @select="select" + @actionClicked="dismissCalloutOnActionClicked(dismiss)" + /> + <local-storage-sync + :storage-key="$options.PREFERRED_EDITOR_KEY" + :value="selection" + as-string + @input="select" + /> + <gl-modal + v-if="computedShowGitpodButton && !gitpodEnabled" + v-model="showEnableGitpodModal" + v-bind="enableGitpodModalProps" + > + <gl-sprintf :message="$options.i18n.modal.content"> + <template #link="{ content }"> + <gl-link :href="userPreferencesGitpodPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-modal> + <confirm-fork-modal + v-if="showWebIdeButton || showEditButton" + v-model="showForkModal" + :modal-id="forkModalId" + :fork-path="forkPath" + /> + <gl-popover + v-if="displayVscodeWebIdeCallout" + :target="$options.webIdeButtonId" + :show="shouldShowCallout" + :css-classes="['web-ide-promo-popover']" + :boundary-padding="80" + show-close-button + triggers="manual" + @close-button-clicked="dismiss" + > + <img + :src="webIdePromoPopoverImg" + class="web-ide-promo-popover-illustration" + width="280" + height="140" + /> + <div class="gl-mx-2"> + <h5 class="gl-mt-3 gl-mb-3">{{ __('The new Web IDE') }}</h5> + <p> + {{ + __( + 'VS Code in your browser. View code and make changes from the same UI as in your local IDE.', + ) + }} + </p> + <gl-link + class="gl-button btn btn-confirm block gl-mb-4 gl-mt-5" + variant="confirm" + category="primary" + target="_blank" + :href="webIdeUrl" + block + > + {{ __('Try it out now') }} + </gl-link> + </div> + </gl-popover> + </div> + </template> + </user-callout-dismisser> </template> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index a851f84ed2f..2f85a29fb84 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -13,7 +13,9 @@ export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; export const ISO_SHORT_FORMAT = 'yyyy-mm-dd'; -export const DATE_FORMATS = [SHORT_DATE_FORMAT, ISO_SHORT_FORMAT]; +export const LONG_DATE_FORMAT_WITH_TZ = 'yyyy-mm-dd HH:MM:ss Z'; + +export const DATE_FORMATS = [SHORT_DATE_FORMAT, ISO_SHORT_FORMAT, LONG_DATE_FORMAT_WITH_TZ]; const getTimeLabel = (days) => n__('1 day', '%d days', days); diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue index 25799171905..2644befc902 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_form.vue @@ -1,8 +1,8 @@ <script> import { GlForm, GlFormInput, GlFormGroup } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; -import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants'; +import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; export default { LabelSelectVariant: DropdownVariant, diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue new file mode 100644 index 00000000000..b3f9c8d9fcd --- /dev/null +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue @@ -0,0 +1,92 @@ +<script> +import { GlFormGroup, GlIcon } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import LabelsSelect from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; +import { __ } from '~/locale'; + +export default { + components: { + GlFormGroup, + GlIcon, + LabelsSelect, + }, + inject: [ + 'allowLabelRemove', + 'attrWorkspacePath', + 'fieldName', + 'fullPath', + 'labelsFilterBasePath', + 'initialLabels', + 'issuableType', + 'labelType', + 'variant', + 'workspaceType', + ], + data() { + return { + selectedLabels: this.initialLabels || [], + }; + }, + methods: { + handleUpdateSelectedLabels({ labels }) { + this.selectedLabels = labels.map((label) => ({ ...label, id: getIdFromGraphQLId(label.id) })); + }, + handleLabelRemove(labelId) { + this.selectedLabels = this.selectedLabels.filter((label) => label.id !== labelId); + }, + }, + i18n: { + fieldLabel: __('Labels'), + dropdownButtonText: __('Select label'), + listTitle: __('Select label'), + createTitle: __('Create project label'), + manageTitle: __('Manage project labels'), + emptySelection: __('None'), + }, +}; +</script> + +<template> + <gl-form-group class="row" label-class="gl-display-none"> + <label class="col-12 gl-display-flex gl-align-center gl-mb-1"> + {{ $options.i18n.fieldLabel }} + <div class="gl-ml-3"> + <gl-icon name="labels" /> + <span class="gl-font-base gl-line-height-24">{{ selectedLabels.length }}</span> + </div> + </label> + <div class="col-12"> + <div class="issuable-form-select-holder"> + <input + v-for="selectedLabel in selectedLabels" + :key="selectedLabel.id" + :value="selectedLabel.id" + :name="fieldName" + type="hidden" + /> + <labels-select + class="block labels" + :allow-label-remove="allowLabelRemove" + :allow-multiselect="true" + :show-embedded-labels-list="true" + :full-path="fullPath" + :attr-workspace-path="attrWorkspacePath" + :labels-filter-base-path="labelsFilterBasePath" + :dropdown-button-text="$options.i18n.dropdownButtonText" + :labels-list-title="$options.i18n.listTitle" + :footer-create-label-title="$options.i18n.createTitle" + :footer-manage-label-title="$options.i18n.manageTitle" + :variant="variant" + :workspace-type="workspaceType" + :issuable-type="issuableType" + :label-create-type="labelType" + :selected-labels="selectedLabels" + @updateSelectedLabels="handleUpdateSelectedLabels" + @onLabelRemove="handleLabelRemove" + > + {{ $options.i18n.emptySelection }} + </labels-select> + </div> + </div> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 30b7b073ac3..5b303b9a314 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -318,8 +318,8 @@ export default { <slot name="statistics"></slot> <li v-if="showDiscussions" - data-testid="issuable-discussions" - class="issuable-comments gl-display-none gl-sm-display-block" + class="gl-display-none gl-sm-display-block" + data-testid="issuable-comments" > <gl-link v-gl-tooltip.top diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue index dd3d7c8f4d6..5b6c5bf6e03 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_list_root.vue @@ -331,6 +331,7 @@ export default { <slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot> </template> </issuable-bulk-edit-sidebar> + <slot name="list-body"></slot> <ul v-if="issuablesLoading" class="content-list"> <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!"> <gl-skeleton-loader /> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue index d4e9120ff17..ce1851ab873 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_description.vue @@ -1,7 +1,6 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; export default { directives: { @@ -26,12 +25,7 @@ export default { }, }, mounted() { - this.renderGFM(); - }, - methods: { - renderGFM() { - $(this.$refs.gfmContainer).renderGFM(); - }, + renderGFM(this.$refs.gfmContainer); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index 35124bd15d2..fd94245b7c9 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -1,12 +1,6 @@ <script> -import { - GlIcon, - GlBadge, - GlButton, - GlIntersectionObserver, - GlTooltipDirective, - GlSafeHtmlDirective as SafeHtml, -} from '@gitlab/ui'; +import { GlIcon, GlBadge, GlButton, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import { IssuableStates } from '~/vue_shared/issuable/list/constants'; 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 index e42720bf1db..ae40076ca96 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/legacy_container.vue @@ -1,4 +1,6 @@ <script> +import projectNew from '~/projects/project_new'; + export default { inheritAttrs: false, props: { @@ -16,6 +18,7 @@ export default { this.source = legacyEntry.parentNode; this.$el.appendChild(legacyEntry); legacyEntry.classList.add('active'); + projectNew.bindEvents(); } }, diff --git a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue index 5cd2018bb8c..b6a459f21e0 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/components/welcome.vue @@ -1,5 +1,5 @@ <script> -import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; export default { diff --git a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue index 624ae7027d5..318adec2319 100644 --- a/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue +++ b/app/assets/javascripts/vue_shared/new_namespace/new_namespace_page.vue @@ -1,5 +1,6 @@ <script> -import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import NewTopLevelGroupAlert from '~/groups/components/new_top_level_group_alert.vue'; import LegacyContainer from './components/legacy_container.vue'; 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 0e1975e1c09..b739baad5d7 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 @@ -2,8 +2,8 @@ import { mapActions, mapGetters } from 'vuex'; import { createAlert } from '~/flash'; import { s__ } from '~/locale'; -import ReportSection from '~/reports/components/report_section.vue'; -import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants'; +import ReportSection from '~/ci/reports/components/report_section.vue'; +import { ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/ci/reports/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import HelpIcon from './components/help_icon.vue'; import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js index 08f6bcca15b..c274f531139 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/getters.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/getters.js @@ -1,5 +1,5 @@ import { s__, sprintf } from '~/locale'; -import { LOADING, ERROR, SUCCESS } from '~/reports/constants'; +import { LOADING, ERROR, SUCCESS } from '~/ci/reports/constants'; import { TRANSLATION_IS_LOADING } from './messages'; import { countVulnerabilities, groupedTextBuilder } from './utils'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js index a6628fa0f9f..f3cb5fc16f0 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -29,7 +29,13 @@ export const fetchDiffData = (state, endpoint, category) => { */ export const enrichVulnerabilityWithFeedback = (vulnerability, feedback = []) => feedback - .filter((fb) => fb.project_fingerprint === vulnerability.project_fingerprint) + .filter((fb) => + // Some records still have a `finding_uuid` with null, we need to fallback to using `project_fingerprint` in those cases. Once all entries have been fixed, we can remove the fallback. + // related epic: https://gitlab.com/groups/gitlab-org/-/epics/2791 + fb.finding_uuid !== null + ? fb.finding_uuid === vulnerability.finding_uuid + : fb.project_fingerprint === vulnerability.project_fingerprint, + ) .reduce((vuln, fb) => { if (fb.feedback_type === FEEDBACK_TYPE_DISMISSAL) { return { diff --git a/app/assets/javascripts/webhooks/components/push_events.vue b/app/assets/javascripts/webhooks/components/push_events.vue index 677f06314e0..91d7e21500a 100644 --- a/app/assets/javascripts/webhooks/components/push_events.vue +++ b/app/assets/javascripts/webhooks/components/push_events.vue @@ -33,7 +33,7 @@ export default { <template> <div> - <gl-form-checkbox v-model="pushEventsData">{{ s__('Webhooks|Push events') }}</gl-form-checkbox> + <gl-form-checkbox v-model="pushEventsData">{{ __('Push events') }}</gl-form-checkbox> <input type="hidden" :value="pushEventsData" name="hook[push_events]" /> <div v-if="pushEventsData" class="gl-pl-6"> diff --git a/app/assets/javascripts/webhooks/constants.js b/app/assets/javascripts/webhooks/constants.js index 6710a418117..96632b47e6b 100644 --- a/app/assets/javascripts/webhooks/constants.js +++ b/app/assets/javascripts/webhooks/constants.js @@ -7,13 +7,13 @@ export const BRANCH_FILTER_REGEX = 'regex'; export const WILDCARD_CODE_STABLE = '*-stable'; export const WILDCARD_CODE_PRODUCTION = 'production/*'; -export const REGEX_CODE = '(feature|hotfix)/*'; +export const REGEX_CODE = '^(feature|hotfix)/'; export const descriptionText = { [BRANCH_FILTER_WILDCARD]: s__( 'Webhooks|Wildcards such as %{WILDCARD_CODE_STABLE} or %{WILDCARD_CODE_PRODUCTION} are supported.', ), - [BRANCH_FILTER_REGEX]: s__('Webhooks|Regex such as %{REGEX_CODE} is supported.'), + [BRANCH_FILTER_REGEX]: s__('Webhooks|Regular expressions such as %{REGEX_CODE} are supported.'), }; export const MASK_ITEM_VALUE_HIDDEN = '************'; diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue index c954a86e593..044a6db6d93 100644 --- a/app/assets/javascripts/whats_new/components/feature.vue +++ b/app/assets/javascripts/whats_new/components/feature.vue @@ -1,5 +1,6 @@ <script> -import { GlBadge, GlIcon, GlLink, GlSafeHtmlDirective, GlButton } from '@gitlab/ui'; +import { GlBadge, GlIcon, GlLink, GlButton } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; import { dateInWords, isValidDate } from '~/lib/utils/datetime_utility'; export default { @@ -10,7 +11,7 @@ export default { GlButton, }, directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, }, props: { feature: { diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue new file mode 100644 index 00000000000..92a2fcaf1df --- /dev/null +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -0,0 +1,229 @@ +<script> +/** + * Common component to render a system note, icon and user information. + * + * This component need not be used with any store neither has any vuex dependency + * + * @example + * <system-note + * :note="{ + * id: String, + * author: Object, + * createdAt: String, + * bodyHtml: String, + * systemNoteIconName: String + * }" + * /> + */ +import { GlButton, GlSkeletonLoader, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import $ from 'jquery'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; +import axios from '~/lib/utils/axios_utils'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import NoteHeader from '~/notes/components/note_header.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + +const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; + +export default { + i18n: { + deleteButtonLabel: __('Remove description history'), + }, + name: 'SystemNote', + components: { + GlIcon, + NoteHeader, + TimelineEntryItem, + GlButton, + GlSkeletonLoader, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + mixins: [descriptionVersionHistoryMixin, glFeatureFlagsMixin()], + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + expanded: false, + lines: [], + showLines: false, + loadingDiff: false, + isLoadingDescriptionVersion: false, + }; + }, + computed: { + targetNoteHash() { + return getLocationHash(); + }, + descriptionVersions() { + return []; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + isTargetNote() { + return this.targetNoteHash === this.noteAnchorId; + }, + toggleIcon() { + return this.expanded ? 'chevron-up' : 'chevron-down'; + }, + // following 2 methods taken from code in `collapseLongCommitList` of notes.js: + actionTextHtml() { + return $(this.note.bodyHtml).unwrap().html(); + }, + hasMoreCommits() { + return $(this.note.bodyHtml).filter('ul').children().length > MAX_VISIBLE_COMMIT_LIST_COUNT; + }, + descriptionVersion() { + return this.descriptionVersions[this.note.description_version_id]; + }, + }, + mounted() { + renderGFM(this.$refs['gfm-content']); + }, + methods: { + fetchDescriptionVersion() {}, + softDeleteDescriptionVersion() {}, + + async toggleDiff() { + this.showLines = !this.showLines; + + if (!this.lines.length) { + this.loadingDiff = true; + const { data } = await axios.get(this.note.outdated_line_change_path); + + this.lines = data.map((l) => ({ + ...l, + rich_text: l.rich_text.replace(/^[+ -]/, ''), + })); + this.loadingDiff = false; + } + }, + }, + safeHtmlConfig: { + ADD_TAGS: ['use'], // to support icon SVGs + }, + userColorSchemeClass: window.gon.user_color_scheme, +}; +</script> + +<template> + <timeline-entry-item + :id="noteAnchorId" + :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" + class="note system-note note-wrapper" + > + <div class="timeline-icon"><gl-icon :name="note.systemNoteIconName" /></div> + <div class="timeline-content"> + <div class="note-header"> + <note-header + :author="note.author" + :created-at="note.createdAt" + :note-id="note.id" + :is-system-note="true" + > + <span ref="gfm-content" v-safe-html="actionTextHtml"></span> + <template + v-if="canSeeDescriptionVersion || note.outdated_line_change_path" + #extra-controls + > + · + <gl-button + v-if="canSeeDescriptionVersion" + variant="link" + :icon="descriptionVersionToggleIcon" + data-testid="compare-btn" + class="gl-vertical-align-text-bottom gl-font-sm!" + @click="toggleDescriptionVersion" + >{{ __('Compare with previous version') }}</gl-button + > + <gl-button + v-if="note.outdated_line_change_path" + :icon="showLines ? 'chevron-up' : 'chevron-down'" + variant="link" + data-testid="outdated-lines-change-btn" + class="gl-vertical-align-text-bottom gl-font-sm!" + @click="toggleDiff" + > + {{ __('Compare changes') }} + </gl-button> + </template> + </note-header> + </div> + <div class="note-body"> + <div + v-safe-html="note.bodyHtml" + :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }" + class="note-text md" + ></div> + <div v-if="hasMoreCommits" class="flex-list"> + <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded"> + <gl-icon :name="toggleIcon" :size="8" class="gl-mr-2" /> + <span>{{ __('Toggle commit list') }}</span> + </div> + </div> + <div v-if="shouldShowDescriptionVersion" class="description-version pt-2"> + <pre v-if="isLoadingDescriptionVersion" class="loading-state"> + <gl-skeleton-loader /> + </pre> + <pre v-else v-safe-html="descriptionVersion" class="wrapper mt-2"></pre> + <gl-button + v-if="displayDeleteButton" + v-gl-tooltip + :title="$options.i18n.deleteButtonLabel" + :aria-label="$options.i18n.deleteButtonLabel" + variant="default" + category="tertiary" + icon="remove" + class="delete-description-history" + data-testid="delete-description-version-button" + @click="deleteDescriptionVersion" + /> + </div> + <div + v-if="lines.length && showLines" + class="diff-content outdated-lines-wrapper gl-border-solid gl-border-1 gl-border-gray-200 gl-mt-4 gl-rounded-small gl-overflow-hidden" + > + <table + :class="$options.userColorSchemeClass" + class="code js-syntax-highlight" + data-testid="outdated-lines" + > + <tr v-for="line in lines" v-once :key="line.line_code" class="line_holder"> + <td + :class="line.type" + class="diff-line-num old_line gl-border-bottom-0! gl-border-top-0! gl-border-0! gl-rounded-0!" + > + {{ line.old_line }} + </td> + <td + :class="line.type" + class="diff-line-num new_line gl-border-bottom-0! gl-border-top-0!" + > + {{ line.new_line }} + </td> + <td + :class="line.type" + class="line_content gl-display-table-cell! gl-border-0! gl-rounded-0!" + v-html="line.rich_text /* eslint-disable-line vue/no-v-html */" + ></td> + </tr> + </table> + </div> + <div v-else-if="showLines" class="mt-4"> + <gl-skeleton-loader /> + </div> + </div> + </div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 4d6a27f61ac..c2980405a19 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -202,6 +202,7 @@ export default { if (!this.allowsMultipleAssignees) { this.localAssignees = assignees.length > 0 ? [assignees[assignees.length - 1]] : []; this.isEditing = false; + this.setAssignees(this.assigneeIds); return; } this.localAssignees = assignees; diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index 57930951856..07da0279b41 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlFormGroup } from '@gitlab/ui'; +import { GlAlert, GlButton, GlFormGroup } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; @@ -19,6 +19,7 @@ import WorkItemDescriptionRendered from './work_item_description_rendered.vue'; export default { components: { EditedAt, + GlAlert, GlButton, GlFormGroup, MarkdownEditor, @@ -54,6 +55,7 @@ export default { isSubmittingWithKeydown: false, descriptionText: '', descriptionHtml: '', + conflictedDescription: '', }; }, apollo: { @@ -68,11 +70,17 @@ export default { return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; }, skip() { - return !this.workItemId; + return !this.queryVariables.id && !this.queryVariables.iid; }, result() { - this.descriptionText = this.workItemDescription?.description; - this.descriptionHtml = this.workItemDescription?.descriptionHtml; + if (this.isEditing) { + if (this.descriptionText !== this.workItemDescription?.description) { + this.conflictedDescription = this.workItemDescription?.description; + } + } else { + this.descriptionText = this.workItemDescription?.description; + this.descriptionHtml = this.workItemDescription?.descriptionHtml; + } }, error() { this.$emit('error', i18n.fetchError); @@ -94,6 +102,9 @@ export default { canEdit() { return this.workItem?.userPermissions?.updateWorkItem || false; }, + hasConflicts() { + return Boolean(this.conflictedDescription); + }, tracking() { return { category: TRACKING_CATEGORY_SHOW, @@ -196,6 +207,7 @@ export default { this.isEditing = false; clearDraft(this.autosaveKey); + this.conflictedDescription = ''; } catch (error) { this.$emit('error', error.message); Sentry.captureException(error); @@ -224,7 +236,7 @@ export default { label-for="work-item-description" > <markdown-editor - v-if="glFeatures.workItemsMvc2" + v-if="glFeatures.workItemsMvc" class="gl-my-3 common-note-form" :value="descriptionText" :render-markdown-path="markdownPreviewPath" @@ -235,6 +247,7 @@ export default { form-field-name="work-item-description" enable-autocomplete init-on-autofocus + use-bottom-toolbar @input="setDescriptionText" @keydown.meta.enter="updateWorkItem" @keydown.ctrl.enter="updateWorkItem" @@ -246,7 +259,7 @@ export default { :is-submitting="isSubmitting" :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" - class="gl-p-3 bordered-box gl-mt-5" + class="gl-px-3 bordered-box gl-mt-5" > <template #textarea> <textarea @@ -267,17 +280,59 @@ export default { </template> </markdown-field> <div class="gl-display-flex"> - <gl-button - category="primary" - variant="confirm" - :loading="isSubmitting" - data-testid="save-description" - @click="updateWorkItem" - >{{ __('Save') }} - </gl-button> - <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing" - >{{ __('Cancel') }} - </gl-button> + <gl-alert + v-if="hasConflicts" + :dismissible="false" + variant="danger" + class="gl-w-full" + data-testid="work-item-description-conflicts" + > + <p> + {{ + s__( + "WorkItem|Someone edited the description at the same time you did. If you save it will overwrite their changes. Please confirm you'd like to save your edits.", + ) + }} + </p> + <details class="gl-mb-5"> + <summary class="gl-text-blue-500">{{ s__('WorkItem|View current version') }}</summary> + <textarea + class="note-textarea js-gfm-input js-autosize markdown-area gl-p-3" + readonly + :value="conflictedDescription" + ></textarea> + </details> + <template #actions> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + data-testid="save-description" + @click="updateWorkItem" + >{{ s__('WorkItem|Save and overwrite') }} + </gl-button> + <gl-button + category="secondary" + class="gl-ml-3" + data-testid="cancel" + @click="cancelEditing" + >{{ s__('WorkItem|Discard changes') }} + </gl-button> + </template> + </gl-alert> + <template v-else> + <gl-button + category="primary" + variant="confirm" + :loading="isSubmitting" + data-testid="save-description" + @click="updateWorkItem" + >{{ __('Save') }} + </gl-button> + <gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing" + >{{ __('Cancel') }} + </gl-button> + </template> </div> </gl-form-group> <work-item-description-rendered diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue index e6f8a301c5e..d58983c013d 100644 --- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue +++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue @@ -1,13 +1,14 @@ <script> -import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui'; -import $ from 'jquery'; -import '~/behaviors/markdown/render_gfm'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox'); export default { directives: { - SafeHtml: GlSafeHtmlDirective, + SafeHtml, + GlTooltip: GlTooltipDirective, }, components: { GlButton, @@ -45,7 +46,7 @@ export default { async renderGFM() { await this.$nextTick(); - $(this.$refs['gfm-content']).renderGFM(); + renderGFM(this.$refs['gfm-content']); if (this.canEdit) { this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox'); @@ -93,14 +94,16 @@ export default { <template> <div class="gl-mb-5 gl-border-t gl-pt-5"> - <div class="gl-display-inline-flex gl-align-items-center gl-mb-5"> + <div class="gl-display-inline-flex gl-align-items-center gl-mb-3"> <label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label> <gl-button v-if="canEdit" + v-gl-tooltip class="gl-ml-auto" icon="pencil" data-testid="edit-description" :aria-label="__('Edit description')" + :title="__('Edit description')" @click="$emit('startEditing')" /> </div> @@ -111,6 +114,7 @@ export default { ref="gfm-content" v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8" + data-testid="work-item-description" @change="toggleCheckboxes" ></div> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 7e9fa24e3f5..cb45a05de89 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,5 +1,6 @@ <script> import { isEmpty } from 'lodash'; +import { produce } from 'immer'; import { GlAlert, GlSkeletonLoader, @@ -11,10 +12,11 @@ import { GlEmptyState, } from '@gitlab/ui'; import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg'; +import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import { i18n, @@ -23,10 +25,14 @@ import { WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, + WIDGET_TYPE_PROGRESS, WIDGET_TYPE_HIERARCHY, - WORK_ITEM_VIEWED_STORAGE_KEY, WIDGET_TYPE_MILESTONE, WIDGET_TYPE_ITERATION, + WIDGET_TYPE_HEALTH_STATUS, + WORK_ITEM_TYPE_VALUE_ISSUE, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WIDGET_TYPE_NOTES, } from '../constants'; import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; @@ -37,6 +43,7 @@ import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; import { getWorkItemQuery } from '../utils'; +import WorkItemTree from './work_item_links/work_item_tree.vue'; import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; @@ -45,7 +52,7 @@ import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; import WorkItemMilestone from './work_item_milestone.vue'; -import WorkItemInformation from './work_item_information.vue'; +import WorkItemNotes from './work_item_notes.vue'; export default { i18n, @@ -68,11 +75,14 @@ export default { WorkItemTitle, WorkItemState, WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), - WorkItemInformation, - LocalStorageSync, + WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), WorkItemTypeIcon, WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), + WorkItemHealthStatus: () => + import('ee_component/work_items/components/work_item_health_status.vue'), WorkItemMilestone, + WorkItemTree, + WorkItemNotes, }, mixins: [glFeatureFlagMixin()], inject: ['fullPath'], @@ -87,7 +97,7 @@ export default { required: false, default: null, }, - iid: { + workItemIid: { type: String, required: false, default: null, @@ -103,7 +113,6 @@ export default { error: undefined, updateError: undefined, workItem: {}, - showInfoBanner: true, updateInProgress: false, }; }, @@ -201,17 +210,31 @@ export default { fullPath() { return this.workItem?.project.fullPath; }, + workItemsMvcEnabled() { + return this.glFeatures.workItemsMvc; + }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, parentWorkItem() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY)?.parent; }, + parentWorkItemType() { + return this.parentWorkItem?.workItemType?.name; + }, + parentWorkItemIconName() { + return this.parentWorkItem?.workItemType?.iconName; + }, parentWorkItemConfidentiality() { return this.parentWorkItem?.confidential; }, parentUrl() { - return `../../issues/${this.parentWorkItem?.iid}`; + // Once more types are moved to have Work Items involved + // we need to handle this properly. + if (this.parentWorkItemType === WORK_ITEM_TYPE_VALUE_ISSUE) { + return `../../issues/${this.parentWorkItem?.iid}`; + } + return this.parentWorkItem?.webUrl; }, workItemIconName() { return this.workItem?.workItemType?.iconName; @@ -234,41 +257,48 @@ export default { workItemWeight() { return this.isWidgetPresent(WIDGET_TYPE_WEIGHT); }, + workItemProgress() { + return this.isWidgetPresent(WIDGET_TYPE_PROGRESS); + }, workItemHierarchy() { return this.isWidgetPresent(WIDGET_TYPE_HIERARCHY); }, workItemIteration() { return this.isWidgetPresent(WIDGET_TYPE_ITERATION); }, + workItemHealthStatus() { + return this.isWidgetPresent(WIDGET_TYPE_HEALTH_STATUS); + }, workItemMilestone() { return this.isWidgetPresent(WIDGET_TYPE_MILESTONE); }, + workItemNotes() { + return this.isWidgetPresent(WIDGET_TYPE_NOTES); + }, fetchByIid() { - return this.glFeatures.useIidInWorkItemsPath && parseBoolean(this.$route.query.iid_path); + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); }, queryVariables() { return this.fetchByIid ? { fullPath: this.fullPath, - iid: this.iid, + iid: this.workItemIid, } : { id: this.workItemId, }; }, - }, - beforeDestroy() { - /** make sure that if the user has not even dismissed the alert , - * should no be able to see the information next time and update the local storage * */ - this.dismissBanner(); + children() { + const widgetHierarchy = this.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + return widgetHierarchy.children.nodes; + }, }, methods: { isWidgetPresent(type) { return this.workItem?.widgets?.find((widget) => widget.type === type); }, - dismissBanner() { - this.showInfoBanner = false; - }, toggleConfidentiality(confidentialStatus) { this.updateInProgress = true; let updateMutation = updateWorkItemMutation; @@ -321,8 +351,76 @@ export default { this.error = this.$options.i18n.fetchError; document.title = s__('404|Not found'); }, + addChild(child) { + const { defaultClient: client } = this.$apollo.provider.clients; + this.toggleChildFromCache(child, child.id, client); + }, + toggleChildFromCache(workItem, childId, store) { + const sourceData = store.readQuery({ + query: getWorkItemQuery(this.fetchByIid), + variables: this.queryVariables, + }); + + const newData = produce(sourceData, (draftState) => { + const widgetHierarchy = draftState.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + + const index = widgetHierarchy.children.nodes.findIndex((child) => child.id === childId); + + if (index >= 0) { + widgetHierarchy.children.nodes.splice(index, 1); + } else { + widgetHierarchy.children.nodes.unshift(workItem); + } + }); + + store.writeQuery({ + query: getWorkItemQuery(this.fetchByIid), + variables: this.queryVariables, + data: newData, + }); + }, + async updateWorkItem(workItem, childId, parentId) { + return this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId } } }, + update: (store) => this.toggleChildFromCache(workItem, childId, store), + }); + }, + async undoChildRemoval(workItem, childId) { + try { + const { data } = await this.updateWorkItem(workItem, childId, this.workItem.id); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast?.hide(); + } + } catch (error) { + this.updateError = s__('WorkItem|Something went wrong while undoing child removal.'); + Sentry.captureException(error); + } finally { + this.activeToast?.hide(); + } + }, + async removeChild(childId) { + try { + const { data } = await this.updateWorkItem(null, childId, null); + + if (data.workItemUpdate.errors.length === 0) { + this.activeToast = this.$toast.show(s__('WorkItem|Child removed'), { + action: { + text: s__('WorkItem|Undo'), + onClick: this.undoChildRemoval.bind(this, data.workItemUpdate.workItem, childId), + }, + }); + } + } catch (error) { + this.updateError = s__('WorkItem|Something went wrong while removing child.'); + Sentry.captureException(error); + } + }, }, - WORK_ITEM_VIEWED_STORAGE_KEY, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, }; </script> @@ -347,14 +445,14 @@ export default { <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body"> <ul v-if="parentWorkItem" - class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0" + class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0 gl-z-index-0" data-testid="work-item-parent" > <li class="gl-ml-n4 gl-display-flex gl-align-items-center gl-overflow-hidden"> <gl-button v-gl-tooltip.hover class="gl-text-truncate gl-max-w-full" - icon="issues" + :icon="parentWorkItemIconName" category="tertiary" :href="parentUrl" :title="parentWorkItem.title" @@ -411,16 +509,6 @@ export default { @click="$emit('close')" /> </div> - <local-storage-sync - v-model="showInfoBanner" - :storage-key="$options.WORK_ITEM_VIEWED_STORAGE_KEY" - > - <work-item-information - v-if="showInfoBanner && !error" - :show-info-banner="showInfoBanner" - @work-item-banner-dismissed="dismissBanner" - /> - </local-storage-sync> <work-item-title v-if="workItem.title" :work-item-id="workItem.id" @@ -465,19 +553,17 @@ export default { :work-item-type="workItemType" @error="updateError = $event" /> - <template v-if="workItemsMvc2Enabled"> - <work-item-milestone - v-if="workItemMilestone" - :work-item-id="workItem.id" - :work-item-milestone="workItemMilestone.milestone" - :work-item-type="workItemType" - :fetch-by-iid="fetchByIid" - :query-variables="queryVariables" - :can-update="canUpdate" - :full-path="fullPath" - @error="updateError = $event" - /> - </template> + <work-item-milestone + v-if="workItemMilestone" + :work-item-id="workItem.id" + :work-item-milestone="workItemMilestone.milestone" + :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" + :can-update="canUpdate" + :full-path="fullPath" + @error="updateError = $event" + /> <work-item-weight v-if="workItemWeight" class="gl-mb-5" @@ -489,20 +575,38 @@ export default { :query-variables="queryVariables" @error="updateError = $event" /> - <template v-if="workItemsMvc2Enabled"> - <work-item-iteration - v-if="workItemIteration" - class="gl-mb-5" - :iteration="workItemIteration.iteration" - :can-update="canUpdate" - :work-item-id="workItem.id" - :work-item-type="workItemType" - :fetch-by-iid="fetchByIid" - :query-variables="queryVariables" - :full-path="fullPath" - @error="updateError = $event" - /> - </template> + <work-item-progress + v-if="workItemProgress" + class="gl-mb-5" + :can-update="canUpdate" + :progress="workItemProgress.progress" + :work-item-id="workItem.id" + :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" + @error="updateError = $event" + /> + <work-item-iteration + v-if="workItemIteration" + class="gl-mb-5" + :iteration="workItemIteration.iteration" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" + :full-path="fullPath" + @error="updateError = $event" + /> + <work-item-health-status + v-if="workItemHealthStatus" + class="gl-mb-5" + :health-status="workItemHealthStatus.healthStatus" + :can-update="canUpdate" + :work-item-id="workItem.id" + :work-item-type="workItemType" + @error="updateError = $event" + /> <work-item-description v-if="hasDescriptionWidget" :work-item-id="workItem.id" @@ -512,6 +616,27 @@ export default { class="gl-pt-5" @error="updateError = $event" /> + <work-item-tree + v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" + :work-item-type="workItemType" + :work-item-id="workItem.id" + :children="children" + :can-update="canUpdate" + :project-path="fullPath" + @addWorkItemChild="addChild" + @removeChild="removeChild" + /> + <template v-if="workItemsMvc2Enabled"> + <work-item-notes + v-if="workItemNotes" + :work-item-id="workItem.id" + :query-variables="queryVariables" + :full-path="fullPath" + :fetch-by-iid="fetchByIid" + class="gl-pt-5" + @error="updateError = $event" + /> + </template> <gl-empty-state v-if="error" :title="$options.i18n.fetchErrorTitle" diff --git a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue index 39a662a6c54..e8726814aaf 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail_modal.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail_modal.vue @@ -20,6 +20,11 @@ export default { required: false, default: null, }, + workItemIid: { + type: String, + required: false, + default: null, + }, issueGid: { type: String, required: false, @@ -134,6 +139,7 @@ export default { size="lg" modal-id="work-item-detail-modal" header-class="gl-p-0 gl-pb-2!" + scrollable @hide="closeModal" > <gl-alert v-if="error" variant="danger" @dismiss="error = false"> @@ -144,6 +150,7 @@ export default { is-modal :work-item-parent-id="issueGid" :work-item-id="workItemId" + :work-item-iid="workItemIid" class="gl-p-5 gl-mt-n3" @close="hide" @deleteWorkItem="deleteWorkItem" diff --git a/app/assets/javascripts/work_items/components/work_item_information.vue b/app/assets/javascripts/work_items/components/work_item_information.vue deleted file mode 100644 index ce75cc98a75..00000000000 --- a/app/assets/javascripts/work_items/components/work_item_information.vue +++ /dev/null @@ -1,53 +0,0 @@ -<script> -import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { helpPagePath } from '~/helpers/help_page_helper'; - -export default { - i18n: { - learnTasksLinkText: s__('WorkItem|Learn about tasks.'), - tasksInformationTitle: s__('WorkItem|Introducing tasks'), - tasksInformationBody: s__( - 'WorkItem|Use tasks to break down your work in an issue into smaller pieces. %{learnMoreLink}', - ), - }, - helpPageLinks: { - tasksDocLinkPath: helpPagePath('user/tasks'), - }, - components: { - GlAlert, - GlSprintf, - GlLink, - }, - props: { - showInfoBanner: { - type: Boolean, - required: false, - default: true, - }, - }, - emits: ['work-item-banner-dismissed'], -}; -</script> - -<template> - <section class="gl-display-block gl-mb-2"> - <gl-alert - v-if="showInfoBanner" - variant="tip" - :title="$options.i18n.tasksInformationTitle" - data-testid="work-item-information" - class="gl-mt-3" - @dismiss="$emit('work-item-banner-dismissed')" - > - <gl-sprintf :message="$options.i18n.tasksInformationBody"> - <template #learnMoreLink> - <gl-link :href="$options.helpPageLinks.tasksDocLinkPath">{{ - $options.i18n.learnTasksLinkText - }}</gl-link> - </template> - ></gl-sprintf - > - </gl-alert> - </section> -</template> diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 22af3c653e9..45fb0f7f21a 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -3,8 +3,8 @@ import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui'; import { debounce, uniqueId, without } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; -import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; -import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; +import labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; @@ -83,7 +83,7 @@ export default { return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; }, skip() { - return !this.workItemId; + return !this.queryVariables.id && !this.queryVariables.iid; }, error() { this.$emit('error', i18n.fetchError); diff --git a/app/assets/javascripts/work_items/components/work_item_links/index.js b/app/assets/javascripts/work_items/components/work_item_links/index.js index 0251dcc33fa..edad0e9b616 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/index.js +++ b/app/assets/javascripts/work_items/components/work_item_links/index.js @@ -17,6 +17,7 @@ export default function initWorkItemLinks() { wiHasIssueWeightsFeature, iid, wiHasIterationsFeature, + wiHasIssuableHealthStatusFeature, } = workItemLinksRoot.dataset; // eslint-disable-next-line no-new @@ -33,6 +34,7 @@ export default function initWorkItemLinks() { fullPath: projectPath, hasIssueWeightsFeature: wiHasIssueWeightsFeature, hasIterationsFeature: wiHasIterationsFeature, + hasIssuableHealthStatusFeature: wiHasIssuableHealthStatusFeature, }, render: (createElement) => createElement('work-item-links', { diff --git a/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue new file mode 100644 index 00000000000..dc5bcdc3dcc --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/okr_actions_split_button.vue @@ -0,0 +1,66 @@ +<script> +import { GlDropdown, GlDropdownSectionHeader, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; + +import { s__ } from '~/locale'; + +const objectiveActionItems = [ + { + title: s__('OKR|New objective'), + eventName: 'showCreateObjectiveForm', + }, + { + title: s__('OKR|Existing objective'), + eventName: 'showAddObjectiveForm', + }, +]; + +const keyResultActionItems = [ + { + title: s__('OKR|New key result'), + eventName: 'showCreateKeyResultForm', + }, + { + title: s__('OKR|Existing key result'), + eventName: 'showAddKeyResultForm', + }, +]; + +export default { + keyResultActionItems, + objectiveActionItems, + components: { + GlDropdown, + GlDropdownSectionHeader, + GlDropdownItem, + GlDropdownDivider, + }, + methods: { + change({ eventName }) { + this.$emit(eventName); + }, + }, +}; +</script> + +<template> + <gl-dropdown :text="__('Add')" size="small" right> + <gl-dropdown-section-header>{{ __('Objective') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in $options.objectiveActionItems" + :key="item.eventName" + @click="change(item)" + > + {{ item.title }} + </gl-dropdown-item> + + <gl-dropdown-divider /> + <gl-dropdown-section-header>{{ __('Key result') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="item in $options.keyResultActionItems" + :key="item.eventName" + @click="change(item)" + > + {{ item.title }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index 34874908f9b..763f2f338a3 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -1,19 +1,35 @@ <script> -import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; -import { STATE_OPEN } from '../../constants'; +import { + STATE_OPEN, + TASK_TYPE_NAME, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WIDGET_TYPE_MILESTONE, + WIDGET_TYPE_HIERARCHY, + WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, + WORK_ITEM_NAME_TO_ICON_MAP, +} from '../../constants'; +import getWorkItemTreeQuery from '../../graphql/work_item_tree.query.graphql'; +import WorkItemLinkChildMetadata from './work_item_link_child_metadata.vue'; import WorkItemLinksMenu from './work_item_links_menu.vue'; +import WorkItemTreeChildren from './work_item_tree_children.vue'; export default { components: { + GlLink, GlButton, GlIcon, RichTimestampTooltip, + WorkItemLinkChildMetadata, WorkItemLinksMenu, + WorkItemTreeChildren, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,16 +51,48 @@ export default { type: Object, required: true, }, + hasIndirectChildren: { + type: Boolean, + required: false, + default: true, + }, + workItemType: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isExpanded: false, + children: [], + isLoadingChildren: false, + }; }, computed: { + canHaveChildren() { + return this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE; + }, + allowsScopedLabels() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.allowsScopedLabels; + }, isItemOpen() { return this.childItem.state === STATE_OPEN; }, - iconClass() { - return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + childItemType() { + return this.childItem.workItemType.name; }, iconName() { - return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + if (this.childItemType === TASK_TYPE_NAME) { + return this.isItemOpen ? 'issue-open-m' : 'issue-close'; + } + return WORK_ITEM_NAME_TO_ICON_MAP[this.childItemType]; + }, + iconClass() { + if (this.childItemType === TASK_TYPE_NAME) { + return this.isItemOpen ? 'gl-text-green-500' : 'gl-text-blue-500'; + } + return ''; }, stateTimestamp() { return this.isItemOpen ? this.childItem.createdAt : this.childItem.closedAt; @@ -55,55 +103,161 @@ export default { childPath() { return `/${this.projectPath}/-/work_items/${getIdFromGraphQLId(this.childItem.id)}`; }, + hasChildren() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_HIERARCHY)?.hasChildren; + }, + chevronType() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + chevronTooltip() { + return this.isExpanded ? __('Collapse') : __('Expand'); + }, + hasMetadata() { + return this.milestone || this.assignees.length > 0 || this.labels.length > 0; + }, + milestone() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_MILESTONE)?.milestone; + }, + assignees() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_ASSIGNEES)?.assignees?.nodes || []; + }, + labels() { + return this.getWidgetByType(this.childItem, WIDGET_TYPE_LABELS)?.labels?.nodes || []; + }, + }, + methods: { + toggleItem() { + this.isExpanded = !this.isExpanded; + if (this.children.length === 0 && this.hasChildren) { + this.fetchChildren(); + } + }, + getWidgetByType(workItem, widgetType) { + return workItem?.widgets?.find((widget) => widget.type === widgetType); + }, + async fetchChildren() { + this.isLoadingChildren = true; + try { + const { data } = await this.$apollo.query({ + query: getWorkItemTreeQuery, + variables: { + id: this.childItem.id, + }, + }); + this.children = this.getWidgetByType(data?.workItem, WIDGET_TYPE_HIERARCHY).children.nodes; + } catch (error) { + this.isExpanded = !this.isExpanded; + createAlert({ + message: s__('Hierarchy|Something went wrong while fetching children.'), + captureError: true, + error, + }); + } finally { + this.isLoadingChildren = false; + } + }, }, }; </script> <template> - <div - class="gl-relative gl-display-flex gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" - data-testid="links-child" - > - <div class="gl-overflow-hidden gl-display-flex gl-align-items-center gl-flex-grow-1"> - <span :id="`stateIcon-${childItem.id}`" class="gl-mr-3" data-testid="item-status-icon"> - <gl-icon :name="iconName" :class="iconClass" :aria-label="stateTimestampTypeText" /> - </span> - <rich-timestamp-tooltip - :target="`stateIcon-${childItem.id}`" - :raw-timestamp="stateTimestamp" - :timestamp-type-text="stateTimestampTypeText" - /> - <gl-icon - v-if="childItem.confidential" - v-gl-tooltip.top - name="eye-slash" - class="gl-mr-2 gl-text-orange-500" - data-testid="confidential-icon" - :aria-label="__('Confidential')" - :title="__('Confidential')" - /> - <gl-button - :href="childPath" - category="tertiary" - variant="link" - class="gl-text-truncate gl-max-w-80 gl-text-black-normal!" - @click="$emit('click', childItem.id, $event)" - @mouseover="$emit('mouseover', childItem.id, $event)" - @mouseout="$emit('mouseout', childItem.id, $event)" - > - {{ childItem.title }} - </gl-button> - </div> + <div> <div - v-if="canUpdate" - class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + class="gl-display-flex gl-align-items-flex-start gl-mb-3" + :class="{ 'gl-ml-6': canHaveChildren && !hasChildren && hasIndirectChildren }" > - <work-item-links-menu - :work-item-id="childItem.id" - :parent-work-item-id="issuableGid" - data-testid="links-menu" - @removeChild="$emit('remove', childItem.id)" + <gl-button + v-if="hasChildren" + v-gl-tooltip.viewport + :title="chevronTooltip" + :aria-label="chevronTooltip" + :icon="chevronType" + category="tertiary" + :loading="isLoadingChildren" + class="gl-px-0! gl-py-3! gl-mr-3" + data-testid="expand-child" + @click="toggleItem" /> + <div + class="gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-bg-white gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32" + data-testid="links-child" + > + <span + :id="`stateIcon-${childItem.id}`" + class="gl-mr-3" + :class="{ 'gl-display-flex': hasMetadata }" + data-testid="item-status-icon" + > + <gl-icon + class="gl-text-secondary" + :class="iconClass" + :name="iconName" + :aria-label="stateTimestampTypeText" + /> + </span> + <div + class="gl-display-flex gl-flex-grow-1" + :class="{ + 'gl-flex-direction-column gl-align-items-flex-start': hasMetadata, + 'gl-align-items-center': !hasMetadata, + }" + > + <div class="gl-display-flex"> + <rich-timestamp-tooltip + :target="`stateIcon-${childItem.id}`" + :raw-timestamp="stateTimestamp" + :timestamp-type-text="stateTimestampTypeText" + /> + <gl-icon + v-if="childItem.confidential" + v-gl-tooltip.top + name="eye-slash" + class="gl-mr-2 gl-text-orange-500" + data-testid="confidential-icon" + :aria-label="__('Confidential')" + :title="__('Confidential')" + /> + <gl-link + :href="childPath" + class="gl-overflow-wrap-break gl-line-height-normal gl-text-black-normal! gl-font-weight-bold" + data-testid="item-title" + @click="$emit('click', $event)" + @mouseover="$emit('mouseover')" + @mouseout="$emit('mouseout')" + > + {{ childItem.title }} + </gl-link> + </div> + <work-item-link-child-metadata + v-if="hasMetadata" + :allows-scoped-labels="allowsScopedLabels" + :milestone="milestone" + :assignees="assignees" + :labels="labels" + class="gl-mt-3" + /> + </div> + <div + v-if="canUpdate" + class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex gl-align-items-center" + > + <work-item-links-menu + :work-item-id="childItem.id" + :parent-work-item-id="issuableGid" + data-testid="links-menu" + @removeChild="$emit('removeChild', childItem.id)" + /> + </div> + </div> </div> + <work-item-tree-children + v-if="isExpanded" + :project-path="projectPath" + :can-update="canUpdate" + :work-item-id="issuableGid" + :work-item-type="workItemType" + :children="children" + @removeChild="fetchChildren" + /> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue new file mode 100644 index 00000000000..7be7e1f3496 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child_metadata.vue @@ -0,0 +1,123 @@ +<script> +import { GlLabel, GlAvatar, GlAvatarLink, GlAvatarsInline, GlTooltipDirective } from '@gitlab/ui'; + +import { s__, sprintf } from '~/locale'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +import ItemMilestone from '~/issuable/components/issue_milestone.vue'; + +export default { + components: { + GlLabel, + GlAvatar, + GlAvatarLink, + GlAvatarsInline, + ItemMilestone, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + allowsScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + milestone: { + type: Object, + required: false, + default: null, + }, + assignees: { + type: Array, + required: false, + default: () => [], + }, + labels: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + assigneesCollapsedTooltip() { + if (this.assignees.length > 2) { + return sprintf(s__('WorkItem|%{count} more assignees'), { + count: this.assignees.length - 2, + }); + } + return ''; + }, + assigneesContainerClass() { + if (this.assignees.length === 2) { + return 'fixed-width-avatars-2'; + } else if (this.assignees.length > 2) { + return 'fixed-width-avatars-3'; + } + return ''; + }, + labelsContainerClass() { + if (this.milestone || this.assignees.length) { + return 'gl-sm-ml-5'; + } + return ''; + }, + }, + methods: { + showScopedLabel(label) { + return isScopedLabel(label) && this.allowsScopedLabels; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-wrap gl-align-items-center"> + <item-milestone + v-if="milestone" + :milestone="milestone" + class="gl-display-flex gl-align-items-center gl-mr-5 gl-max-w-15 gl-text-secondary! gl-cursor-help! gl-text-decoration-none!" + /> + <gl-avatars-inline + v-if="assignees.length" + :avatars="assignees" + :collapsed="true" + :max-visible="2" + :avatar-size="24" + badge-tooltip-prop="name" + :badge-sr-only-text="assigneesCollapsedTooltip" + :class="assigneesContainerClass" + > + <template #avatar="{ avatar }"> + <gl-avatar-link v-gl-tooltip target="blank" :href="avatar.webUrl" :title="avatar.name"> + <gl-avatar :src="avatar.avatarUrl" :size="24" /> + </gl-avatar-link> + </template> + </gl-avatars-inline> + <div v-if="labels.length" class="gl-display-flex gl-flex-wrap" :class="labelsContainerClass"> + <gl-label + v-for="label in labels" + :key="label.id" + :title="label.title" + :background-color="label.color" + :description="label.description" + :scoped="showScopedLabel(label)" + class="gl-mt-2 gl-sm-mt-0 gl-mr-2 gl-mb-auto gl-label-sm" + tooltip-placement="top" + /> + </div> + </div> +</template> + +<style scoped> +/** + * These overrides are needed to address https://gitlab.com/gitlab-org/gitlab-ui/-/issues/865 + */ +.fixed-width-avatars-2 { + width: 42px !important; +} + +.fixed-width-avatars-3 { + width: 67px !important; +} +</style> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 3d469b790a1..faadb5fa6fa 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -9,13 +9,15 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { produce } from 'immer'; +import { isEmpty } from 'lodash'; import { s__ } from '~/locale'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getIssueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; -import { isMetaKey } from '~/lib/utils/common_utils'; -import { setUrlParams, updateHistory } from '~/lib/utils/url_utility'; +import { isMetaKey, parseBoolean } from '~/lib/utils/common_utils'; +import { setUrlParams, updateHistory, getParameterByName } from '~/lib/utils/url_utility'; import { FORM_TYPES, @@ -26,6 +28,7 @@ import { import getWorkItemLinksQuery from '../../graphql/work_item_links.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import workItemQuery from '../../graphql/work_item.query.graphql'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import WorkItemDetailModal from '../work_item_detail_modal.vue'; import WorkItemLinkChild from './work_item_link_child.vue'; import WorkItemLinksForm from './work_item_links_form.vue'; @@ -45,6 +48,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], inject: ['projectPath', 'iid'], props: { workItemId: { @@ -72,6 +76,18 @@ export default { error(e) { this.error = e.message || this.$options.i18n.fetchError; }, + async result() { + const { id, iid } = this.childUrlParams; + this.activeChild = this.fetchByIid + ? this.children.find((child) => child.iid === iid) ?? {} + : this.children.find((child) => child.id === id) ?? {}; + await this.$nextTick(); + if (!isEmpty(this.activeChild)) { + this.$refs.modal.show(); + return; + } + this.updateWorkItemIdUrlQuery(); + }, }, parentIssue: { query: getIssueDetailsQuery, @@ -90,7 +106,7 @@ export default { return { isShownAddForm: false, isOpen: true, - activeChildId: null, + activeChild: {}, activeToast: null, prefetchedWorkItem: null, error: undefined, @@ -139,6 +155,29 @@ export default { childrenCountLabel() { return this.isLoading && this.children.length === 0 ? '...' : this.children.length; }, + fetchByIid() { + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); + }, + childUrlParams() { + const params = {}; + if (this.fetchByIid) { + const iid = getParameterByName('work_item_iid'); + if (iid) { + params.iid = iid; + } + } else { + const workItemId = getParameterByName('work_item_id'); + if (workItemId) { + params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + } + } + return params; + }, + }, + mounted() { + if (!isEmpty(this.childUrlParams)) { + this.addWorkItemQuery(this.childUrlParams); + } }, methods: { toggle() { @@ -159,29 +198,29 @@ export default { const { defaultClient: client } = this.$apollo.provider.clients; this.toggleChildFromCache(child, child.id, client); }, - openChild(childItemId, e) { + openChild(child, e) { if (isMetaKey(e)) { return; } e.preventDefault(); - this.activeChildId = childItemId; + this.activeChild = child; this.$refs.modal.show(); - this.updateWorkItemIdUrlQuery(childItemId); + this.updateWorkItemIdUrlQuery(child); }, - closeModal() { - this.activeChildId = null; - this.updateWorkItemIdUrlQuery(undefined); + async closeModal() { + this.activeChild = {}; + this.updateWorkItemIdUrlQuery(); }, handleWorkItemDeleted(childId) { const { defaultClient: client } = this.$apollo.provider.clients; this.toggleChildFromCache(null, childId, client); this.activeToast = this.$toast.show(s__('WorkItem|Task deleted')); }, - updateWorkItemIdUrlQuery(childItemId) { - updateHistory({ - url: setUrlParams({ work_item_id: getIdFromGraphQLId(childItemId) }), - replace: true, - }); + updateWorkItemIdUrlQuery({ id, iid } = {}) { + const params = this.fetchByIid + ? { work_item_iid: iid } + : { work_item_id: getIdFromGraphQLId(id) }; + updateHistory({ url: setUrlParams(params), replace: true }); }, toggleChildFromCache(workItem, childId, store) { const sourceData = store.readQuery({ @@ -235,16 +274,31 @@ export default { }); } }, - prefetchWorkItem(id) { + addWorkItemQuery({ id, iid }) { + const variables = this.fetchByIid + ? { + fullPath: this.projectPath, + iid, + } + : { + id, + }; + this.$apollo.addSmartQuery('prefetchedWorkItem', { + query() { + return this.fetchByIid ? workItemByIidQuery : workItemQuery; + }, + variables, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + context: { + isSingleRequest: true, + }, + }); + }, + prefetchWorkItem({ id, iid }) { this.prefetch = setTimeout( - () => - this.$apollo.addSmartQuery('prefetchedWorkItem', { - query: workItemQuery, - variables: { - id, - }, - update: (data) => data.workItem, - }), + () => this.addWorkItemQuery({ id, iid }), DEFAULT_DEBOUNCE_AND_THROTTLE_MS, ); }, @@ -355,16 +409,17 @@ export default { :can-update="canUpdate" :issuable-gid="issuableGid" :child-item="child" - @click="openChild" - @mouseover="prefetchWorkItem" + @click="openChild(child, $event)" + @mouseover="prefetchWorkItem(child)" @mouseout="clearPrefetching" - @remove="removeChild" + @removeChild="removeChild" /> <work-item-detail-modal ref="modal" - :work-item-id="activeChildId" + :work-item-id="activeChild.id" + :work-item-iid="activeChild.iid" @close="closeModal" - @workItemDeleted="handleWorkItemDeleted(activeChildId)" + @workItemDeleted="handleWorkItemDeleted(activeChild.id)" /> </template> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 095ea86e0d8..5cf0c4154bb 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -9,7 +9,16 @@ import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_ty import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; import createWorkItemMutation from '../../graphql/create_work_item.mutation.graphql'; -import { FORM_TYPES, TASK_TYPE_NAME } from '../../constants'; +import { + FORM_TYPES, + WORK_ITEMS_TYPE_MAP, + WORK_ITEM_TYPE_ENUM_TASK, + I18N_WORK_ITEM_CREATE_BUTTON_LABEL, + I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, + I18N_WORK_ITEM_ADD_BUTTON_LABEL, + I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, + sprintfWorkItem, +} from '../../constants'; export default { components: { @@ -52,6 +61,11 @@ export default { type: String, required: true, }, + childrenType: { + type: String, + required: false, + default: WORK_ITEM_TYPE_ENUM_TASK, + }, }, apollo: { workItemTypes: { @@ -71,7 +85,7 @@ export default { return { projectPath: this.projectPath, searchTerm: this.search?.title || this.search, - types: ['TASK'], + types: [this.childrenType], in: this.search ? 'TITLE' : undefined, }; }, @@ -79,7 +93,9 @@ export default { return !this.searchStarted; }, update(data) { - return data.workspace.workItems.nodes.filter((wi) => !this.childrenIds.includes(wi.id)); + return data.workspace.workItems.nodes.filter( + (wi) => !this.childrenIds.includes(wi.id) && this.issuableGid !== wi.id, + ); }, }, }, @@ -99,14 +115,14 @@ export default { let workItemInput = { title: this.search?.title || this.search, projectPath: this.projectPath, - workItemTypeId: this.taskWorkItemType, + workItemTypeId: this.childWorkItemType, hierarchyWidget: { parentId: this.issuableGid, }, confidential: this.parentConfidential, }; - if (this.associateMilestone) { + if (this.parentMilestoneId) { workItemInput = { ...workItemInput, milestoneWidget: { @@ -114,46 +130,62 @@ export default { }, }; } + + if (this.associateIteration) { + workItemInput = { + ...workItemInput, + iterationWidget: { + iterationId: this.parentIterationId, + }, + }; + } + return workItemInput; }, + workItemsMvcEnabled() { + return this.glFeatures.workItemsMvc; + }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; }, isCreateForm() { return this.formType === FORM_TYPES.create; }, + childrenTypeName() { + return WORK_ITEMS_TYPE_MAP[this.childrenType]?.name; + }, addOrCreateButtonLabel() { if (this.isCreateForm) { - return this.$options.i18n.createChildOptionLabel; + return sprintfWorkItem(I18N_WORK_ITEM_CREATE_BUTTON_LABEL, this.childrenTypeName); } else if (this.workItemsToAdd.length > 1) { - return this.$options.i18n.addTasksButtonLabel; + return sprintfWorkItem(I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, this.childrenTypeName); } - return this.$options.i18n.addTaskButtonLabel; + return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName); }, addOrCreateMethod() { return this.isCreateForm ? this.createChild : this.addChild; }, - taskWorkItemType() { - return this.workItemTypes.find((type) => type.name === TASK_TYPE_NAME)?.id; + childWorkItemType() { + return this.workItemTypes.find((type) => type.name === this.childrenTypeName)?.id; }, parentIterationId() { return this.parentIteration?.id; }, associateIteration() { - return this.parentIterationId && this.hasIterationsFeature && this.workItemsMvc2Enabled; + return this.parentIterationId && this.hasIterationsFeature; }, parentMilestoneId() { return this.parentMilestone?.id; }, - associateMilestone() { - return this.parentMilestoneId && this.workItemsMvc2Enabled; - }, isSubmitButtonDisabled() { return this.isCreateForm ? this.search.length === 0 : this.workItemsToAdd.length === 0; }, isLoading() { return this.$apollo.queries.availableWorkItems.loading; }, + addInputPlaceholder() { + return sprintfWorkItem(I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, this.childrenTypeName); + }, }, created() { this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); @@ -206,13 +238,6 @@ export default { } else { this.unsetError(); this.$emit('addWorkItemChild', data.workItemCreate.workItem); - /** - * call update mutation only when there is an iteration associated with the issue - */ - // TODO: setting the iteration should be moved to the creation mutation once the backend is done - if (this.associateIteration) { - this.addIterationToWorkItem(data.workItemCreate.workItem.id); - } } }) .catch(() => { @@ -223,19 +248,6 @@ export default { this.childToCreateTitle = null; }); }, - async addIterationToWorkItem(workItemId) { - await this.$apollo.mutate({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: workItemId, - iterationWidget: { - iterationId: this.parentIterationId, - }, - }, - }, - }); - }, setSearchKey(value) { this.search = value; }, @@ -253,17 +265,13 @@ export default { }, i18n: { inputLabel: __('Title'), - addTaskButtonLabel: s__('WorkItem|Add task'), - addTasksButtonLabel: s__('WorkItem|Add tasks'), addChildErrorMessage: s__( 'WorkItem|Something went wrong when trying to add a child. Please try again.', ), - createChildOptionLabel: s__('WorkItem|Create task'), createChildErrorMessage: s__( 'WorkItem|Something went wrong when trying to create a child. Please try again.', ), createPlaceholder: s__('WorkItem|Add a title'), - addPlaceholder: s__('WorkItem|Search existing tasks'), fieldValidationMessage: __('Maximum of 255 characters'), }, }; @@ -296,7 +304,7 @@ export default { v-model="workItemsToAdd" :dropdown-items="availableWorkItems" :loading="isLoading" - :placeholder="$options.i18n.addPlaceholder" + :placeholder="addInputPlaceholder" menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" class="gl-mb-4" data-testid="work-item-token-select-input" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue new file mode 100644 index 00000000000..f06de2ca048 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -0,0 +1,244 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { __ } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +import { + FORM_TYPES, + WIDGET_TYPE_HIERARCHY, + WORK_ITEMS_TREE_TEXT_MAP, + WORK_ITEM_TYPE_ENUM_OBJECTIVE, + WORK_ITEM_TYPE_ENUM_KEY_RESULT, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, +} from '../../constants'; +import workItemQuery from '../../graphql/work_item.query.graphql'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import OkrActionsSplitButton from './okr_actions_split_button.vue'; +import WorkItemLinksForm from './work_item_links_form.vue'; +import WorkItemLinkChild from './work_item_link_child.vue'; + +export default { + FORM_TYPES, + WORK_ITEMS_TREE_TEXT_MAP, + WORK_ITEM_TYPE_ENUM_OBJECTIVE, + WORK_ITEM_TYPE_ENUM_KEY_RESULT, + components: { + GlButton, + OkrActionsSplitButton, + WorkItemLinksForm, + WorkItemLinkChild, + }, + mixins: [glFeatureFlagMixin()], + props: { + workItemType: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + children: { + type: Array, + required: false, + default: () => [], + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + isShownAddForm: false, + isOpen: true, + error: null, + formType: null, + childType: null, + prefetchedWorkItem: null, + }; + }, + computed: { + toggleIcon() { + return this.isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; + }, + toggleLabel() { + return this.isOpen ? __('Collapse') : __('Expand'); + }, + fetchByIid() { + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(getParameterByName('iid_path')); + }, + childrenIds() { + return this.children.map((c) => c.id); + }, + hasIndirectChildren() { + return this.children + .map( + (child) => child.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY) || {}, + ) + .some((hierarchy) => hierarchy.hasChildren); + }, + childUrlParams() { + const params = {}; + if (this.fetchByIid) { + const iid = getParameterByName('work_item_iid'); + if (iid) { + params.iid = iid; + } + } else { + const workItemId = getParameterByName('work_item_id'); + if (workItemId) { + params.id = convertToGraphQLId(TYPE_WORK_ITEM, workItemId); + } + } + return params; + }, + }, + mounted() { + if (!isEmpty(this.childUrlParams)) { + this.addWorkItemQuery(this.childUrlParams); + } + }, + methods: { + toggle() { + this.isOpen = !this.isOpen; + }, + showAddForm(formType, childType) { + this.isOpen = true; + this.isShownAddForm = true; + this.formType = formType; + this.childType = childType; + this.$nextTick(() => { + this.$refs.wiLinksForm.$refs.wiTitleInput?.$el.focus(); + }); + }, + hideAddForm() { + this.isShownAddForm = false; + }, + addWorkItemQuery({ id, iid }) { + const variables = this.fetchByIid + ? { + fullPath: this.projectPath, + iid, + } + : { + id, + }; + this.$apollo.addSmartQuery('prefetchedWorkItem', { + query() { + return this.fetchByIid ? workItemByIidQuery : workItemQuery; + }, + variables, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + }, + context: { + isSingleRequest: true, + }, + }); + }, + prefetchWorkItem({ id, iid }) { + if (this.workItemType !== WORK_ITEM_TYPE_VALUE_OBJECTIVE) { + this.prefetch = setTimeout( + () => this.addWorkItemQuery({ id, iid }), + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + ); + } + }, + clearPrefetching() { + if (this.prefetch) { + clearTimeout(this.prefetch); + } + }, + }, +}; +</script> + +<template> + <div + class="gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-bg-gray-10 gl-mt-4" + data-testid="work-item-tree" + > + <div + class="gl-px-5 gl-py-3 gl-display-flex gl-justify-content-space-between" + :class="{ 'gl-border-b-1 gl-border-b-solid gl-border-b-gray-100': isOpen }" + > + <div class="gl-display-flex gl-flex-grow-1"> + <h5 class="gl-m-0 gl-line-height-24"> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} + </h5> + </div> + <okr-actions-split-button + @showCreateObjectiveForm=" + showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) + " + @showAddObjectiveForm=" + showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_OBJECTIVE) + " + @showCreateKeyResultForm=" + showAddForm($options.FORM_TYPES.create, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) + " + @showAddKeyResultForm=" + showAddForm($options.FORM_TYPES.add, $options.WORK_ITEM_TYPE_ENUM_KEY_RESULT) + " + /> + <div class="gl-border-l-1 gl-border-l-solid gl-border-l-gray-100 gl-pl-3 gl-ml-3"> + <gl-button + category="tertiary" + size="small" + :icon="toggleIcon" + :aria-label="toggleLabel" + data-testid="toggle-tree" + @click="toggle" + /> + </div> + </div> + <div + v-if="isOpen" + class="gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + :class="{ 'gl-p-5 gl-pb-3': !error }" + data-testid="tree-body" + > + <div v-if="!isShownAddForm && !error && children.length === 0" data-testid="tree-empty"> + <p class="gl-mb-3"> + {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].empty }} + </p> + </div> + <work-item-links-form + v-if="isShownAddForm" + ref="wiLinksForm" + data-testid="add-tree-form" + :issuable-gid="workItemId" + :form-type="formType" + :children-type="childType" + :children-ids="childrenIds" + @addWorkItemChild="$emit('addWorkItemChild', $event)" + @cancel="hideAddForm" + /> + <work-item-link-child + v-for="child in children" + :key="child.id" + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="workItemId" + :child-item="child" + :work-item-type="workItemType" + :has-indirect-children="hasIndirectChildren" + @mouseover="prefetchWorkItem(child)" + @mouseout="clearPrefetching" + @removeChild="$emit('removeChild', $event)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue new file mode 100644 index 00000000000..911cac4de88 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue @@ -0,0 +1,68 @@ +<script> +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; + +import updateWorkItemMutation from '../../graphql/update_work_item.mutation.graphql'; + +export default { + components: { + WorkItemLinkChild: () => import('./work_item_link_child.vue'), + }, + props: { + workItemType: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + children: { + type: Array, + required: false, + default: () => [], + }, + canUpdate: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, + }, + methods: { + async updateWorkItem(childId) { + try { + await this.$apollo.mutate({ + mutation: updateWorkItemMutation, + variables: { input: { id: childId, hierarchyWidget: { parentId: null } } }, + }); + this.$emit('removeChild'); + } catch (error) { + createAlert({ + message: s__('Hierarchy|Something went wrong while removing a child item.'), + captureError: true, + error, + }); + } + }, + }, +}; +</script> + +<template> + <div class="gl-ml-6"> + <work-item-link-child + v-for="child in children" + :key="child.id" + :project-path="projectPath" + :can-update="canUpdate" + :issuable-gid="workItemId" + :child-item="child" + :work-item-type="workItemType" + @removeChild="updateWorkItem" + /> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue index a8d3b57aae0..6ed230b8ad4 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -13,6 +13,7 @@ import { debounce } from 'lodash'; import Tracking from '~/tracking'; import { s__, __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { MILESTONE_STATE } from '~/sidebar/constants'; import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import { @@ -118,6 +119,7 @@ export default { return { 'gl-text-gray-500!': this.canUpdate && this.isNoMilestone, 'is-not-focused': !this.isFocused, + 'gl-min-w-20': true, }; }, }, @@ -139,6 +141,7 @@ export default { return { fullPath: this.fullPath, title: this.searchTerm, + state: MILESTONE_STATE.ACTIVE, first: 20, }; }, @@ -214,9 +217,10 @@ export default { <template> <gl-form-group - class="work-item-dropdown" + class="work-item-dropdown gl-flex-nowrap" :label="$options.i18n.MILESTONE" - label-class="gl-pb-0! gl-overflow-wrap-break gl-mt-3" + label-for="milestone-value" + label-class="gl-pb-0! gl-mt-3 gl-overflow-wrap-break" label-cols="3" label-cols-lg="2" > @@ -229,6 +233,8 @@ export default { </span> <gl-dropdown v-else + id="milestone-value" + class="gl-pl-0 gl-max-w-full" :toggle-class="dropdownClasses" :text="dropdownText" :loading="updateInProgress" diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue new file mode 100644 index 00000000000..91e90589a93 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -0,0 +1,109 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import SystemNote from '~/work_items/components/notes/system_note.vue'; +import { i18n, DEFAULT_PAGE_SIZE_NOTES } from '~/work_items/constants'; +import { getWorkItemNotesQuery } from '~/work_items/utils'; + +export default { + i18n: { + ACTIVITY_LABEL: s__('WorkItem|Activity'), + }, + loader: { + repeat: 10, + width: 1000, + height: 40, + }, + components: { + SystemNote, + GlSkeletonLoader, + }, + props: { + workItemId: { + type: String, + required: true, + }, + queryVariables: { + type: Object, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + areNotesLoading() { + return this.$apollo.queries.workItemNotes.loading; + }, + notes() { + return this.workItemNotes?.nodes; + }, + pageInfo() { + return this.workItemNotes?.pageInfo; + }, + }, + apollo: { + workItemNotes: { + query() { + return getWorkItemNotesQuery(this.fetchByIid); + }, + context: { + isSingleRequest: true, + }, + variables() { + return { + ...this.queryVariables, + pageSize: DEFAULT_PAGE_SIZE_NOTES, + }; + }, + update(data) { + const workItemWidgets = this.fetchByIid + ? data.workspace?.workItems?.nodes[0]?.widgets + : data.workItem?.widgets; + return workItemWidgets.find((widget) => widget.type === 'NOTES').discussions || []; + }, + skip() { + return !this.queryVariables.id && !this.queryVariables.iid; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, +}; +</script> + +<template> + <div class="gl-border-t gl-mt-5"> + <label class="gl-mb-0">{{ $options.i18n.ACTIVITY_LABEL }}</label> + <div v-if="areNotesLoading" class="gl-mt-5"> + <gl-skeleton-loader + v-for="index in $options.loader.repeat" + :key="index" + :width="$options.loader.width" + :height="$options.loader.height" + preserve-aspect-ratio="xMinYMax meet" + > + <circle cx="20" cy="20" r="16" /> + <rect width="500" x="45" y="15" height="10" rx="4" /> + </gl-skeleton-loader> + </div> + <div v-else class="issuable-discussion gl-mb-5 work-item-notes"> + <template v-if="notes && notes.length"> + <ul class="notes main-notes-list timeline"> + <system-note + v-for="note in notes" + :key="note.notes.nodes[0].id" + :note="note.notes.nodes[0]" + /> + </ul> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_type_icon.vue b/app/assets/javascripts/work_items/components/work_item_type_icon.vue index 96a6493357c..32678e29fa4 100644 --- a/app/assets/javascripts/work_items/components/work_item_type_icon.vue +++ b/app/assets/javascripts/work_items/components/work_item_type_icon.vue @@ -33,6 +33,11 @@ export default { }, computed: { iconName() { + // TODO: Remove this once https://gitlab.com/gitlab-org/gitlab-svgs/-/merge_requests/865 + // is merged and updated in GitLab repo. + if (this.workItemIconName === 'issue-type-keyresult') { + return 'issue-type-key-result'; + } return ( this.workItemIconName || WORK_ITEMS_TYPE_MAP[this.workItemType]?.icon || 'issue-type-issue' ); diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 8b47c24de7d..3cd17f4d360 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -16,17 +16,23 @@ export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; +export const WIDGET_TYPE_PROGRESS = 'PROGRESS'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; export const WIDGET_TYPE_MILESTONE = 'MILESTONE'; export const WIDGET_TYPE_ITERATION = 'ITERATION'; - -export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner'; +export const WIDGET_TYPE_NOTES = 'NOTES'; +export const WIDGET_TYPE_HEALTH_STATUS = 'HEALTH_STATUS'; export const WORK_ITEM_TYPE_ENUM_INCIDENT = 'INCIDENT'; export const WORK_ITEM_TYPE_ENUM_ISSUE = 'ISSUE'; export const WORK_ITEM_TYPE_ENUM_TASK = 'TASK'; export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; +export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE'; +export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT'; + +export const WORK_ITEM_TYPE_VALUE_ISSUE = 'Issue'; +export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective'; export const i18n = { fetchErrorTitle: s__('WorkItem|Work item not found'), @@ -61,6 +67,13 @@ export const I18N_WORK_ITEM_FETCH_ITERATIONS_ERROR = s__( 'WorkItem|Something went wrong when fetching iterations. Please try again.', ); +export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}'); +export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}'); +export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s'); +export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__( + 'WorkItem|Search existing %{workItemType}s', +); + export const sprintfWorkItem = (msg, workItemTypeArg) => { const workItemType = workItemTypeArg || s__('WorkItem|Work item'); return capitalizeFirstCharacter( @@ -100,11 +113,45 @@ export const WORK_ITEMS_TYPE_MAP = { icon: `issue-type-requirements`, name: s__('WorkItem|Requirements'), }, + [WORK_ITEM_TYPE_ENUM_OBJECTIVE]: { + icon: `issue-type-objective`, + name: s__('WorkItem|Objective'), + }, + [WORK_ITEM_TYPE_ENUM_KEY_RESULT]: { + icon: `issue-type-issue`, + name: s__('WorkItem|Key Result'), + }, +}; + +export const WORK_ITEMS_TREE_TEXT_MAP = { + [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: { + title: s__('WorkItem|Child objectives and key results'), + empty: s__('WorkItem|No objectives or key results are currently assigned.'), + }, + [WORK_ITEM_TYPE_VALUE_ISSUE]: { + title: s__('WorkItem|Tasks'), + empty: s__( + 'WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts.', + ), + }, +}; + +export const WORK_ITEM_NAME_TO_ICON_MAP = { + Issue: 'issue-type-issue', + Task: 'issue-type-task', + Objective: 'issue-type-objective', + // eslint-disable-next-line @gitlab/require-i18n-strings + 'Key Result': 'issue-type-key-result', }; export const FORM_TYPES = { create: 'create', add: 'add', + [WORK_ITEM_TYPE_ENUM_OBJECTIVE]: { + icon: `issue-type-issue`, + name: s__('WorkItem|Objective'), + }, }; export const DEFAULT_PAGE_SIZE_ASSIGNEES = 10; +export const DEFAULT_PAGE_SIZE_NOTES = 100; diff --git a/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql b/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql new file mode 100644 index 00000000000..62ced6bdfea --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/discussion.fragment.graphql @@ -0,0 +1,12 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +fragment Discussion on Note { + id + body + bodyHtml + systemNoteIconName + createdAt + author { + ...User + } +} diff --git a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql index 58140aff89e..5c93370aac9 100644 --- a/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/milestone.fragment.graphql @@ -2,4 +2,7 @@ fragment MilestoneFragment on Milestone { expired id title + state + startDate + dueDate } diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index 7b63d9c7ca3..7fcf622cdb2 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -20,9 +20,12 @@ query workItemLinksQuery($id: WorkItemID!) { children { nodes { id + iid confidential workItemType { id + name + iconName } title state diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql new file mode 100644 index 00000000000..baefcdaea93 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -0,0 +1,29 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/work_items/graphql/milestone.fragment.graphql" + +fragment WorkItemMetadataWidgets on WorkItemWidget { + ... on WorkItemWidgetMilestone { + type + milestone { + ...MilestoneFragment + } + } + ... on WorkItemWidgetAssignees { + type + assignees { + nodes { + ...User + } + } + } + ... on WorkItemWidgetLabels { + type + allowsScopedLabels + labels { + nodes { + ...Label + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql new file mode 100644 index 00000000000..9439f22f955 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_notes.query.graphql @@ -0,0 +1,27 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/discussion.fragment.graphql" + +query workItemNotes($id: WorkItemID!, $after: String, $pageSize: Int) { + workItem(id: $id) { + id + iid + widgets { + ... on WorkItemWidgetNotes { + type + discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + pageInfo { + ...PageInfo + } + nodes { + id + notes { + nodes { + ...Discussion + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql new file mode 100644 index 00000000000..3e0960f3f54 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_notes_by_iid.query.graphql @@ -0,0 +1,32 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/discussion.fragment.graphql" + +query workItemNotesByIid($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { + workspace: project(fullPath: $fullPath) { + id + workItems(iid: $iid) { + nodes { + id + iid + widgets { + ... on WorkItemWidgetNotes { + type + discussions(first: $pageSize, after: $after, filter: ONLY_ACTIVITY) { + pageInfo { + ...PageInfo + } + nodes { + id + notes { + nodes { + ...Discussion + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql new file mode 100644 index 00000000000..006ca29e01c --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_tree.query.graphql @@ -0,0 +1,53 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "./work_item_metadata_widgets.fragment.graphql" + +query workItemTreeQuery($id: WorkItemID!) { + workItem(id: $id) { + id + workItemType { + id + name + iconName + } + title + userPermissions { + deleteWorkItem + updateWorkItem + } + confidential + widgets { + type + ... on WorkItemWidgetHierarchy { + type + parent { + id + } + children { + nodes { + id + iid + confidential + workItemType { + id + name + iconName + } + title + state + createdAt + closedAt + widgets { + ... on WorkItemWidgetHierarchy { + type + hasChildren + } + ...WorkItemMetadataWidgets + } + } + } + } + ...WorkItemMetadataWidgets + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql index b9715c21c27..cf3374e1737 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql @@ -1,6 +1,7 @@ #import "~/graphql_shared/fragments/label.fragment.graphql" #import "~/graphql_shared/fragments/user.fragment.graphql" #import "~/work_items/graphql/milestone.fragment.graphql" +#import "./work_item_metadata_widgets.fragment.graphql" fragment WorkItemWidgets on WorkItemWidget { ... on WorkItemWidgetDescription { @@ -38,15 +39,39 @@ fragment WorkItemWidgets on WorkItemWidget { } ... on WorkItemWidgetHierarchy { type + hasChildren parent { id iid title confidential + webUrl + workItemType { + id + name + iconName + } } children { nodes { id + confidential + workItemType { + id + name + iconName + } + title + state + createdAt + closedAt + widgets { + ... on WorkItemWidgetHierarchy { + type + hasChildren + } + ...WorkItemMetadataWidgets + } } } } @@ -56,4 +81,7 @@ fragment WorkItemWidgets on WorkItemWidget { ...MilestoneFragment } } + ... on WorkItemWidgetNotes { + type + } } diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 4fbcdfe2b96..a056fde6928 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -6,7 +6,14 @@ import { createRouter } from './router'; export const initWorkItemsRoot = () => { const el = document.querySelector('#js-work-items'); - const { fullPath, hasIssueWeightsFeature, issuesListPath, hasIterationsFeature } = el.dataset; + const { + fullPath, + hasIssueWeightsFeature, + issuesListPath, + hasIterationsFeature, + hasOkrsFeature, + hasIssuableHealthStatusFeature, + } = el.dataset; return new Vue({ el, @@ -15,9 +22,12 @@ export const initWorkItemsRoot = () => { apolloProvider, provide: { fullPath, + projectPath: fullPath, hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + hasOkrsFeature: parseBoolean(hasOkrsFeature), issuesListPath, hasIterationsFeature: parseBoolean(hasIterationsFeature), + hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), }, render(createElement) { return createElement(App); diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index 1c00bd16263..d04d4942253 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -70,6 +70,10 @@ export default { <template> <div> <gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert> - <work-item-detail :work-item-id="gid" :iid="id" @deleteWorkItem="deleteWorkItem($event)" /> + <work-item-detail + :work-item-id="gid" + :work-item-iid="id" + @deleteWorkItem="deleteWorkItem($event)" + /> </div> </template> diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 17f9c882c2d..e58fd19ea31 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,6 +1,12 @@ import workItemQuery from './graphql/work_item.query.graphql'; import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql'; +import workItemNotesIdQuery from './graphql/work_item_notes.query.graphql'; +import workItemNotesByIidQuery from './graphql/work_item_notes_by_iid.query.graphql'; export function getWorkItemQuery(isFetchedByIid) { return isFetchedByIid ? workItemByIidQuery : workItemQuery; } + +export function getWorkItemNotesQuery(isFetchedByIid) { + return isFetchedByIid ? workItemNotesByIidQuery : workItemNotesIdQuery; +} |