diff options
Diffstat (limited to 'app/assets/javascripts')
715 files changed, 13542 insertions, 6597 deletions
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 43d56295f78..7f5f0403de6 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -1,20 +1,10 @@ import Vue from 'vue'; import createFlash from '~/flash'; +import { parseRailsFormFields } from '~/lib/utils/forms'; import { __ } from '~/locale'; import ExpiresAtField from './components/expires_at_field.vue'; -const getInputAttrs = (el) => { - const input = el.querySelector('input'); - - return { - id: input.id, - name: input.name, - value: input.value, - placeholder: input.placeholder, - }; -}; - export const initExpiresAtField = () => { const el = document.querySelector('.js-access-tokens-expires-at'); @@ -22,7 +12,7 @@ export const initExpiresAtField = () => { return null; } - const inputAttrs = getInputAttrs(el); + const { expiresAt: inputAttrs } = parseRailsFormFields(el); return new Vue({ el, @@ -43,7 +33,7 @@ export const initProjectsField = () => { return null; } - const inputAttrs = getInputAttrs(el); + const { projects: inputAttrs } = parseRailsFormFields(el); if (window.gon.features.personalAccessTokensScopedToProjects) { return new Promise((resolve) => { diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index 5064d9ee2d2..b671d038ce8 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -2,14 +2,20 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; import { localTimeAgo } from './lib/utils/datetime_utility'; import Pager from './pager'; export default class Activities { - constructor(container = '') { - this.container = container; + constructor(containerSelector = '') { + this.containerSelector = containerSelector; + this.containerEl = this.containerSelector + ? document.querySelector(this.containerSelector) + : undefined; + this.$contentList = $('.content_list'); - Pager.init(20, true, false, (data) => data, this.updateTooltips, this.container); + this.loadActivities(); $('.event-filter-link').on('click', (e) => { e.preventDefault(); @@ -18,13 +24,30 @@ export default class Activities { }); } + loadActivities() { + Pager.init({ + limit: 20, + preload: true, + prepareData: (data) => data, + successCallback: () => this.updateTooltips(), + errorCallback: () => + createFlash({ + message: s__( + 'Activity|An error occured while retrieving activity. Reload the page to try again.', + ), + parent: this.containerEl, + }), + container: this.containerSelector, + }); + } + updateTooltips() { localTimeAgo($('.js-timeago', '.content_list')); } reloadActivities() { - $('.content_list').html(''); - Pager.init(20, true, false, (data) => data, this.updateTooltips, this.container); + this.$contentList.html(''); + this.loadActivities(); } toggleFilter(sender) { diff --git a/app/assets/javascripts/admin/statistics_panel/constants.js b/app/assets/javascripts/admin/statistics_panel/constants.js index 2dce19a3894..de413b2e7f0 100644 --- a/app/assets/javascripts/admin/statistics_panel/constants.js +++ b/app/assets/javascripts/admin/statistics_panel/constants.js @@ -3,7 +3,7 @@ import { s__ } from '~/locale'; const statisticsLabels = { forks: s__('AdminStatistics|Forks'), issues: s__('AdminStatistics|Issues'), - mergeRequests: s__('AdminStatistics|Merge Requests'), + mergeRequests: s__('AdminStatistics|Merge requests'), notes: s__('AdminStatistics|Notes'), snippets: s__('AdminStatistics|Snippets'), sshKeys: s__('AdminStatistics|SSH Keys'), diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue index 8962068601c..8b41a063abc 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/admin/users/components/users_table.vue @@ -1,9 +1,9 @@ <script> import { GlTable } from '@gitlab/ui'; import { __ } from '~/locale'; +import UserDate from '~/vue_shared/components/user_date.vue'; import UserActions from './user_actions.vue'; import UserAvatar from './user_avatar.vue'; -import UserDate from './user_date.vue'; const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js index 8ea1bd3ca7a..c55edefe607 100644 --- a/app/assets/javascripts/admin/users/constants.js +++ b/app/assets/javascripts/admin/users/constants.js @@ -2,8 +2,6 @@ import { s__, __ } from '~/locale'; export const USER_AVATAR_SIZE = 32; -export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; - export const LENGTH_OF_USER_NOTE_TOOLTIP = 100; export const I18N_USER_ACTIONS = { diff --git a/app/assets/javascripts/admin/users/new.js b/app/assets/javascripts/admin/users/new.js new file mode 100644 index 00000000000..33565bfc14f --- /dev/null +++ b/app/assets/javascripts/admin/users/new.js @@ -0,0 +1,55 @@ +const DATA_ATTR_REGEX_PATTERN = 'data-user-internal-regex-pattern'; +const DATA_ATTR_REGEX_OPTIONS = 'data-user-internal-regex-options'; +export const ID_USER_EXTERNAL = 'user_external'; +export const ID_WARNING = 'warning_external_automatically_set'; +export const ID_USER_EMAIL = 'user_email'; + +const getAttributeValue = (attr) => document.querySelector(`[${attr}]`)?.getAttribute(attr); + +const getRegexPattern = () => getAttributeValue(DATA_ATTR_REGEX_PATTERN); + +const getRegexOptions = () => getAttributeValue(DATA_ATTR_REGEX_OPTIONS); + +export const setupInternalUserRegexHandler = () => { + const regexPattern = getRegexPattern(); + + if (!regexPattern) { + return; + } + + const regexOptions = getRegexOptions(); + const elExternal = document.getElementById(ID_USER_EXTERNAL); + const elWarningMessage = document.getElementById(ID_WARNING); + const elUserEmail = document.getElementById(ID_USER_EMAIL); + + const isEmailInternal = (email) => { + const regex = new RegExp(regexPattern, regexOptions); + return regex.test(email); + }; + + const setExternalCheckbox = (email) => { + const isChecked = elExternal.checked; + + if (isEmailInternal(email)) { + if (isChecked) { + elExternal.checked = false; + elWarningMessage.classList.remove('hidden'); + } + } else if (!isChecked) { + elExternal.checked = true; + elWarningMessage.classList.add('hidden'); + } + }; + + const setupListeners = () => { + elUserEmail.addEventListener('input', (event) => { + setExternalCheckbox(event.target.value); + }); + + elExternal.addEventListener('change', () => { + elWarningMessage.classList.add('hidden'); + }); + }; + + setupListeners(); +}; diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue index 9b0e5090a75..77c14d9f812 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue @@ -43,7 +43,7 @@ export default { </gl-link> </div> <div v-if="userCanEnableAlertManagement" class="gl-display-block center gl-pt-4"> - <gl-button category="primary" variant="success" :href="enableAlertManagementPath"> + <gl-button category="primary" variant="confirm" :href="enableAlertManagementPath"> {{ $options.i18n.emptyState.buttonText }} </gl-button> </div> diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index f5eac26431f..b23f8a8eba4 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -5,6 +5,7 @@ import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants'; import AlertManagementList from './components/alert_management_list_wrapper.vue'; +import alertsHelpUrlQuery from './graphql/queries/alert_help_url.query.graphql'; Vue.use(VueApollo); @@ -41,7 +42,8 @@ export default () => { ), }); - apolloProvider.clients.defaultClient.cache.writeData({ + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: alertsHelpUrlQuery, data: { alertsHelpUrl, }, diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue index 07b2e59671e..5171588eb64 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -118,17 +118,17 @@ export default { <template> <div class="gl-display-table gl-w-full gl-mt-5"> <div class="gl-display-table-row"> - <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-pb-3 gl-pr-3"> {{ $options.i18n.columns.gitlabKeyTitle }} </h5> - <h5 class="gl-display-table-cell gl-py-3 gl-pr-3"> </h5> - <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + <h5 class="gl-display-table-cell gl-pb-3 gl-pr-3"> </h5> + <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-pb-3 gl-pr-3"> {{ $options.i18n.columns.payloadKeyTitle }} </h5> <h5 v-if="hasFallbackColumn" id="fallbackFieldsHeader" - class="gl-display-table-cell gl-py-3 gl-pr-3" + class="gl-display-table-cell gl-pb-3 gl-pr-3" > {{ $options.i18n.columns.fallbackKeyTitle }} <gl-icon @@ -140,11 +140,7 @@ export default { </h5> </div> - <div - v-for="(gitlabField, index) in mappingData" - :key="gitlabField.name" - class="gl-display-table-row" - > + <div v-for="gitlabField in mappingData" :key="gitlabField.name" class="gl-display-table-row"> <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle"> <gl-form-input aria-labelledby="gitlabFieldsHeader" @@ -153,8 +149,8 @@ export default { /> </div> - <div class="gl-display-table-cell gl-py-3 gl-pr-3"> - <div class="right-arrow" :class="{ 'gl-vertical-align-middle': index === 0 }"> + <div class="gl-display-table-cell gl-pr-3 gl-vertical-align-middle"> + <div class="right-arrow"> <i class="right-arrow-head"></i> </div> </div> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue index a5e17d80f86..ef29fc5e8b4 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -21,8 +21,10 @@ import { import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; export const i18n = { + deleteIntegration: s__('AlertSettings|Delete integration'), + editIntegration: s__('AlertSettings|Edit integration'), title: s__('AlertsIntegrations|Current integrations'), - emptyState: s__('AlertsIntegrations|No integrations have been added yet'), + emptyState: s__('AlertsIntegrations|No integrations have been added yet.'), status: { enabled: { name: __('Enabled'), @@ -139,7 +141,7 @@ export default { <template> <div class="incident-management-list"> - <h5 class="gl-font-lg">{{ $options.i18n.title }}</h5> + <h5 class="gl-font-lg gl-mt-5">{{ $options.i18n.title }}</h5> <gl-table class="integration-list" :items="integrations" @@ -174,11 +176,16 @@ export default { <template #cell(actions)="{ item }"> <gl-button-group class="gl-ml-3"> - <gl-button icon="settings" @click="editIntegration(item)" /> + <gl-button + icon="settings" + :aria-label="$options.i18n.editIntegration" + @click="editIntegration(item)" + /> <gl-button v-gl-modal.deleteIntegration :disabled="item.type === $options.typeSet.prometheus" icon="remove" + :aria-label="$options.i18n.deleteIntegration" @click="setIntegrationToDelete(item)" /> </gl-button-group> @@ -198,15 +205,15 @@ export default { </gl-table> <gl-modal modal-id="deleteIntegration" - :title="s__('AlertSettings|Delete integration')" - :ok-title="s__('AlertSettings|Delete integration')" + :title="$options.i18n.deleteIntegration" + :ok-title="$options.i18n.deleteIntegration" ok-variant="danger" @ok="deleteIntegration" > <gl-sprintf :message=" s__( - 'AlertsIntegrations|You have opted to delete the %{integrationName} integration. Do you want to proceed? It means you will no longer receive alerts from this endpoint in your alert list, and this action cannot be undone.', + 'AlertsIntegrations|If you delete the %{integrationName} integration, alerts are no longer sent from this endpoint. This action cannot be undone.', ) " > 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 5d9513e5b53..a5f7b84446f 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -14,7 +14,7 @@ import { GlTab, } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; -import { isEmpty, omit } from 'lodash'; +import { isEqual, isEmpty, omit } from 'lodash'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { integrationTypes, @@ -24,8 +24,9 @@ import { JSON_VALIDATE_DELAY, targetPrometheusUrlPlaceholder, typeSet, - viewCredentialsTabIndex, i18n, + tabIndices, + testAlertModalId, } from '../constants'; import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; import parseSamplePayloadQuery from '../graphql/queries/parse_sample_payload.query.graphql'; @@ -40,6 +41,10 @@ export default { typeSet, integrationSteps, i18n, + primaryProps: { text: i18n.integrationFormSteps.testPayload.savedAndTest }, + secondaryProps: { text: i18n.integrationFormSteps.testPayload.proceedWithoutSave }, + cancelProps: { text: i18n.integrationFormSteps.testPayload.cancel }, + testAlertModalId, components: { ClipboardButton, GlButton, @@ -60,11 +65,8 @@ export default { GlModal: GlModalDirective, }, inject: { - generic: { - default: {}, - }, - prometheus: { - default: {}, + alertsUsageUrl: { + default: '#', }, multiIntegrations: { default: false, @@ -87,6 +89,11 @@ export default { required: false, default: null, }, + tabIndex: { + type: Number, + required: false, + default: tabIndices.configureDetails, + }, }, apollo: { currentIntegration: { @@ -96,11 +103,10 @@ export default { data() { return { integrationTypesOptions: Object.values(integrationTypes), - selectedIntegration: integrationTypes.none.value, - active: false, samplePayload: { json: null, error: null, + loading: false, }, testPayload: { json: null, @@ -108,18 +114,32 @@ export default { }, resetPayloadAndMappingConfirmed: false, mapping: [], - parsingPayload: false, + integrationForm: { + active: false, + type: integrationTypes.none.value, + name: '', + token: '', + url: '', + apiUrl: '', + }, + activeTabIndex: this.tabIndex, currentIntegration: null, parsedPayload: [], - activeTabIndex: 0, + validationState: { + name: true, + apiUrl: true, + }, }; }, computed: { isPrometheus() { - return this.selectedIntegration === this.$options.typeSet.prometheus; + return this.integrationForm.type === typeSet.prometheus; }, isHttp() { - return this.selectedIntegration === this.$options.typeSet.http; + return this.integrationForm.type === typeSet.http; + }, + isNone() { + return !this.isHttp && !this.isPrometheus; }, isCreating() { return !this.currentIntegration; @@ -130,29 +150,6 @@ export default { isTestPayloadValid() { return this.testPayload.error === null; }, - selectedIntegrationType() { - switch (this.selectedIntegration) { - case typeSet.http: - return this.generic; - case typeSet.prometheus: - return this.prometheus; - default: - return {}; - } - }, - integrationForm() { - return { - name: this.currentIntegration?.name || '', - active: this.currentIntegration?.active || false, - token: - this.currentIntegration?.token || - (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.token : ''), - url: - this.currentIntegration?.url || - (this.selectedIntegrationType !== this.generic ? this.selectedIntegrationType.url : ''), - apiUrl: this.currentIntegration?.apiUrl || '', - }; - }, testAlertPayload() { return { data: this.testPayload.json, @@ -170,13 +167,7 @@ export default { return this.hasSamplePayload && !this.resetPayloadAndMappingConfirmed; }, canParseSamplePayload() { - return !this.active || !this.isSampePayloadValid || !this.samplePayload.json; - }, - isResetAuthKeyDisabled() { - return !this.active && !this.integrationForm.token !== ''; - }, - isPayloadEditDisabled() { - return !this.active || this.canEditPayload; + return this.isSampePayloadValid && this.samplePayload.json; }, isSelectDisabled() { return this.currentIntegration !== null || !this.canAddIntegration; @@ -186,30 +177,105 @@ export default { ? i18n.integrationFormSteps.setupCredentials.prometheusHelp : i18n.integrationFormSteps.setupCredentials.help; }, + isFormValid() { + return ( + Object.values(this.validationState).every(Boolean) && + !this.isNone && + this.isSampePayloadValid + ); + }, + isFormDirty() { + const { type, active, name, apiUrl, payloadAlertFields = [], payloadAttributeMappings = [] } = + this.currentIntegration || {}; + const { + name: formName, + apiUrl: formApiUrl, + active: formActive, + type: formType, + } = this.integrationForm; + + const isDirty = + type !== formType || + active !== formActive || + name !== formName || + apiUrl !== formApiUrl || + !isEqual(this.parsedPayload, payloadAlertFields) || + !isEqual(this.mapping, this.getCleanMapping(payloadAttributeMappings)); + + return isDirty; + }, + canSubmitForm() { + return this.isFormValid && this.isFormDirty; + }, + dataForSave() { + const { name, apiUrl, active } = this.integrationForm; + const customMappingVariables = { + payloadAttributeMappings: this.mapping, + payloadExample: this.samplePayload.json || '{}', + }; + + const variables = this.isHttp + ? { name, active, ...customMappingVariables } + : { apiUrl, active }; + + return { type: this.integrationForm.type, variables }; + }, + testAlertModal() { + return this.isFormDirty ? testAlertModalId : null; + }, + prometheusUrlInvalidFeedback() { + const { blankUrlError, invalidUrlError } = i18n.integrationFormSteps.prometheusFormUrl; + return this.integrationForm.apiUrl?.length ? invalidUrlError : blankUrlError; + }, }, watch: { + tabIndex(val) { + this.activeTabIndex = val; + }, currentIntegration(val) { if (val === null) { this.reset(); return; } - const { type, active, payloadExample, payloadAlertFields, payloadAttributeMappings } = val; - this.selectedIntegration = type; - this.active = active; - if (type === typeSet.http && this.showMappingBuilder) { + this.resetPayloadAndMapping(); + const { + name, + type, + active, + url, + apiUrl, + token, + payloadExample, + payloadAlertFields, + payloadAttributeMappings, + } = val; + this.integrationForm = { type, name, active, url, apiUrl, token }; + + if (this.showMappingBuilder) { + this.resetPayloadAndMappingConfirmed = false; this.parsedPayload = payloadAlertFields; - this.samplePayload.json = this.isValidNonEmptyJSON(payloadExample) ? payloadExample : null; - const mapping = payloadAttributeMappings.map((mappingItem) => - omit(mappingItem, '__typename'), - ); - this.updateMapping(mapping); + this.samplePayload.json = this.getPrettifiedPayload(payloadExample); + this.updateMapping(this.getCleanMapping(payloadAttributeMappings)); } - this.activeTabIndex = viewCredentialsTabIndex; this.$el.scrollIntoView({ block: 'center' }); }, }, methods: { + getCleanMapping(mapping) { + return mapping.map((mappingItem) => omit(mappingItem, '__typename')); + }, + validateName() { + this.validationState.name = Boolean(this.integrationForm.name?.length); + }, + validateApiUrl() { + try { + const parsedUrl = new URL(this.integrationForm.apiUrl); + this.validationState.apiUrl = ['http:', 'https:'].includes(parsedUrl.protocol); + } catch (e) { + this.validationState.apiUrl = false; + } + }, isValidNonEmptyJSON(JSONString) { if (JSONString) { let parsed; @@ -222,29 +288,37 @@ export default { } return false; }, + getPrettifiedPayload(payload) { + return this.isValidNonEmptyJSON(payload) + ? JSON.stringify(JSON.parse(payload), null, '\t') + : null; + }, + triggerValidation() { + if (this.isHttp) { + this.validationState.apiUrl = true; + this.validateName(); + if (!this.validationState.name) { + this.$refs.integrationName.$el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } else if (this.isPrometheus) { + this.validationState.name = true; + this.validateApiUrl(); + } + }, sendTestAlert() { this.$emit('test-alert-payload', this.testAlertPayload); }, - submit() { - const { name, apiUrl } = this.integrationForm; - const customMappingVariables = { - payloadAttributeMappings: this.mapping, - payloadExample: this.samplePayload.json || '{}', - }; - - const variables = - this.selectedIntegration === typeSet.http - ? { name, active: this.active, ...customMappingVariables } - : { apiUrl, active: this.active }; - - const integrationPayload = { type: this.selectedIntegration, variables }; + saveAndSendTestAlert() { + this.$emit('save-and-test-alert-payload', this.dataForSave, this.testAlertPayload); + }, + submit(testAfterSubmit = false) { + this.triggerValidation(); - if (this.currentIntegration) { - return this.$emit('update-integration', integrationPayload); + if (!this.isFormValid) { + return; } - - this.reset(); - return this.$emit('create-new-integration', integrationPayload); + const event = this.currentIntegration ? 'update-integration' : 'create-new-integration'; + this.$emit(event, this.dataForSave, testAfterSubmit); }, reset() { this.resetFormValues(); @@ -252,14 +326,14 @@ export default { this.$emit('clear-current-integration', { type: this.currentIntegration?.type }); }, resetFormValues() { - this.selectedIntegration = integrationTypes.none.value; + this.integrationForm.type = integrationTypes.none.value; this.integrationForm.name = ''; + this.integrationForm.active = false; this.integrationForm.apiUrl = ''; this.samplePayload = { json: null, error: null, }; - this.active = false; }, resetAuthKey() { if (!this.currentIntegration) { @@ -267,7 +341,7 @@ export default { } this.$emit('reset-token', { - type: this.selectedIntegration, + type: this.integrationForm.type, variables: { id: this.currentIntegration.id }, }); }, @@ -285,8 +359,8 @@ export default { payload.error = JSON.stringify(e.message); } }, - parseMapping() { - this.parsingPayload = true; + parseSamplePayload() { + this.samplePayload.loading = true; return this.$apollo .query({ @@ -303,7 +377,7 @@ export default { this.resetPayloadAndMappingConfirmed = false; this.$toast.show( - this.$options.i18n.integrationFormSteps.setSamplePayload.payloadParsedSucessMsg, + this.$options.i18n.integrationFormSteps.mapFields.payloadParsedSucessMsg, ); }, ) @@ -311,7 +385,7 @@ export default { this.samplePayload.error = message; }) .finally(() => { - this.parsingPayload = false; + this.samplePayload.loading = false; }); }, updateMapping(mapping) { @@ -338,7 +412,7 @@ export default { <template> <gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset"> <gl-tabs v-model="activeTabIndex"> - <gl-tab :title="$options.i18n.integrationTabs.configureDetails"> + <gl-tab :title="$options.i18n.integrationTabs.configureDetails" class="gl-mt-3"> <gl-form-group v-if="isCreating" id="integration-type" @@ -351,7 +425,7 @@ export default { label-for="integration-type" > <gl-form-select - v-model="selectedIntegration" + v-model="integrationForm.type" :disabled="isSelectDisabled" class="gl-max-w-full" :options="integrationTypesOptions" @@ -369,7 +443,6 @@ export default { <div class="gl-mt-3"> <gl-form-group v-if="isHttp" - id="name-integration" :label=" getLabelWithStepNumber( $options.integrationSteps.nameIntegration, @@ -377,67 +450,82 @@ export default { ) " label-for="name-integration" + :invalid-feedback="$options.i18n.integrationFormSteps.nameIntegration.error" + :state="validationState.name" > <gl-form-input + id="name-integration" + ref="integrationName" v-model="integrationForm.name" type="text" :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder" + @input="validateName" /> </gl-form-group> - <gl-toggle - v-model="active" - :is-loading="loading" - :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" - class="gl-my-4 gl-font-weight-normal" - /> - - <div v-if="isPrometheus" class="gl-my-4"> - <span class="gl-font-weight-bold"> - {{ - getLabelWithStepNumber( - $options.integrationSteps.setPrometheusApiUrl, - $options.i18n.integrationFormSteps.prometheusFormUrl.label, - ) - }} - </span> + <gl-form-group + v-if="!isNone" + :label=" + getLabelWithStepNumber( + isHttp + ? $options.integrationSteps.enableHttpIntegration + : $options.integrationSteps.enablePrometheusIntegration, + $options.i18n.integrationFormSteps.enableIntegration.label, + ) + " + > + <span>{{ $options.i18n.integrationFormSteps.enableIntegration.help }}</span> + + <gl-toggle + id="enable-integration" + v-model="integrationForm.active" + :is-loading="loading" + :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" + class="gl-mt-4 gl-font-weight-normal" + /> + </gl-form-group> + <gl-form-group + v-if="isPrometheus" + class="gl-my-4" + :label="$options.i18n.integrationFormSteps.prometheusFormUrl.label" + label-for="api-url" + :invalid-feedback="prometheusUrlInvalidFeedback" + :state="validationState.apiUrl" + > <gl-form-input - id="integration-apiUrl" + id="api-url" v-model="integrationForm.apiUrl" type="text" :placeholder="$options.placeholders.prometheus" + @input="validateApiUrl" /> - <span class="gl-text-gray-400"> {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }} </span> - </div> + </gl-form-group> <template v-if="showMappingBuilder"> <gl-form-group data-testid="sample-payload-section" :label=" getLabelWithStepNumber( - $options.integrationSteps.setSamplePayload, - $options.i18n.integrationFormSteps.setSamplePayload.label, + $options.integrationSteps.customizeMapping, + $options.i18n.integrationFormSteps.mapFields.label, ) " label-for="sample-payload" class="gl-mb-0!" :invalid-feedback="samplePayload.error" > - <alert-settings-form-help-block - :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelpHttp" - :link="generic.alertsUsageUrl" - /> + <span>{{ $options.i18n.integrationFormSteps.mapFields.help }}</span> <gl-form-textarea id="sample-payload" - v-model.trim="samplePayload.json" - :disabled="isPayloadEditDisabled" + v-model="samplePayload.json" + :disabled="canEditPayload" :state="isSampePayloadValid" - :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder" + :placeholder="$options.i18n.integrationFormSteps.mapFields.placeholder" class="gl-my-3" :debounce="$options.JSON_VALIDATE_DELAY" rows="6" @@ -450,71 +538,76 @@ export default { v-if="canEditPayload" v-gl-modal.resetPayloadModal data-testid="payload-action-btn" - :disabled="!active" + :disabled="!integrationForm.active" class="gl-mt-3" > - {{ $options.i18n.integrationFormSteps.setSamplePayload.editPayload }} + {{ $options.i18n.integrationFormSteps.mapFields.editPayload }} </gl-button> <gl-button v-else data-testid="payload-action-btn" :class="{ 'gl-mt-3': samplePayload.error }" - :disabled="canParseSamplePayload" - :loading="parsingPayload" - @click="parseMapping" + :disabled="!canParseSamplePayload" + :loading="samplePayload.loading" + @click="parseSamplePayload" > - {{ $options.i18n.integrationFormSteps.setSamplePayload.parsePayload }} + {{ $options.i18n.integrationFormSteps.mapFields.parsePayload }} </gl-button> <gl-modal modal-id="resetPayloadModal" - :title="$options.i18n.integrationFormSteps.setSamplePayload.resetHeader" - :ok-title="$options.i18n.integrationFormSteps.setSamplePayload.resetOk" + :title="$options.i18n.integrationFormSteps.mapFields.resetHeader" + :ok-title="$options.i18n.integrationFormSteps.mapFields.resetOk" ok-variant="danger" - @ok="resetPayloadAndMapping" + @ok="resetPayloadAndMappingConfirmed = true" > - {{ $options.i18n.integrationFormSteps.setSamplePayload.resetBody }} + {{ $options.i18n.integrationFormSteps.mapFields.resetBody }} </gl-modal> - <gl-form-group - id="mapping-builder" - class="gl-mt-5" - :label=" - getLabelWithStepNumber( - $options.integrationSteps.customizeMapping, - $options.i18n.integrationFormSteps.mapFields.label, - ) - " - label-for="mapping-builder" - > - <span>{{ $options.i18n.integrationFormSteps.mapFields.intro }}</span> + <div class="gl-mt-5"> + <span>{{ $options.i18n.integrationFormSteps.mapFields.mapIntro }}</span> <mapping-builder :parsed-payload="parsedPayload" :saved-mapping="mapping" :alert-fields="alertFields" @onMappingUpdate="updateMapping" /> - </gl-form-group> + </div> </template> </div> - <div class="gl-display-flex gl-justify-content-start gl-py-3"> <gl-button - type="submit" + :disabled="!canSubmitForm" variant="confirm" class="js-no-auto-disable" data-testid="integration-form-submit" + @click="submit(false)" > {{ $options.i18n.saveIntegration }} </gl-button> + <gl-button + :disabled="!canSubmitForm" + variant="confirm" + category="secondary" + class="gl-ml-3 js-no-auto-disable" + data-testid="integration-form-test-and-submit" + @click="submit(true)" + > + {{ $options.i18n.saveAndTestIntegration }} + </gl-button> + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ $options.i18n.cancelAndClose }}</gl-button> </div> </gl-tab> - <gl-tab :title="$options.i18n.integrationTabs.viewCredentials" :disabled="isCreating"> + <gl-tab + :title="$options.i18n.integrationTabs.viewCredentials" + :disabled="isCreating" + class="gl-mt-3" + > <alert-settings-form-help-block :message="viewCredentialsHelpMsg" link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" @@ -559,13 +652,15 @@ export default { </div> </gl-form-group> - <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled" variant="danger"> - {{ $options.i18n.integrationFormSteps.setupCredentials.reset }} - </gl-button> + <div class="gl-display-flex gl-justify-content-start gl-py-3"> + <gl-button v-gl-modal.authKeyModal variant="danger"> + {{ $options.i18n.integrationFormSteps.setupCredentials.reset }} + </gl-button> - <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ - $options.i18n.cancelAndClose - }}</gl-button> + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable"> + {{ $options.i18n.cancelAndClose }} + </gl-button> + </div> <gl-modal modal-id="authKeyModal" @@ -578,18 +673,22 @@ export default { </gl-modal> </gl-tab> - <gl-tab :title="$options.i18n.integrationTabs.sendTestAlert" :disabled="isCreating"> + <gl-tab + :title="$options.i18n.integrationTabs.sendTestAlert" + :disabled="isCreating" + class="gl-mt-3" + > <gl-form-group id="test-integration" :invalid-feedback="testPayload.error"> <alert-settings-form-help-block - :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelp" - :link="generic.alertsUsageUrl" + :message="$options.i18n.integrationFormSteps.testPayload.help" + :link="alertsUsageUrl" /> <gl-form-textarea id="test-payload" - v-model.trim="testPayload.json" + v-model="testPayload.json" :state="isTestPayloadValid" - :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder" + :placeholder="$options.i18n.integrationFormSteps.testPayload.placeholder" class="gl-my-3" :debounce="$options.JSON_VALIDATE_DELAY" rows="6" @@ -597,20 +696,35 @@ export default { @input="validateJson(false)" /> </gl-form-group> + <div class="gl-display-flex gl-justify-content-start gl-py-3"> + <gl-button + v-gl-modal="testAlertModal" + :disabled="!isTestPayloadValid" + :loading="loading" + data-testid="send-test-alert" + variant="confirm" + class="js-no-auto-disable" + @click="isFormDirty ? null : sendTestAlert()" + > + {{ $options.i18n.send }} + </gl-button> - <gl-button - :disabled="!isTestPayloadValid" - data-testid="send-test-alert" - variant="confirm" - class="js-no-auto-disable" - @click="sendTestAlert" - > - {{ $options.i18n.send }} - </gl-button> + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable"> + {{ $options.i18n.cancelAndClose }} + </gl-button> + </div> - <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ - $options.i18n.cancelAndClose - }}</gl-button> + <gl-modal + :modal-id="$options.testAlertModalId" + :title="$options.i18n.integrationFormSteps.testPayload.modalTitle" + :action-primary="$options.primaryProps" + :action-secondary="$options.secondaryProps" + :action-cancel="$options.cancelProps" + @primary="saveAndSendTestAlert" + @secondary="sendTestAlert" + > + {{ $options.i18n.integrationFormSteps.testPayload.modalBody }} + </gl-modal> </gl-tab> </gl-tabs> </gl-form> 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 3ffb652e61b..f51c8d7e9f7 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -1,11 +1,11 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlAlert } from '@gitlab/ui'; import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import createFlash, { FLASH_TYPES } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; -import { s__ } from '~/locale'; -import { typeSet } from '../constants'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { typeSet, i18n, tabIndices } from '../constants'; import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql'; import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql'; import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql'; @@ -28,21 +28,12 @@ import { RESET_INTEGRATION_TOKEN_ERROR, UPDATE_INTEGRATION_ERROR, INTEGRATION_PAYLOAD_TEST_ERROR, + INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR, + DEFAULT_ERROR, } from '../utils/error_messages'; import IntegrationsList from './alerts_integrations_list.vue'; import AlertSettingsForm from './alerts_settings_form.vue'; -export const i18n = { - changesSaved: s__( - 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.', - ), - integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'), - alertSent: s__( - 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.', - ), - addNewIntegration: s__('AlertSettings|Add new integration'), -}; - export default { typeSet, i18n, @@ -50,14 +41,9 @@ export default { IntegrationsList, AlertSettingsForm, GlButton, + GlAlert, }, inject: { - generic: { - default: {}, - }, - prometheus: { - default: {}, - }, projectPath: { default: '', }, @@ -124,7 +110,10 @@ export default { integrations: {}, httpIntegrations: {}, currentIntegration: null, + newIntegration: null, formVisible: false, + showSuccessfulCreateAlert: false, + tabIndex: tabIndices.configureDetails, }; }, computed: { @@ -139,10 +128,10 @@ export default { isHttp(type) { return type === typeSet.http; }, - createNewIntegration({ type, variables }) { + createNewIntegration({ type, variables }, testAfterSubmit) { const { projectPath } = this; - const isHttp = this.isHttp(type); + this.isUpdating = true; this.$apollo .mutate({ @@ -163,16 +152,19 @@ export default { .then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => { const error = httpIntegrationCreate?.errors[0] || prometheusIntegrationCreate?.errors[0]; if (error) { - return createFlash({ message: error }); + createFlash({ message: error }); + return; } - const { integration } = httpIntegrationCreate || prometheusIntegrationCreate; - this.editIntegration(integration); + const { integration } = httpIntegrationCreate || prometheusIntegrationCreate; + this.newIntegration = integration; + this.showSuccessfulCreateAlert = true; - return createFlash({ - message: this.$options.i18n.changesSaved, - type: FLASH_TYPES.SUCCESS, - }); + if (testAfterSubmit) { + this.viewIntegration(this.newIntegration, tabIndices.sendTestAlert); + } else { + this.setFormVisibility(false); + } }) .catch(() => { createFlash({ message: ADD_INTEGRATION_ERROR }); @@ -181,9 +173,9 @@ export default { this.isUpdating = false; }); }, - updateIntegration({ type, variables }) { + updateIntegration({ type, variables }, testAfterSubmit) { this.isUpdating = true; - this.$apollo + return this.$apollo .mutate({ mutation: this.isHttp(type) ? updateHttpIntegrationMutation @@ -196,12 +188,20 @@ export default { .then(({ data: { httpIntegrationUpdate, prometheusIntegrationUpdate } = {} } = {}) => { const error = httpIntegrationUpdate?.errors[0] || prometheusIntegrationUpdate?.errors[0]; if (error) { - return createFlash({ message: error }); + createFlash({ message: error }); + return; } - this.clearCurrentIntegration({ type }); + const integration = + httpIntegrationUpdate?.integration || prometheusIntegrationUpdate?.integration; - return createFlash({ + if (testAfterSubmit) { + this.viewIntegration(integration, tabIndices.sendTestAlert); + } else { + this.clearCurrentIntegration(type); + } + + createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.SUCCESS, }); @@ -261,13 +261,23 @@ export default { currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData }; } - this.$apollo.mutate({ - mutation: this.isHttp(type) - ? updateCurrentHttpIntegrationMutation - : updateCurrentPrometheusIntegrationMutation, - variables: currentIntegration, - }); - this.setFormVisibility(true); + this.viewIntegration(currentIntegration, tabIndices.viewCredentials); + }, + viewIntegration(integration, tabIndex) { + this.$apollo + .mutate({ + mutation: this.isHttp(integration.type) + ? updateCurrentHttpIntegrationMutation + : updateCurrentPrometheusIntegrationMutation, + variables: integration, + }) + .then(() => { + this.setFormVisibility(true); + this.tabIndex = tabIndex; + }) + .catch(() => { + createFlash({ message: DEFAULT_ERROR }); + }); }, deleteIntegration({ id, type }) { const { projectPath } = this; @@ -319,19 +329,44 @@ export default { type: FLASH_TYPES.SUCCESS, }); }) - .catch(() => { - createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + .catch((error) => { + let message = INTEGRATION_PAYLOAD_TEST_ERROR; + if (error.response?.status === httpStatusCodes.FORBIDDEN) { + message = INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR; + } + createFlash({ message }); }); }, + saveAndTestAlertPayload(integration, payload) { + return this.updateIntegration(integration, false).then(() => { + this.testAlertPayload(payload); + }); + }, setFormVisibility(visible) { this.formVisible = visible; }, + viewCreatedIntegration() { + this.viewIntegration(this.newIntegration, tabIndices.viewCredentials); + this.showSuccessfulCreateAlert = false; + this.newIntegration = null; + }, }, }; </script> <template> <div> + <gl-alert + v-if="showSuccessfulCreateAlert" + class="gl-mt-n2" + :primary-button-text="$options.i18n.integrationCreated.btnCaption" + :title="$options.i18n.integrationCreated.title" + @primaryAction="viewCreatedIntegration" + @dismiss="showSuccessfulCreateAlert = false" + > + {{ $options.i18n.integrationCreated.successMsg }} + </gl-alert> + <integrations-list :integrations="integrations.list" :loading="loading" @@ -353,11 +388,13 @@ export default { :loading="isUpdating" :can-add-integration="canAddIntegration" :alert-fields="alertFields" + :tab-index="tabIndex" @create-new-integration="createNewIntegration" @update-integration="updateIntegration" @reset-token="resetToken" @clear-current-integration="clearCurrentIntegration" @test-alert-payload="testAlertPayload" + @save-and-test-alert-payload="saveAndTestAlertPayload" /> </div> </template> diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index ce6cf61b5dd..4a180ed2bc0 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -10,96 +10,118 @@ export const i18n = { selectType: { label: s__('AlertSettings|Select integration type'), enterprise: s__( - 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.', + 'AlertSettings|Free versions of GitLab are limited to one integration per type. To add more, %{linkStart}upgrade your subscription%{linkEnd}.', ), }, nameIntegration: { label: s__('AlertSettings|Name integration'), placeholder: s__('AlertSettings|Enter integration name'), activeToggle: __('Active'), + error: __("Name can't be blank"), + }, + enableIntegration: { + label: s__('AlertSettings|Enable integration'), + help: s__( + 'AlertSettings|A webhook URL and authorization key is generated for the integration. After you save the integration, both are visible under the “View credentials” tab.', + ), }, setupCredentials: { help: s__( - "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", + 'AlertSettings|Use the URL and authorization key below to configure how an external service sends alerts to GitLab. %{linkStart}How do I configure the endpoint?%{linkEnd}', ), prometheusHelp: s__( - 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.', + 'AlertSettings|Use the URL and authorization key below to configure how Prometheus sends alerts to GitLab. Review the %{linkStart}GitLab documentation%{linkEnd} to learn how to configure your endpoint.', ), webhookUrl: s__('AlertSettings|Webhook URL'), authorizationKey: s__('AlertSettings|Authorization key'), reset: s__('AlertSettings|Reset Key'), }, - setSamplePayload: { - label: s__('AlertSettings|Sample alert payload (optional)'), - testPayloadHelpHttp: s__( - 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional).', - ), - testPayloadHelp: s__( - 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point.', + mapFields: { + label: s__('AlertSettings|Customize alert payload mapping (optional)'), + help: s__( + 'AlertSettings|To create a custom mapping, enter an example payload from your monitoring tool, in JSON format. Select the "Parse payload fields" button to continue.', ), placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'), - resetHeader: s__('AlertSettings|Reset the mapping'), - resetBody: s__( - "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.", - ), - resetOk: s__('AlertSettings|Proceed with editing'), editPayload: s__('AlertSettings|Edit payload'), - parsePayload: s__('AlertSettings|Parse payload for custom mapping'), + parsePayload: s__('AlertSettings|Parse payload fields'), payloadParsedSucessMsg: s__( 'AlertSettings|Sample payload has been parsed. You can now map the fields.', ), + resetHeader: s__('AlertSettings|Reset the mapping'), + resetBody: s__('AlertSettings|If you edit the payload, you must re-map the fields again.'), + resetOk: s__('AlertSettings|Proceed with editing'), + mapIntro: s__( + 'AlertSettings|You can map default GitLab alert fields to your payload keys in the dropdowns below.', + ), }, - mapFields: { - label: s__('AlertSettings|Customize alert payload mapping (optional)'), - intro: s__( - 'AlertSettings|If you intend to create a custom mapping, provide an example payload from your monitoring tool and click "parse payload fields" button to continue. The sample payload is required for completing the custom mapping; if you want to skip the mapping step, progress straight to saving your integration.', + testPayload: { + help: s__( + 'AlertSettings|Enter an example payload from your selected monitoring tool. This supports sending alerts to a GitLab endpoint.', ), + placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'), + modalTitle: s__('AlertSettings|The form has unsaved changes'), + modalBody: s__('AlertSettings|The form has unsaved changes. How would you like to proceed?'), + savedAndTest: s__('AlertSettings|Save integration & send'), + proceedWithoutSave: s__('AlertSettings|Send without saving'), + cancel: __('Cancel'), }, prometheusFormUrl: { label: s__('AlertSettings|Prometheus API base URL'), - help: s__('AlertSettings|URL cannot be blank and must start with http or https'), + help: s__('AlertSettings|URL cannot be blank and must start with http: or https:.'), + blankUrlError: __('URL cannot be blank'), + invalidUrlError: __('URL is invalid'), }, restKeyInfo: { label: s__( - 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', + 'AlertSettings|If you reset the authorization key for this project, you must update the key in every enabled alert source.', ), }, }, saveIntegration: s__('AlertSettings|Save integration'), - changesSaved: s__('AlertSettings|Your integration was successfully updated.'), + saveAndTestIntegration: s__('AlertSettings|Save & create test alert'), cancelAndClose: __('Cancel and close'), - send: s__('AlertSettings|Send'), + send: __('Send'), copy: __('Copy'), + integrationCreated: { + title: s__('AlertSettings|Integration successfully saved'), + successMsg: s__( + 'AlertSettings|GitLab has created a URL and authorization key for your integration. You can use them to set up a webhook and authorize your endpoint to send alerts to GitLab.', + ), + btnCaption: s__('AlertSettings|View URL and authorization key'), + }, + changesSaved: s__('AlertsIntegrations|The integration is saved.'), + integrationRemoved: s__('AlertsIntegrations|The integration is deleted.'), + alertSent: s__('AlertsIntegrations|The test alert should now be visible in your alerts list.'), + addNewIntegration: s__('AlertSettings|Add new integration'), }; export const integrationSteps = { selectType: 'SELECT_TYPE', nameIntegration: 'NAME_INTEGRATION', - setPrometheusApiUrl: 'SET_PROMETHEUS_API_URL', - setSamplePayload: 'SET_SAMPLE_PAYLOAD', + enableHttpIntegration: 'ENABLE_HTTP_INTEGRATION', + enablePrometheusIntegration: 'ENABLE_PROMETHEUS_INTEGRATION', customizeMapping: 'CUSTOMIZE_MAPPING', }; export const createStepNumbers = { [integrationSteps.selectType]: 1, [integrationSteps.nameIntegration]: 2, - [integrationSteps.setPrometheusApiUrl]: 2, - [integrationSteps.setSamplePayload]: 3, + [integrationSteps.enableHttpIntegration]: 3, + [integrationSteps.enablePrometheusIntegration]: 2, [integrationSteps.customizeMapping]: 4, }; export const editStepNumbers = { - [integrationSteps.selectType]: 1, [integrationSteps.nameIntegration]: 1, - [integrationSteps.setPrometheusApiUrl]: null, - [integrationSteps.setSamplePayload]: 2, + [integrationSteps.enableHttpIntegration]: 2, + [integrationSteps.enablePrometheusIntegration]: null, [integrationSteps.customizeMapping]: 3, }; export const integrationTypes = { none: { value: '', text: s__('AlertSettings|Select integration type') }, http: { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') }, - prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') }, + prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|Prometheus') }, }; export const typeSet = { @@ -127,4 +149,10 @@ export const mappingFields = { fallback: 'fallback', }; -export const viewCredentialsTabIndex = 1; +export const tabIndices = { + configureDetails: 0, + viewCredentials: 1, + sendTestAlert: 2, +}; + +export const testAlertModalId = 'confirmSendTestAlert'; diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js index 321af9fedb6..953a867b2b7 100644 --- a/app/assets/javascripts/alerts_settings/index.js +++ b/app/assets/javascripts/alerts_settings/index.js @@ -3,12 +3,15 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import AlertSettingsWrapper from './components/alerts_settings_wrapper.vue'; import apolloProvider from './graphql'; +import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql'; -apolloProvider.clients.defaultClient.cache.writeData({ +apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getCurrentIntegrationQuery, data: { currentIntegration: null, }, }); + Vue.use(GlToast); export default (el) => { @@ -16,23 +19,7 @@ export default (el) => { return null; } - const { - prometheusActivated, - prometheusUrl, - prometheusAuthorizationKey, - prometheusFormPath, - prometheusResetKeyPath, - prometheusApiUrl, - activated: activatedStr, - alertsSetupUrl, - alertsUsageUrl, - formPath, - authorizationKey, - url, - projectPath, - multiIntegrations, - alertFields, - } = el.dataset; + const { alertsUsageUrl, projectPath, multiIntegrations, alertFields } = el.dataset; return new Vue({ el, @@ -40,22 +27,7 @@ export default (el) => { AlertSettingsWrapper, }, provide: { - prometheus: { - active: parseBoolean(prometheusActivated), - url: prometheusUrl, - token: prometheusAuthorizationKey, - prometheusFormPath, - prometheusResetKeyPath, - prometheusApiUrl, - }, - generic: { - alertsSetupUrl, - alertsUsageUrl, - active: parseBoolean(activatedStr), - formPath, - token: authorizationKey, - url, - }, + alertsUsageUrl, projectPath, multiIntegrations: parseBoolean(multiIntegrations), }, diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js index e380257f983..9a0644b4e22 100644 --- a/app/assets/javascripts/alerts_settings/utils/error_messages.js +++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js @@ -1,4 +1,4 @@ -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; export const DELETE_INTEGRATION_ERROR = s__( 'AlertsIntegrations|The integration could not be deleted. Please try again.', @@ -19,3 +19,9 @@ export const RESET_INTEGRATION_TOKEN_ERROR = s__( export const INTEGRATION_PAYLOAD_TEST_ERROR = s__( 'AlertsIntegrations|Integration payload is invalid.', ); + +export const INTEGRATION_INACTIVE_PAYLOAD_TEST_ERROR = s__( + 'AlertsIntegrations|The integration is currently inactive. Enable the integration to send the test alert.', +); + +export const DEFAULT_ERROR = __('Something went wrong on our end.'); diff --git a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js index 014f823cdc4..ea11ecb0c5b 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js +++ b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js @@ -83,7 +83,7 @@ export default [ 'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.', ), noDataMessage, - chartTitle: s__('UsageTrends|Issues & Merge Requests'), + chartTitle: s__('UsageTrends|Issues & merge requests'), yAxisTitle: s__('UsageTrends|Items'), xAxisTitle: s__('UsageTrends|Month'), queries: [ diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue index 0630cca93ae..80ad36d0519 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue @@ -45,7 +45,7 @@ export default { projects: s__('UsageTrends|Projects'), groups: s__('UsageTrends|Groups'), issues: s__('UsageTrends|Issues'), - mergeRequests: s__('UsageTrends|Merge Requests'), + mergeRequests: s__('UsageTrends|Merge requests'), pipelines: s__('UsageTrends|Pipelines'), }, loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'), diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 48005787d81..516235657cb 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -44,7 +44,7 @@ const Api = { projectMilestonesPath: '/api/:version/projects/:id/milestones', projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid', mergeRequestsPath: '/api/:version/merge_requests', - groupLabelsPath: '/groups/:namespace_path/-/labels', + groupLabelsPath: '/api/:version/groups/:namespace_path/labels', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', issuableTemplatesPath: '/:namespace_path/:project_path/templates/:type', projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', @@ -79,6 +79,7 @@ const Api = { issuePath: '/api/:version/projects/:id/issues/:issue_iid', tagsPath: '/api/:version/projects/:id/repository/tags', freezePeriodsPath: '/api/:version/projects/:id/freeze_periods', + freezePeriodPath: '/api/:version/projects/:id/freeze_periods/:freeze_period_id', usageDataIncrementCounterPath: '/api/:version/usage_data/increment_counter', usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users', featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists', @@ -282,7 +283,7 @@ const Api = { }, /** - * Get all Merge Requests for a project, eventually filtering based on + * Get all merge requests for a project, eventually filtering based on * supplied parameters * @param projectPath * @param params @@ -306,7 +307,7 @@ const Api = { return axios.post(url, options); }, - // Return Merge Request for project + // Return merge request for project projectMergeRequest(projectPath, mergeRequestId, params = {}) { const url = Api.buildUrl(Api.projectMergeRequestPath) .replace(':id', encodeURIComponent(projectPath)) @@ -401,18 +402,29 @@ const Api = { newLabel(namespacePath, projectPath, data, callback) { let url; + let payload; if (projectPath) { url = Api.buildUrl(Api.projectLabelsPath) .replace(':namespace_path', namespacePath) .replace(':project_path', projectPath); + payload = { + label: data, + }; } else { url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespacePath); + + // groupLabelsPath uses public API which accepts + // `name` and `color` props. + payload = { + name: data.title, + color: data.color, + }; } return axios .post(url, { - label: data, + ...payload, }) .then((res) => callback(res.data)) .catch((e) => callback(e.response.data)); @@ -784,7 +796,7 @@ const Api = { return axios.delete(url, { data }); }, - getRawFile(id, path, params = { ref: 'master' }) { + getRawFile(id, path, params = {}) { const url = Api.buildUrl(this.rawFilePath) .replace(':id', encodeURIComponent(id)) .replace(':path', encodeURIComponent(path)); @@ -832,6 +844,14 @@ const Api = { return axios.post(url, freezePeriod); }, + updateFreezePeriod(id, freezePeriod = {}) { + const url = Api.buildUrl(this.freezePeriodPath) + .replace(':id', encodeURIComponent(id)) + .replace(':freeze_period_id', encodeURIComponent(freezePeriod.id)); + + return axios.put(url, freezePeriod); + }, + trackRedisCounterEvent(event) { if (!gon.features?.usageDataApi) { return null; diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js index 5efc7063efa..27901120c53 100644 --- a/app/assets/javascripts/api/user_api.js +++ b/app/assets/javascripts/api/user_api.js @@ -55,12 +55,13 @@ export function getUserProjects(userId, query, options, callback) { .catch(() => flash(__('Something went wrong while fetching projects'))); } -export function updateUserStatus({ emoji, message, availability }) { +export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) { const url = buildApiUrl(USER_POST_STATUS_PATH); return axios.put(url, { emoji, message, availability, + clear_status_after: clearStatusAfter, }); } diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index dbdc7e43d2d..3a2f2078e44 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -446,7 +446,7 @@ export class AwardsHandler { createAwardButtonForVotesBlock(votesBlock, emojiName) { const buttonHtml = ` - <button class="btn award-control js-emoji-btn has-tooltip active" title="You"> + <button class="gl-button btn btn-default award-control js-emoji-btn has-tooltip active" title="You"> ${this.emoji.glEmojiTag(emojiName)} <span class="award-control-text js-counter">1</span> </button> diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index 9e5d70075f3..309af368df9 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -1,7 +1,11 @@ <script> import { GlLoadingIcon, GlTooltipDirective, GlIcon, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; export default { + i18n: { + buttonLabel: s__('Badges|Reload badge image'), + }, // name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 // eslint-disable-next-line @gitlab/require-i18n-strings name: 'Badge', @@ -94,7 +98,8 @@ export default { <gl-button v-show="hasError" v-gl-tooltip.hover - :title="s__('Badges|Reload badge image')" + :title="$options.i18n.buttonLabel" + :aria-label="$options.i18n.buttonLabel" category="tertiary" variant="confirm" type="button" diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index 756bcfdb3d0..753608cf6f7 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -41,13 +41,17 @@ export default { titleText() { const file = this.discussion ? this.discussion.diff_file : this.draft; - if (file) { + if (file?.file_path) { return file.file_path; } - return sprintf(__("%{authorsName}'s thread"), { - authorsName: this.discussion.notes.find((note) => !note.system).author.name, - }); + if (this.discussion) { + return sprintf(__("%{authorsName}'s thread"), { + authorsName: this.discussion.notes.find((note) => !note.system).author.name, + }); + } + + return __('Your new comment'); }, linePosition() { if (this.position?.position_type === IMAGE_DIFF_POSITION_TYPE) { @@ -94,7 +98,7 @@ export default { <span class="review-preview-item-header"> <gl-icon class="flex-shrink-0" :name="iconName" /> <span class="bold text-nowrap gl-align-items-center"> - <span class="review-preview-item-header-text block-truncated"> + <span class="review-preview-item-header-text block-truncated gl-ml-2"> {{ titleText }} </span> <template v-if="showLinePosition"> diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js index 0b085da1ff9..bec360e3b2e 100644 --- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -1,9 +1,7 @@ import { mapGetters } from 'vuex'; import { sprintf, s__, __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { - mixins: [glFeatureFlagsMixin()], props: { discussionId: { type: String, @@ -52,16 +50,18 @@ export default { return this.resolveDiscussion ? 'is-resolving-discussion' : 'is-unresolving-discussion'; }, resolveButtonTitle() { - if (this.isDraft || this.discussionId) return this.resolvedStatusMessage; + const escapeParameters = false; - let title = __('Mark as resolved'); + if (this.isDraft || this.discussionId) return this.resolvedStatusMessage; - if (this.glFeatures.removeResolveNote) { - title = __('Resolve thread'); - } + let title = __('Resolve thread'); if (this.resolvedBy) { - title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name }); + title = sprintf( + __('Resolved by %{name}'), + { name: this.resolvedBy.name }, + escapeParameters, + ); } return title; diff --git a/app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js b/app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js new file mode 100644 index 00000000000..5731474e3a4 --- /dev/null +++ b/app/assets/javascripts/behaviors/deprecated_remove_row_behavior.js @@ -0,0 +1,15 @@ +import $ from 'jquery'; + +export default function initDeprecatedRemoveRowBehavior() { + $('.js-remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() { + $(this).closest('li').addClass('gl-display-none!'); + }); + + $('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() { + $(this).parent().find('.btn').addClass('disabled'); + }); + + $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() { + $(this).closest('tr').addClass('gl-display-none!'); + }); +} diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 0cb13815c7e..5b5148a850b 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import { once } from 'lodash'; import { deprecatedCreateFlash as flash } from '~/flash'; +import { darkModeEnabled } from '~/lib/utils/color_utils'; import { __, sprintf } from '~/locale'; // Renders diagrams and flowcharts from text using Mermaid in any element with the @@ -27,37 +28,34 @@ let renderedMermaidBlocks = 0; let mermaidModule = {}; +export function initMermaid(mermaid) { + let theme = 'neutral'; + + if (darkModeEnabled()) { + theme = 'dark'; + } + + mermaid.initialize({ + // mermaid core options + mermaid: { + startOnLoad: false, + }, + // mermaidAPI options + theme, + flowchart: { + useMaxWidth: true, + htmlLabels: false, + }, + securityLevel: 'strict', + }); + + return mermaid; +} + function importMermaidModule() { return import(/* webpackChunkName: 'mermaid' */ 'mermaid') .then((mermaid) => { - let theme = 'neutral'; - const ideDarkThemes = ['dark', 'solarized-dark', 'monokai']; - - if ( - ideDarkThemes.includes(window.gon?.user_color_scheme) && - // if on the Web IDE page - document.querySelector('.ide') - ) { - theme = 'dark'; - } - - mermaid.initialize({ - // mermaid core options - mermaid: { - startOnLoad: false, - }, - // mermaidAPI options - theme, - flowchart: { - useMaxWidth: true, - htmlLabels: false, - }, - securityLevel: 'strict', - }); - - mermaidModule = mermaid; - - return mermaid; + mermaidModule = initMermaid(mermaid); }) .catch((err) => { flash(sprintf(__("Can't load mermaid module: %{err}"), { err })); diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index a8fe00d26e6..6abbd7f3243 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -1,6 +1,6 @@ import { memoize } from 'lodash'; import AccessorUtilities from '~/lib/utils/accessor'; -import { s__ } from '~/locale'; +import { __ } from '~/locale'; const isCustomizable = (command) => 'customizable' in command ? Boolean(command.customizable) : true; @@ -33,42 +33,608 @@ export const getCustomizations = memoize(() => { }); // All available commands +export const TOGGLE_KEYBOARD_SHORTCUTS_DIALOG = { + id: 'globalShortcuts.toggleKeyboardShortcutsDialog', + description: __('Toggle keyboard shortcuts help dialog'), + defaultKeys: ['?'], +}; + +export const GO_TO_YOUR_PROJECTS = { + id: 'globalShortcuts.goToYourProjects', + description: __('Go to your projects'), + defaultKeys: ['shift+p'], +}; + +export const GO_TO_YOUR_GROUPS = { + id: 'globalShortcuts.goToYourGroups', + description: __('Go to your groups'), + defaultKeys: ['shift+g'], +}; + +export const GO_TO_ACTIVITY_FEED = { + id: 'globalShortcuts.goToActivityFeed', + description: __('Go to the activity feed'), + defaultKeys: ['shift+a'], +}; + +export const GO_TO_MILESTONE_LIST = { + id: 'globalShortcuts.goToMilestoneList', + description: __('Go to the milestone list'), + defaultKeys: ['shift+l'], +}; + +export const GO_TO_YOUR_SNIPPETS = { + id: 'globalShortcuts.goToYourSnippets', + description: __('Go to your snippets'), + defaultKeys: ['shift+s'], +}; + +export const START_SEARCH = { + id: 'globalShortcuts.startSearch', + description: __('Start search'), + defaultKeys: ['s', '/'], +}; + +export const FOCUS_FILTER_BAR = { + id: 'globalShortcuts.focusFilterBar', + description: __('Focus filter bar'), + defaultKeys: ['f'], +}; + +export const GO_TO_YOUR_ISSUES = { + id: 'globalShortcuts.goToYourIssues', + description: __('Go to your issues'), + defaultKeys: ['shift+i'], +}; + +export const GO_TO_YOUR_MERGE_REQUESTS = { + id: 'globalShortcuts.goToYourMergeRequests', + description: __('Go to your merge requests'), + defaultKeys: ['shift+m'], +}; + +export const GO_TO_YOUR_TODO_LIST = { + id: 'globalShortcuts.goToYourTodoList', + description: __('Go to your To-Do list'), + defaultKeys: ['shift+t'], +}; + export const TOGGLE_PERFORMANCE_BAR = { id: 'globalShortcuts.togglePerformanceBar', - description: s__('KeyboardShortcuts|Toggle the Performance Bar'), - // eslint-disable-next-line @gitlab/require-i18n-strings - defaultKeys: ['p b'], + description: __('Toggle the Performance Bar'), + defaultKeys: ['p b'], // eslint-disable-line @gitlab/require-i18n-strings }; export const TOGGLE_CANARY = { id: 'globalShortcuts.toggleCanary', - description: s__('KeyboardShortcuts|Toggle GitLab Next'), - // eslint-disable-next-line @gitlab/require-i18n-strings - defaultKeys: ['g x'], + description: __('Toggle GitLab Next'), + defaultKeys: ['g x'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const BOLD_TEXT = { + id: 'editing.boldText', + description: __('Bold text'), + defaultKeys: ['mod+b'], + customizable: false, +}; + +export const ITALIC_TEXT = { + id: 'editing.italicText', + description: __('Italic text'), + defaultKeys: ['mod+i'], + customizable: false, +}; + +export const LINK_TEXT = { + id: 'editing.linkText', + description: __('Link text'), + defaultKeys: ['mod+k'], + customizable: false, +}; + +export const TOGGLE_MARKDOWN_PREVIEW = { + id: 'editing.toggleMarkdownPreview', + description: __('Toggle Markdown preview'), + // Note: Ideally, keyboard shortcuts should be made cross-platform by using the special `mod` key + // instead of binding both `ctrl` and `command` versions of the shortcut. + // See https://docs.gitlab.com/ee/development/fe_guide/keyboard_shortcuts.html#make-cross-platform-shortcuts. + // However, this particular shortcut has been in place since before the `mod` key was available. + // We've chosen to leave this implemented as-is for the time being to avoid breaking people's workflows. + // See discussion in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45308#note_527490548. + defaultKeys: ['ctrl+shift+p', 'command+shift+p'], +}; + +export const EDIT_RECENT_COMMENT = { + id: 'editing.editRecentComment', + description: __('Edit your most recent comment in a thread (from an empty textarea)'), + defaultKeys: ['up'], +}; + +export const EDIT_WIKI_PAGE = { + id: 'wiki.editWikiPage', + description: __('Edit wiki page'), + defaultKeys: ['e'], +}; + +export const REPO_GRAPH_SCROLL_LEFT = { + id: 'repositoryGraph.scrollLeft', + description: __('Scroll left'), + defaultKeys: ['left', 'h'], +}; + +export const REPO_GRAPH_SCROLL_RIGHT = { + id: 'repositoryGraph.scrollRight', + description: __('Scroll right'), + defaultKeys: ['right', 'l'], +}; + +export const REPO_GRAPH_SCROLL_UP = { + id: 'repositoryGraph.scrollUp', + description: __('Scroll up'), + defaultKeys: ['up', 'k'], +}; + +export const REPO_GRAPH_SCROLL_DOWN = { + id: 'repositoryGraph.scrollDown', + description: __('Scroll down'), + defaultKeys: ['down', 'j'], +}; + +export const REPO_GRAPH_SCROLL_TOP = { + id: 'repositoryGraph.scrollToTop', + description: __('Scroll to top'), + defaultKeys: ['shift+up', 'shift+k'], +}; + +export const REPO_GRAPH_SCROLL_BOTTOM = { + id: 'repositoryGraph.scrollToBottom', + description: __('Scroll to bottom'), + defaultKeys: ['shift+down', 'shift+j'], +}; + +export const GO_TO_PROJECT_OVERVIEW = { + id: 'project.goToOverview', + description: __("Go to the project's overview page"), + defaultKeys: ['g p'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_ACTIVITY_FEED = { + id: 'project.goToActivityFeed', + description: __("Go to the project's activity feed"), + defaultKeys: ['g v'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_RELEASES = { + id: 'project.goToReleases', + description: __('Go to releases'), + defaultKeys: ['g r'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_FILES = { + id: 'project.goToFiles', + description: __('Go to files'), + defaultKeys: ['g f'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_FIND_FILE = { + id: 'project.goToFindFile', + description: __('Go to find file'), + defaultKeys: ['t'], +}; + +export const GO_TO_PROJECT_COMMITS = { + id: 'project.goToCommits', + description: __('Go to commits'), + defaultKeys: ['g c'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_REPO_GRAPH = { + id: 'project.goToRepoGraph', + description: __('Go to repository graph'), + defaultKeys: ['g n'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_REPO_CHARTS = { + id: 'project.goToRepoCharts', + description: __('Go to repository charts'), + defaultKeys: ['g d'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_ISSUES = { + id: 'project.goToIssues', + description: __('Go to issues'), + defaultKeys: ['g i'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const NEW_ISSUE = { + id: 'project.newIssue', + description: __('New issue'), + defaultKeys: ['i'], +}; + +export const GO_TO_PROJECT_ISSUE_BOARDS = { + id: 'project.goToIssueBoards', + description: __('Go to issue boards'), + defaultKeys: ['g b'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_MERGE_REQUESTS = { + id: 'project.goToMergeRequests', + description: __('Go to merge requests'), + defaultKeys: ['g m'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_JOBS = { + id: 'project.goToJobs', + description: __('Go to jobs'), + defaultKeys: ['g j'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_METRICS = { + id: 'project.goToMetrics', + description: __('Go to metrics'), + defaultKeys: ['g l'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_ENVIRONMENTS = { + id: 'project.goToEnvironments', + description: __('Go to environments'), + defaultKeys: ['g e'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_KUBERNETES = { + id: 'project.goToKubernetes', + description: __('Go to kubernetes'), + defaultKeys: ['g k'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_SNIPPETS = { + id: 'project.goToSnippets', + description: __('Go to snippets'), + defaultKeys: ['g s'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const GO_TO_PROJECT_WIKI = { + id: 'project.goToWiki', + description: __('Go to wiki'), + defaultKeys: ['g w'], // eslint-disable-line @gitlab/require-i18n-strings +}; + +export const PROJECT_FILES_MOVE_SELECTION_UP = { + id: 'projectFiles.moveSelectionUp', + description: __('Move selection up'), + defaultKeys: ['up'], +}; + +export const PROJECT_FILES_MOVE_SELECTION_DOWN = { + id: 'projectFiles.moveSelectionDown', + description: __('Move selection down'), + defaultKeys: ['down'], +}; + +export const PROJECT_FILES_OPEN_SELECTION = { + id: 'projectFiles.openSelection', + description: __('Open Selection'), + defaultKeys: ['enter'], +}; + +export const PROJECT_FILES_GO_BACK = { + id: 'projectFiles.goBack', + description: __('Go back (while searching for files)'), + defaultKeys: ['esc'], +}; + +export const PROJECT_FILES_GO_TO_PERMALINK = { + id: 'projectFiles.goToFilePermalink', + description: __('Go to file permalink (while viewing a file)'), + defaultKeys: ['y'], +}; + +export const ISSUABLE_COMMENT_OR_REPLY = { + id: 'issuables.commentReply', + description: __('Comment/Reply (quoting selected text)'), + defaultKeys: ['r'], +}; + +export const ISSUABLE_EDIT_DESCRIPTION = { + id: 'issuables.editDescription', + description: __('Edit description'), + defaultKeys: ['e'], +}; + +export const ISSUABLE_CHANGE_LABEL = { + id: 'issuables.changeLabel', + description: __('Change label'), + defaultKeys: ['l'], +}; + +export const ISSUE_MR_CHANGE_ASSIGNEE = { + id: 'issuesMRs.changeAssignee', + description: __('Change assignee'), + defaultKeys: ['a'], +}; + +export const ISSUE_MR_CHANGE_MILESTONE = { + id: 'issuesMRs.changeMilestone', + description: __('Change milestone'), + defaultKeys: ['m'], +}; + +export const MR_NEXT_FILE_IN_DIFF = { + id: 'mergeRequests.nextFileInDiff', + description: __('Next file in diff'), + defaultKeys: [']', 'j'], +}; + +export const MR_PREVIOUS_FILE_IN_DIFF = { + id: 'mergeRequests.previousFileInDiff', + description: __('Previous file in diff'), + defaultKeys: ['[', 'k'], +}; + +export const MR_GO_TO_FILE = { + id: 'mergeRequests.goToFile', + description: __('Go to file'), + defaultKeys: ['t', 'mod+p'], + customizable: false, +}; + +export const MR_NEXT_UNRESOLVED_DISCUSSION = { + id: 'mergeRequests.nextUnresolvedDiscussion', + description: __('Next unresolved discussion'), + defaultKeys: ['n'], +}; + +export const MR_PREVIOUS_UNRESOLVED_DISCUSSION = { + id: 'mergeRequests.previousUnresolvedDiscussion', + description: __('Previous unresolved discussion'), + defaultKeys: ['p'], +}; + +export const MR_COPY_SOURCE_BRANCH_NAME = { + id: 'mergeRequests.copySourceBranchName', + description: __('Copy source branch name'), + defaultKeys: ['b'], +}; + +export const MR_COMMITS_NEXT_COMMIT = { + id: 'mergeRequestCommits.nextCommit', + description: __('Next commit'), + defaultKeys: ['c'], +}; + +export const MR_COMMITS_PREVIOUS_COMMIT = { + id: 'mergeRequestCommits.previousCommit', + description: __('Previous commit'), + defaultKeys: ['x'], +}; + +export const ISSUE_NEXT_DESIGN = { + id: 'issues.nextDesign', + description: __('Next design'), + defaultKeys: ['right'], +}; + +export const ISSUE_PREVIOUS_DESIGN = { + id: 'issues.previousDesign', + description: __('Previous design'), + defaultKeys: ['left'], +}; + +export const ISSUE_CLOSE_DESIGN = { + id: 'issues.closeDesign', + description: __('Close design'), + defaultKeys: ['esc'], +}; + +export const WEB_IDE_GO_TO_FILE = { + id: 'webIDE.goToFile', + description: __('Go to file'), + defaultKeys: ['mod+p'], }; export const WEB_IDE_COMMIT = { id: 'webIDE.commit', - description: s__('KeyboardShortcuts|Commit (when editing commit message)'), + description: __('Commit (when editing commit message)'), defaultKeys: ['mod+enter'], customizable: false, }; +export const METRICS_EXPAND_PANEL = { + id: 'metrics.expandPanel', + description: __('Expand panel'), + defaultKeys: ['e'], + customizable: false, +}; + +export const METRICS_VIEW_LOGS = { + id: 'metrics.viewLogs', + description: __('View logs'), + defaultKeys: ['l'], + customizable: false, +}; + +export const METRICS_DOWNLOAD_CSV = { + id: 'metrics.downloadCSV', + description: __('Download CSV'), + defaultKeys: ['d'], + customizable: false, +}; + +export const METRICS_COPY_LINK_TO_CHART = { + id: 'metrics.copyLinkToChart', + description: __('Copy link to chart'), + defaultKeys: ['c'], + customizable: false, +}; + +export const METRICS_SHOW_ALERTS = { + id: 'metrics.showAlerts', + description: __('Alerts'), + defaultKeys: ['a'], + customizable: false, +}; + // All keybinding groups export const GLOBAL_SHORTCUTS_GROUP = { id: 'globalShortcuts', - name: s__('KeyboardShortcuts|Global Shortcuts'), - keybindings: [TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY], + name: __('Global Shortcuts'), + keybindings: [ + TOGGLE_KEYBOARD_SHORTCUTS_DIALOG, + GO_TO_YOUR_PROJECTS, + GO_TO_YOUR_GROUPS, + GO_TO_ACTIVITY_FEED, + GO_TO_MILESTONE_LIST, + GO_TO_YOUR_SNIPPETS, + START_SEARCH, + FOCUS_FILTER_BAR, + GO_TO_YOUR_ISSUES, + GO_TO_YOUR_MERGE_REQUESTS, + GO_TO_YOUR_TODO_LIST, + TOGGLE_PERFORMANCE_BAR, + ], +}; + +export const EDITING_SHORTCUTS_GROUP = { + id: 'editing', + name: __('Editing'), + keybindings: [BOLD_TEXT, ITALIC_TEXT, LINK_TEXT, TOGGLE_MARKDOWN_PREVIEW, EDIT_RECENT_COMMENT], +}; + +export const WIKI_SHORTCUTS_GROUP = { + id: 'wiki', + name: __('Wiki'), + keybindings: [EDIT_WIKI_PAGE], +}; + +export const REPOSITORY_GRAPH_SHORTCUTS_GROUP = { + id: 'repositoryGraph', + name: __('Repository Graph'), + keybindings: [ + REPO_GRAPH_SCROLL_LEFT, + REPO_GRAPH_SCROLL_RIGHT, + REPO_GRAPH_SCROLL_UP, + REPO_GRAPH_SCROLL_DOWN, + REPO_GRAPH_SCROLL_TOP, + REPO_GRAPH_SCROLL_BOTTOM, + ], +}; + +export const PROJECT_SHORTCUTS_GROUP = { + id: 'project', + name: __('Project'), + keybindings: [ + GO_TO_PROJECT_OVERVIEW, + GO_TO_PROJECT_ACTIVITY_FEED, + GO_TO_PROJECT_RELEASES, + GO_TO_PROJECT_FILES, + GO_TO_PROJECT_FIND_FILE, + GO_TO_PROJECT_COMMITS, + GO_TO_PROJECT_REPO_GRAPH, + GO_TO_PROJECT_REPO_CHARTS, + GO_TO_PROJECT_ISSUES, + NEW_ISSUE, + GO_TO_PROJECT_ISSUE_BOARDS, + GO_TO_PROJECT_MERGE_REQUESTS, + GO_TO_PROJECT_JOBS, + GO_TO_PROJECT_METRICS, + GO_TO_PROJECT_ENVIRONMENTS, + GO_TO_PROJECT_KUBERNETES, + GO_TO_PROJECT_SNIPPETS, + GO_TO_PROJECT_WIKI, + ], +}; + +export const PROJECT_FILES_SHORTCUTS_GROUP = { + id: 'projectFiles', + name: __('Project Files'), + keybindings: [ + PROJECT_FILES_MOVE_SELECTION_UP, + PROJECT_FILES_MOVE_SELECTION_DOWN, + PROJECT_FILES_OPEN_SELECTION, + PROJECT_FILES_GO_BACK, + PROJECT_FILES_GO_TO_PERMALINK, + ], +}; + +export const ISSUABLE_SHORTCUTS_GROUP = { + id: 'issuables', + name: __('Epics, issues, and merge requests'), + keybindings: [ISSUABLE_COMMENT_OR_REPLY, ISSUABLE_EDIT_DESCRIPTION, ISSUABLE_CHANGE_LABEL], +}; + +export const ISSUE_MR_SHORTCUTS_GROUP = { + id: 'issuesMRs', + name: __('Issues and merge requests'), + keybindings: [ISSUE_MR_CHANGE_ASSIGNEE, ISSUE_MR_CHANGE_MILESTONE], }; -export const WEB_IDE_GROUP = { +export const MR_SHORTCUTS_GROUP = { + id: 'mergeRequests', + name: __('Merge requests'), + keybindings: [ + MR_NEXT_FILE_IN_DIFF, + MR_PREVIOUS_FILE_IN_DIFF, + MR_GO_TO_FILE, + MR_NEXT_UNRESOLVED_DISCUSSION, + MR_PREVIOUS_UNRESOLVED_DISCUSSION, + MR_COPY_SOURCE_BRANCH_NAME, + ], +}; + +export const MR_COMMITS_SHORTCUTS_GROUP = { + id: 'mergeRequestCommits', + name: __('Merge request commits'), + keybindings: [MR_COMMITS_NEXT_COMMIT, MR_COMMITS_PREVIOUS_COMMIT], +}; + +export const ISSUES_SHORTCUTS_GROUP = { + id: 'issues', + name: __('Issues'), + keybindings: [ISSUE_NEXT_DESIGN, ISSUE_PREVIOUS_DESIGN, ISSUE_CLOSE_DESIGN], +}; + +export const WEB_IDE_SHORTCUTS_GROUP = { id: 'webIDE', - name: s__('KeyboardShortcuts|Web IDE'), - keybindings: [WEB_IDE_COMMIT], + name: __('Web IDE'), + keybindings: [WEB_IDE_GO_TO_FILE, WEB_IDE_COMMIT], +}; + +export const METRICS_SHORTCUTS_GROUP = { + id: 'metrics', + name: __('Metrics'), + keybindings: [ + METRICS_EXPAND_PANEL, + METRICS_VIEW_LOGS, + METRICS_DOWNLOAD_CSV, + METRICS_COPY_LINK_TO_CHART, + METRICS_SHOW_ALERTS, + ], +}; + +export const MISC_SHORTCUTS_GROUP = { + id: 'misc', + name: __('Miscellaneous'), + keybindings: [TOGGLE_CANARY], }; /** All keybindings, grouped and ordered with descriptions */ -export const keybindingGroups = [GLOBAL_SHORTCUTS_GROUP, WEB_IDE_GROUP]; +export const keybindingGroups = [ + GLOBAL_SHORTCUTS_GROUP, + EDITING_SHORTCUTS_GROUP, + WIKI_SHORTCUTS_GROUP, + REPOSITORY_GRAPH_SHORTCUTS_GROUP, + PROJECT_SHORTCUTS_GROUP, + PROJECT_FILES_SHORTCUTS_GROUP, + ISSUABLE_SHORTCUTS_GROUP, + ISSUE_MR_SHORTCUTS_GROUP, + MR_SHORTCUTS_GROUP, + MR_COMMITS_SHORTCUTS_GROUP, + ISSUES_SHORTCUTS_GROUP, + WEB_IDE_SHORTCUTS_GROUP, + METRICS_SHORTCUTS_GROUP, + MISC_SHORTCUTS_GROUP, +]; /** * Gets keyboard shortcuts associated with a command diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js index e4ec68601e0..03cba78cf31 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js @@ -6,13 +6,29 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import findAndFollowLink from '~/lib/utils/navigation_utility'; import { refreshCurrentPage, visitUrl } from '~/lib/utils/url_utility'; - -import { keysFor, TOGGLE_PERFORMANCE_BAR, TOGGLE_CANARY } from './keybindings'; +import { + keysFor, + TOGGLE_KEYBOARD_SHORTCUTS_DIALOG, + START_SEARCH, + FOCUS_FILTER_BAR, + TOGGLE_PERFORMANCE_BAR, + TOGGLE_CANARY, + TOGGLE_MARKDOWN_PREVIEW, + GO_TO_YOUR_TODO_LIST, + GO_TO_ACTIVITY_FEED, + GO_TO_YOUR_ISSUES, + GO_TO_YOUR_MERGE_REQUESTS, + GO_TO_YOUR_PROJECTS, + GO_TO_YOUR_GROUPS, + GO_TO_MILESTONE_LIST, + GO_TO_YOUR_SNIPPETS, + GO_TO_PROJECT_FIND_FILE, +} from './keybindings'; import { disableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle'; const defaultStopCallback = Mousetrap.prototype.stopCallback; Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) { - if (['ctrl+shift+p', 'command+shift+p'].indexOf(combo) !== -1) { + if (keysFor(TOGGLE_MARKDOWN_PREVIEW).indexOf(combo) !== -1) { return false; } @@ -58,28 +74,41 @@ export default class Shortcuts { this.helpModalElement = null; this.helpModalVueInstance = null; - Mousetrap.bind('?', this.onToggleHelp); - Mousetrap.bind('s', Shortcuts.focusSearch); - Mousetrap.bind('/', Shortcuts.focusSearch); - Mousetrap.bind('f', this.focusFilter.bind(this)); + Mousetrap.bind(keysFor(TOGGLE_KEYBOARD_SHORTCUTS_DIALOG), this.onToggleHelp); + Mousetrap.bind(keysFor(START_SEARCH), Shortcuts.focusSearch); + Mousetrap.bind(keysFor(FOCUS_FILTER_BAR), this.focusFilter.bind(this)); Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar); Mousetrap.bind(keysFor(TOGGLE_CANARY), Shortcuts.onToggleCanary); const findFileURL = document.body.dataset.findFile; - Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos')); - Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity')); - Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues')); - Mousetrap.bind('shift+m', () => findAndFollowLink('.dashboard-shortcuts-merge_requests')); - Mousetrap.bind('shift+p', () => findAndFollowLink('.dashboard-shortcuts-projects')); - Mousetrap.bind('shift+g', () => findAndFollowLink('.dashboard-shortcuts-groups')); - Mousetrap.bind('shift+l', () => findAndFollowLink('.dashboard-shortcuts-milestones')); - Mousetrap.bind('shift+s', () => findAndFollowLink('.dashboard-shortcuts-snippets')); - - Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], Shortcuts.toggleMarkdownPreview); + Mousetrap.bind(keysFor(GO_TO_YOUR_TODO_LIST), () => findAndFollowLink('.shortcuts-todos')); + Mousetrap.bind(keysFor(GO_TO_ACTIVITY_FEED), () => + findAndFollowLink('.dashboard-shortcuts-activity'), + ); + Mousetrap.bind(keysFor(GO_TO_YOUR_ISSUES), () => + findAndFollowLink('.dashboard-shortcuts-issues'), + ); + Mousetrap.bind(keysFor(GO_TO_YOUR_MERGE_REQUESTS), () => + findAndFollowLink('.dashboard-shortcuts-merge_requests'), + ); + Mousetrap.bind(keysFor(GO_TO_YOUR_PROJECTS), () => + findAndFollowLink('.dashboard-shortcuts-projects'), + ); + Mousetrap.bind(keysFor(GO_TO_YOUR_GROUPS), () => + findAndFollowLink('.dashboard-shortcuts-groups'), + ); + Mousetrap.bind(keysFor(GO_TO_MILESTONE_LIST), () => + findAndFollowLink('.dashboard-shortcuts-milestones'), + ); + Mousetrap.bind(keysFor(GO_TO_YOUR_SNIPPETS), () => + findAndFollowLink('.dashboard-shortcuts-snippets'), + ); + + Mousetrap.bind(keysFor(TOGGLE_MARKDOWN_PREVIEW), Shortcuts.toggleMarkdownPreview); if (typeof findFileURL !== 'undefined' && findFileURL !== null) { - Mousetrap.bind('t', () => { + Mousetrap.bind(keysFor(GO_TO_PROJECT_FIND_FILE), () => { visitUrl(findFileURL); }); } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js index 11b4fcd4e1c..ab7fcbb35f1 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js @@ -1,4 +1,5 @@ import Mousetrap from 'mousetrap'; +import { keysFor, PROJECT_FILES_GO_TO_PERMALINK } from '~/behaviors/shortcuts/keybindings'; import { getLocationHash, updateHistory, @@ -28,7 +29,7 @@ export default class ShortcutsBlob extends Shortcuts { this.shortcircuitPermalinkButton(); - Mousetrap.bind('y', this.moveToFilePermalink.bind(this)); + Mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.moveToFilePermalink.bind(this)); } moveToFilePermalink() { diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js index f0d2ecfd210..992e571e596 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_find_file.js @@ -1,4 +1,11 @@ import Mousetrap from 'mousetrap'; +import { + keysFor, + PROJECT_FILES_MOVE_SELECTION_UP, + PROJECT_FILES_MOVE_SELECTION_DOWN, + PROJECT_FILES_OPEN_SELECTION, + PROJECT_FILES_GO_BACK, +} from '~/behaviors/shortcuts/keybindings'; import ShortcutsNavigation from './shortcuts_navigation'; export default class ShortcutsFindFile extends ShortcutsNavigation { @@ -10,7 +17,10 @@ export default class ShortcutsFindFile extends ShortcutsNavigation { Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) { if ( element === projectFindFile.inputElement[0] && - (combo === 'up' || combo === 'down' || combo === 'esc' || combo === 'enter') + (keysFor(PROJECT_FILES_MOVE_SELECTION_UP).includes(combo) || + keysFor(PROJECT_FILES_MOVE_SELECTION_DOWN).includes(combo) || + keysFor(PROJECT_FILES_GO_BACK).includes(combo) || + keysFor(PROJECT_FILES_OPEN_SELECTION).includes(combo)) ) { // when press up/down key in textbox, cursor prevent to move to home/end e.preventDefault(); @@ -20,9 +30,9 @@ export default class ShortcutsFindFile extends ShortcutsNavigation { return oldStopCallback.call(this, e, element, combo); }; - Mousetrap.bind('up', projectFindFile.selectRowUp); - Mousetrap.bind('down', projectFindFile.selectRowDown); - Mousetrap.bind('esc', projectFindFile.goToTree); - Mousetrap.bind('enter', projectFindFile.goToBlob); + Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_UP), projectFindFile.selectRowUp); + Mousetrap.bind(keysFor(PROJECT_FILES_MOVE_SELECTION_DOWN), projectFindFile.selectRowDown); + Mousetrap.bind(keysFor(PROJECT_FILES_GO_BACK), projectFindFile.goToTree); + Mousetrap.bind(keysFor(PROJECT_FILES_OPEN_SELECTION), projectFindFile.goToBlob); } } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue index 1277dd0ed37..49216cc4aa0 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_help.vue @@ -397,7 +397,7 @@ export default { <tbody> <tr> <th></th> - <th>{{ __('Epics, Issues, and Merge Requests') }}</th> + <th>{{ __('Epics, issues, and merge requests') }}</th> </tr> <tr> <td class="shortcut"> @@ -421,7 +421,7 @@ export default { <tbody> <tr> <th></th> - <th>{{ __('Issues and Merge Requests') }}</th> + <th>{{ __('Issues and merge requests') }}</th> </tr> <tr> <td class="shortcut"> @@ -439,7 +439,7 @@ export default { <tbody> <tr> <th></th> - <th>{{ __('Merge Requests') }}</th> + <th>{{ __('Merge requests') }}</th> </tr> <tr> <td class="shortcut"> @@ -485,7 +485,7 @@ export default { <tbody> <tr> <th></th> - <th>{{ __('Merge Request Commits') }}</th> + <th>{{ __('Merge request commits') }}</th> </tr> <tr> <td class="shortcut"> diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index 476745beb19..c2908133fd0 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -5,18 +5,33 @@ import { getSelectedFragment } from '~/lib/utils/common_utils'; import { isElementVisible } from '~/lib/utils/dom_utils'; import Sidebar from '../../right_sidebar'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; +import { + keysFor, + ISSUE_MR_CHANGE_ASSIGNEE, + ISSUE_MR_CHANGE_MILESTONE, + ISSUABLE_CHANGE_LABEL, + ISSUABLE_COMMENT_OR_REPLY, + ISSUABLE_EDIT_DESCRIPTION, + MR_COPY_SOURCE_BRANCH_NAME, +} from './keybindings'; import Shortcuts from './shortcuts'; export default class ShortcutsIssuable extends Shortcuts { constructor() { super(); - Mousetrap.bind('a', () => ShortcutsIssuable.openSidebarDropdown('assignee')); - Mousetrap.bind('m', () => ShortcutsIssuable.openSidebarDropdown('milestone')); - Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels')); - Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText); - Mousetrap.bind('e', ShortcutsIssuable.editIssue); - Mousetrap.bind('b', ShortcutsIssuable.copyBranchName); + Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_ASSIGNEE), () => + ShortcutsIssuable.openSidebarDropdown('assignee'), + ); + Mousetrap.bind(keysFor(ISSUE_MR_CHANGE_MILESTONE), () => + ShortcutsIssuable.openSidebarDropdown('milestone'), + ); + Mousetrap.bind(keysFor(ISSUABLE_CHANGE_LABEL), () => + ShortcutsIssuable.openSidebarDropdown('labels'), + ); + Mousetrap.bind(keysFor(ISSUABLE_COMMENT_OR_REPLY), ShortcutsIssuable.replyWithSelectedText); + Mousetrap.bind(keysFor(ISSUABLE_EDIT_DESCRIPTION), ShortcutsIssuable.editIssue); + Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), ShortcutsIssuable.copyBranchName); } static replyWithSelectedText() { @@ -105,7 +120,7 @@ export default class ShortcutsIssuable extends Shortcuts { static copyBranchName() { // There are two buttons - one that is shown when the sidebar // is expanded, and one that is shown when it's collapsed. - const allCopyBtns = Array.from(document.querySelectorAll('.sidebar-source-branch button')); + const allCopyBtns = Array.from(document.querySelectorAll('.js-sidebar-source-branch button')); // Select whichever button is currently visible so that // the "Copied" tooltip is shown when a click is simulated. diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js index b46b4132ba8..b188d3b0ec3 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js @@ -1,27 +1,63 @@ import Mousetrap from 'mousetrap'; import findAndFollowLink from '../../lib/utils/navigation_utility'; +import { + keysFor, + GO_TO_PROJECT_OVERVIEW, + GO_TO_PROJECT_ACTIVITY_FEED, + GO_TO_PROJECT_RELEASES, + GO_TO_PROJECT_FILES, + GO_TO_PROJECT_COMMITS, + GO_TO_PROJECT_JOBS, + GO_TO_PROJECT_REPO_GRAPH, + GO_TO_PROJECT_REPO_CHARTS, + GO_TO_PROJECT_ISSUES, + GO_TO_PROJECT_ISSUE_BOARDS, + GO_TO_PROJECT_MERGE_REQUESTS, + GO_TO_PROJECT_WIKI, + GO_TO_PROJECT_SNIPPETS, + GO_TO_PROJECT_KUBERNETES, + GO_TO_PROJECT_ENVIRONMENTS, + GO_TO_PROJECT_METRICS, + NEW_ISSUE, +} from './keybindings'; import Shortcuts from './shortcuts'; export default class ShortcutsNavigation extends Shortcuts { constructor() { super(); - Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project')); - Mousetrap.bind('g v', () => findAndFollowLink('.shortcuts-project-activity')); - Mousetrap.bind('g r', () => findAndFollowLink('.shortcuts-project-releases')); - Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree')); - Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits')); - Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds')); - Mousetrap.bind('g n', () => findAndFollowLink('.shortcuts-network')); - Mousetrap.bind('g d', () => findAndFollowLink('.shortcuts-repository-charts')); - Mousetrap.bind('g i', () => findAndFollowLink('.shortcuts-issues')); - Mousetrap.bind('g b', () => findAndFollowLink('.shortcuts-issue-boards')); - Mousetrap.bind('g m', () => findAndFollowLink('.shortcuts-merge_requests')); - Mousetrap.bind('g w', () => findAndFollowLink('.shortcuts-wiki')); - Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets')); - Mousetrap.bind('g k', () => findAndFollowLink('.shortcuts-kubernetes')); - Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-environments')); - Mousetrap.bind('g l', () => findAndFollowLink('.shortcuts-metrics')); - Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue')); + Mousetrap.bind(keysFor(GO_TO_PROJECT_OVERVIEW), () => findAndFollowLink('.shortcuts-project')); + Mousetrap.bind(keysFor(GO_TO_PROJECT_ACTIVITY_FEED), () => + findAndFollowLink('.shortcuts-project-activity'), + ); + Mousetrap.bind(keysFor(GO_TO_PROJECT_RELEASES), () => + findAndFollowLink('.shortcuts-project-releases'), + ); + Mousetrap.bind(keysFor(GO_TO_PROJECT_FILES), () => findAndFollowLink('.shortcuts-tree')); + Mousetrap.bind(keysFor(GO_TO_PROJECT_COMMITS), () => findAndFollowLink('.shortcuts-commits')); + Mousetrap.bind(keysFor(GO_TO_PROJECT_JOBS), () => findAndFollowLink('.shortcuts-builds')); + Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_GRAPH), () => + findAndFollowLink('.shortcuts-network'), + ); + Mousetrap.bind(keysFor(GO_TO_PROJECT_REPO_CHARTS), () => + findAndFollowLink('.shortcuts-repository-charts'), + ); + Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUES), () => findAndFollowLink('.shortcuts-issues')); + Mousetrap.bind(keysFor(GO_TO_PROJECT_ISSUE_BOARDS), () => + findAndFollowLink('.shortcuts-issue-boards'), + ); + Mousetrap.bind(keysFor(GO_TO_PROJECT_MERGE_REQUESTS), () => + findAndFollowLink('.shortcuts-merge_requests'), + ); + Mousetrap.bind(keysFor(GO_TO_PROJECT_WIKI), () => findAndFollowLink('.shortcuts-wiki')); + Mousetrap.bind(keysFor(GO_TO_PROJECT_SNIPPETS), () => findAndFollowLink('.shortcuts-snippets')); + Mousetrap.bind(keysFor(GO_TO_PROJECT_KUBERNETES), () => + findAndFollowLink('.shortcuts-kubernetes'), + ); + Mousetrap.bind(keysFor(GO_TO_PROJECT_ENVIRONMENTS), () => + findAndFollowLink('.shortcuts-environments'), + ); + Mousetrap.bind(keysFor(GO_TO_PROJECT_METRICS), () => findAndFollowLink('.shortcuts-metrics')); + Mousetrap.bind(keysFor(NEW_ISSUE), () => findAndFollowLink('.shortcuts-new-issue')); } } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js index 3e791e4673a..c33c092b009 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_network.js @@ -1,15 +1,24 @@ import Mousetrap from 'mousetrap'; +import { + keysFor, + REPO_GRAPH_SCROLL_BOTTOM, + REPO_GRAPH_SCROLL_DOWN, + REPO_GRAPH_SCROLL_LEFT, + REPO_GRAPH_SCROLL_RIGHT, + REPO_GRAPH_SCROLL_TOP, + REPO_GRAPH_SCROLL_UP, +} from './keybindings'; import ShortcutsNavigation from './shortcuts_navigation'; export default class ShortcutsNetwork extends ShortcutsNavigation { constructor(graph) { super(); - Mousetrap.bind(['left', 'h'], graph.scrollLeft); - Mousetrap.bind(['right', 'l'], graph.scrollRight); - Mousetrap.bind(['up', 'k'], graph.scrollUp); - Mousetrap.bind(['down', 'j'], graph.scrollDown); - Mousetrap.bind(['shift+up', 'shift+k'], graph.scrollTop); - Mousetrap.bind(['shift+down', 'shift+j'], graph.scrollBottom); + Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_LEFT), graph.scrollLeft); + Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_RIGHT), graph.scrollRight); + Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_UP), graph.scrollUp); + Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_DOWN), graph.scrollDown); + Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_TOP), graph.scrollTop); + Mousetrap.bind(keysFor(REPO_GRAPH_SCROLL_BOTTOM), graph.scrollBottom); } } diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue index 8418c0f66ac..6cbe443062a 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue @@ -1,9 +1,13 @@ <script> import { GlToggle } from '@gitlab/ui'; import AccessorUtilities from '~/lib/utils/accessor'; +import { __ } from '~/locale'; import { disableShortcuts, enableShortcuts, shouldDisableShortcuts } from './shortcuts_toggle'; export default { + i18n: { + toggleLabel: __('Keyboard shortcuts'), + }, components: { GlToggle, }, @@ -31,7 +35,7 @@ export default { <gl-toggle v-model="shortcutsEnabled" aria-describedby="shortcutsToggle" - label="Keyboard shortcuts" + :label="$options.i18n.toggleLabel" label-position="left" @change="onChange" /> diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js index c609936a02a..59c1d2654bc 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js @@ -1,11 +1,12 @@ import Mousetrap from 'mousetrap'; import findAndFollowLink from '../../lib/utils/navigation_utility'; +import { keysFor, EDIT_WIKI_PAGE } from './keybindings'; import ShortcutsNavigation from './shortcuts_navigation'; export default class ShortcutsWiki extends ShortcutsNavigation { constructor() { super(); - Mousetrap.bind('e', ShortcutsWiki.editWiki); + Mousetrap.bind(keysFor(EDIT_WIKI_PAGE), ShortcutsWiki.editWiki); } static editWiki() { diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue index eb7f45cba6f..f5f06436bcc 100644 --- a/app/assets/javascripts/blob/components/blob_content.vue +++ b/app/assets/javascripts/blob/components/blob_content.vue @@ -21,6 +21,11 @@ export default { default: '', required: false, }, + isRawContent: { + type: Boolean, + default: false, + required: false, + }, loading: { type: Boolean, default: true, @@ -65,6 +70,8 @@ export default { v-else ref="contentViewer" :content="content" + :is-raw-content="isRawContent" + :file-name="blob.name" :type="activeViewer.fileType" data-qa-selector="file_content" /> diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index a5c8050b772..e02217d0deb 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -19,6 +19,17 @@ export default class FileTemplateSelector { this.$dropdownToggleText = this.$wrapper.find('.dropdown-toggle-text'); this.initDropdown(); + this.selectInitialTemplate(); + } + + selectInitialTemplate() { + const template = this.$dropdown.data('selected'); + + if (!template) { + return; + } + + this.mediator.selectTemplateFile(this, template); } show() { @@ -27,6 +38,19 @@ export default class FileTemplateSelector { } this.$wrapper.removeClass('hidden'); + + /** + * We set the focus on the dropdown that was just shown. This is done so that, after selecting + * a template type, the template selector immediately receives the focus. + * This improves the UX of the tour as the suggest_gitlab_ci_yml popover requires its target to + * be have the focus to appear. This way, users don't have to interact with the template + * selector to actually see the first hint: it is shown as soon as the selector becomes visible. + * We also need a timeout here, otherwise the template type selector gets stuck and can not be + * closed anymore. + */ + setTimeout(() => { + this.$dropdown.focus(); + }, 0); } hide() { @@ -36,7 +60,7 @@ export default class FileTemplateSelector { } isHidden() { - return this.$wrapper.hasClass('hidden'); + return !this.$wrapper || this.$wrapper.hasClass('hidden'); } getToggleText() { diff --git a/app/assets/javascripts/blob/stl_viewer.js b/app/assets/javascripts/blob/stl_viewer.js index 339906adc34..0ea623a705a 100644 --- a/app/assets/javascripts/blob/stl_viewer.js +++ b/app/assets/javascripts/blob/stl_viewer.js @@ -9,8 +9,8 @@ export default () => { e.preventDefault(); - document.querySelector('.js-material-changer.active').classList.remove('active'); - target.classList.add('active'); + document.querySelector('.js-material-changer.selected').classList.remove('selected'); + target.classList.add('selected'); target.blur(); viewer.changeObjectMaterials(target.dataset.type); diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue index 6fee40fb061..aee8bf15e44 100644 --- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue +++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue @@ -108,7 +108,6 @@ export default { show :target="target" placement="right" - trigger="manual" container="viewport" :css-classes="['suggest-gitlab-ci-yml', 'ml-4']" > diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 7c8f6646c0d..ab2fc80e653 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -43,7 +43,7 @@ export default class EditBlob { blobPath: fileNameEl.value, blobContent: editorEl.innerText, }); - this.editor.use(new FileTemplateExtension()); + this.editor.use(new FileTemplateExtension({ instance: this.editor })); fileNameEl.addEventListener('change', () => { this.editor.updateModelLanguage(fileNameEl.value); @@ -82,7 +82,7 @@ export default class EditBlob { this.$editModePanes.hide(); - currentPane.fadeIn(200); + currentPane.show(); if (paneId === '#preview') { this.$toggleButton.hide(); diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 2cd25f58770..a8b870f9b8e 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,4 +1,4 @@ -import { sortBy } from 'lodash'; +import { sortBy, cloneDeep } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { ListType, NOT_FILTER } from './constants'; @@ -113,6 +113,37 @@ export function formatIssueInput(issueInput, boardConfig) { }; } +export function shouldCloneCard(fromListType, toListType) { + const involvesClosed = fromListType === ListType.closed || toListType === ListType.closed; + const involvesBacklog = fromListType === ListType.backlog || toListType === ListType.backlog; + + if (involvesClosed || involvesBacklog) { + return false; + } + + if (fromListType !== toListType) { + return true; + } + + return false; +} + +export function getMoveData(state, params) { + const { boardItems, boardItemsByListId, boardLists } = state; + const { itemId, fromListId, toListId } = params; + const fromListType = boardLists[fromListId].listType; + const toListType = boardLists[toListId].listType; + + return { + reordering: fromListId === toListId, + shouldClone: shouldCloneCard(fromListType, toListType), + itemNotInToList: !boardItemsByListId[toListId].includes(itemId), + originalIssue: cloneDeep(boardItems[itemId]), + originalIndex: boardItemsByListId[fromListId].indexOf(itemId), + ...params, + }; +} + export function moveItemListHelper(item, fromList, toList) { const updatedItem = item; if ( diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue index 3c7c792b787..d4b559add6e 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -1,23 +1,16 @@ <script> -import { - GlFormRadio, - GlFormRadioGroup, - GlLabel, - GlTooltipDirective as GlTooltip, -} from '@gitlab/ui'; +import { GlFormRadio, GlFormRadioGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; import { ListType } from '~/boards/constants'; import boardsStore from '~/boards/stores/boards_store'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { isScopedLabel } from '~/lib/utils/common_utils'; export default { components: { BoardAddNewColumnForm, GlFormRadio, GlFormRadioGroup, - GlLabel, }, directives: { GlTooltip, @@ -26,17 +19,12 @@ export default { data() { return { selectedId: null, + selectedLabel: null, }; }, computed: { ...mapState(['labels', 'labelsLoading']), ...mapGetters(['getListByLabelId', 'shouldUseGraphQL']), - selectedLabel() { - if (!this.selectedId) { - return null; - } - return this.labels.find(({ id }) => id === this.selectedId); - }, columnForSelected() { return this.getListByLabelId(this.selectedId); }, @@ -89,8 +77,13 @@ export default { this.fetchLabels(searchTerm); }, - showScopedLabels(label) { - return this.scopedLabelsAvailable && isScopedLabel(label); + setSelectedItem(selectedId) { + const label = this.labels.find(({ id }) => id === selectedId); + if (!selectedId || !label) { + this.selectedLabel = null; + } else { + this.selectedLabel = { ...label }; + } }, }, }; @@ -99,38 +92,39 @@ export default { <template> <board-add-new-column-form :loading="labelsLoading" - :form-description="__('A label list displays issues with the selected label.')" - :search-label="__('Select label')" + :none-selected="__('Select a label')" :search-placeholder="__('Search labels')" :selected-id="selectedId" @filter-items="filterItems" @add-list="addList" > - <template slot="selected"> - <gl-label - v-if="selectedLabel" - v-gl-tooltip - :title="selectedLabel.title" - :description="selectedLabel.description" - :background-color="selectedLabel.color" - :scoped="showScopedLabels(selectedLabel)" - /> + <template #selected> + <template v-if="selectedLabel"> + <span + class="dropdown-label-box gl-top-0 gl-flex-shrink-0" + :style="{ + backgroundColor: selectedLabel.color, + }" + ></span> + <div class="gl-text-truncate">{{ selectedLabel.title }}</div> + </template> </template> - <template slot="items"> + <template #items> <gl-form-radio-group v-if="labels.length > 0" v-model="selectedId" class="gl-overflow-y-auto gl-px-5 gl-pt-3" + @change="setSelectedItem" > <label v-for="label in labels" :key="label.id" - class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal" + class="gl-display-flex gl-mb-5 gl-font-weight-normal gl-overflow-break-word" > - <gl-form-radio :value="label.id" class="gl-mb-0" /> + <gl-form-radio :value="label.id" /> <span - class="dropdown-label-box gl-top-0" + class="dropdown-label-box gl-top-0 gl-flex-shrink-0" :style="{ backgroundColor: label.color, }" diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue index d85343a5390..70ba90bb1d4 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -1,5 +1,12 @@ <script> -import { GlButton, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlFormGroup, + GlIcon, + GlSearchBoxByType, + GlSkeletonLoader, +} from '@gitlab/ui'; import { mapActions } from 'vuex'; import { __ } from '~/locale'; @@ -8,13 +15,16 @@ export default { add: __('Add to board'), cancel: __('Cancel'), newList: __('New list'), - noneSelected: __('None'), noResults: __('No matching results'), + scope: __('Scope'), + scopeDescription: __('Issues must match this scope to appear in this list.'), selected: __('Selected'), }, components: { GlButton, + GlDropdown, GlFormGroup, + GlIcon, GlSearchBoxByType, GlSkeletonLoader, }, @@ -23,11 +33,12 @@ export default { type: Boolean, required: true, }, - formDescription: { + searchLabel: { type: String, - required: true, + required: false, + default: null, }, - searchLabel: { + noneSelected: { type: String, required: true, }, @@ -46,8 +57,23 @@ export default { searchValue: '', }; }, + watch: { + selectedId(val) { + if (val) { + this.$refs.dropdown.hide(true); + } + }, + }, methods: { ...mapActions(['setAddColumnFormVisibility']), + setFocus() { + this.$refs.searchBox.focusInput(); + }, + onHide() { + this.searchValue = ''; + this.$emit('filter-items', ''); + this.$emit('hide'); + }, }, }; </script> @@ -62,51 +88,64 @@ export default { class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white" > <h3 - class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + class="gl-font-size-h2 gl-px-5 gl-py-4 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" data-testid="board-add-column-form-title" > {{ $options.i18n.newList }} </h3> - <div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden"> - <slot name="select-list-type"> - <div class="gl-mb-5"></div> - </slot> + <div + class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-y-auto gl-align-items-flex-start" + > + <div class="gl-px-5"> + <h3 class="gl-font-lg gl-mt-5 gl-mb-2"> + {{ $options.i18n.scope }} + </h3> + <p class="gl-mb-3">{{ $options.i18n.scopeDescription }}</p> + </div> - <p class="gl-px-5">{{ formDescription }}</p> + <slot name="select-list-type"></slot> - <div class="gl-px-5 gl-pb-4"> - <label class="gl-mb-2">{{ $options.i18n.selected }}</label> - <slot name="selected"> - <div class="gl-text-gray-500">{{ $options.i18n.noneSelected }}</div> - </slot> - </div> + <gl-form-group class="gl-px-5 lg-mb-3 gl-max-w-full" :label="searchLabel"> + <gl-dropdown + ref="dropdown" + class="gl-mb-3 gl-max-w-full" + toggle-class="gl-max-w-full gl-display-flex gl-align-items-center gl-text-trunate" + boundary="viewport" + @shown="setFocus" + @hide="onHide" + > + <template #button-content> + <slot name="selected"> + <div>{{ noneSelected }}</div> + </slot> + <gl-icon class="dropdown-chevron gl-flex-shrink-0" name="chevron-down" /> + </template> - <gl-form-group - class="gl-mx-5 gl-mb-3" - :label="searchLabel" - label-for="board-available-column-entities" - > - <gl-search-box-by-type - id="board-available-column-entities" - v-model="searchValue" - debounce="250" - :placeholder="searchPlaceholder" - @input="$emit('filter-items', $event)" - /> - </gl-form-group> + <template #header> + <gl-search-box-by-type + ref="searchBox" + v-model="searchValue" + debounce="250" + class="gl-mt-0!" + :placeholder="searchPlaceholder" + @input="$emit('filter-items', $event)" + /> + </template> - <div v-if="loading" class="gl-px-5"> - <gl-skeleton-loader :width="500" :height="172"> - <rect width="480" height="20" x="10" y="15" rx="4" /> - <rect width="380" height="20" x="10" y="50" rx="4" /> - <rect width="430" height="20" x="10" y="85" rx="4" /> - </gl-skeleton-loader> - </div> + <div v-if="loading" class="gl-px-5"> + <gl-skeleton-loader :width="400" :height="172"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="330" height="20" x="10" y="85" rx="4" /> + </gl-skeleton-loader> + </div> - <slot v-else name="items"> - <p class="gl-mx-5">{{ $options.i18n.noResults }}</p> - </slot> + <slot v-else name="items"> + <p class="gl-mx-5">{{ $options.i18n.noResults }}</p> + </slot> + </gl-dropdown> + </gl-form-group> </div> <div class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue index 7c08e33be7e..85f001d9d61 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue @@ -13,9 +13,9 @@ export default { </script> <template> - <span class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list"> + <div class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list"> <gl-button variant="confirm" @click="setAddColumnFormVisibility(true)" >{{ __('Create list') }} </gl-button> - </span> + </div> </template> diff --git a/app/assets/javascripts/boards/components/board_blocked_icon.vue b/app/assets/javascripts/boards/components/board_blocked_icon.vue new file mode 100644 index 00000000000..0f92e714752 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_blocked_icon.vue @@ -0,0 +1,192 @@ +<script> +import { GlIcon, GlLink, GlPopover, GlLoadingIcon } from '@gitlab/ui'; +import { blockingIssuablesQueries, issuableTypes } from '~/boards/constants'; +import { IssueType } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { truncate } from '~/lib/utils/text_utility'; +import { __, n__, s__, sprintf } from '~/locale'; + +export default { + i18n: { + issuableType: { + [issuableTypes.issue]: __('issue'), + }, + }, + graphQLIdType: { + [issuableTypes.issue]: IssueType, + }, + referenceFormatter: { + [issuableTypes.issue]: (r) => r.split('/')[1], + }, + defaultDisplayLimit: 3, + textTruncateWidth: 80, + components: { + GlIcon, + GlPopover, + GlLink, + GlLoadingIcon, + }, + blockingIssuablesQueries, + props: { + item: { + type: Object, + required: true, + }, + uniqueId: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: true, + validator(value) { + return [issuableTypes.issue].includes(value); + }, + }, + }, + apollo: { + blockingIssuables: { + skip() { + return this.skip; + }, + query() { + return blockingIssuablesQueries[this.issuableType].query; + }, + variables() { + return { + id: convertToGraphQLId(this.$options.graphQLIdType[this.issuableType], this.item.id), + }; + }, + update(data) { + this.skip = true; + + return data?.issuable?.blockingIssuables?.nodes || []; + }, + error(error) { + const message = sprintf(s__('Boards|Failed to fetch blocking %{issuableType}s'), { + issuableType: this.issuableTypeText, + }); + this.$emit('blocking-issuables-error', { error, message }); + }, + }, + }, + data() { + return { + skip: true, + blockingIssuables: [], + }; + }, + computed: { + displayedIssuables() { + const { defaultDisplayLimit, referenceFormatter } = this.$options; + return this.blockingIssuables.slice(0, defaultDisplayLimit).map((i) => { + return { + ...i, + title: truncate(i.title, this.$options.textTruncateWidth), + reference: referenceFormatter[this.issuableType](i.reference), + }; + }); + }, + loading() { + return this.$apollo.queries.blockingIssuables.loading; + }, + issuableTypeText() { + return this.$options.i18n.issuableType[this.issuableType]; + }, + blockedLabel() { + return sprintf( + n__( + 'Boards|Blocked by %{blockedByCount} %{issuableType}', + 'Boards|Blocked by %{blockedByCount} %{issuableType}s', + this.item.blockedByCount, + ), + { + blockedByCount: this.item.blockedByCount, + issuableType: this.issuableTypeText, + }, + ); + }, + glIconId() { + return `blocked-icon-${this.uniqueId}`; + }, + hasMoreIssuables() { + return this.item.blockedByCount > this.$options.defaultDisplayLimit; + }, + displayedIssuablesCount() { + return this.hasMoreIssuables + ? this.item.blockedByCount - this.$options.defaultDisplayLimit + : this.item.blockedByCount; + }, + moreIssuablesText() { + return sprintf( + n__( + 'Boards|+ %{displayedIssuablesCount} more %{issuableType}', + 'Boards|+ %{displayedIssuablesCount} more %{issuableType}s', + this.displayedIssuablesCount, + ), + { + displayedIssuablesCount: this.displayedIssuablesCount, + issuableType: this.issuableTypeText, + }, + ); + }, + viewAllIssuablesText() { + return sprintf(s__('Boards|View all blocking %{issuableType}s'), { + issuableType: this.issuableTypeText, + }); + }, + loadingMessage() { + return sprintf(s__('Boards|Retrieving blocking %{issuableType}s'), { + issuableType: this.issuableTypeText, + }); + }, + }, + methods: { + handleMouseEnter() { + this.skip = false; + }, + }, +}; +</script> +<template> + <div class="gl-display-inline"> + <gl-icon + :id="glIconId" + ref="icon" + name="issue-block" + class="issue-blocked-icon gl-mr-2 gl-cursor-pointer" + data-testid="issue-blocked-icon" + @mouseenter="handleMouseEnter" + /> + <gl-popover :target="glIconId" placement="top"> + <template #title + ><span data-testid="popover-title">{{ blockedLabel }}</span></template + > + <template v-if="loading"> + <gl-loading-icon /> + <p class="gl-mt-4 gl-mb-0 gl-font-small">{{ loadingMessage }}</p> + </template> + <template v-else> + <ul class="gl-list-style-none gl-p-0"> + <li v-for="issuable in displayedIssuables" :key="issuable.id"> + <gl-link :href="issuable.webUrl" class="gl-text-blue-500! gl-font-sm">{{ + issuable.reference + }}</gl-link> + <p class="gl-mb-3 gl-display-block!" data-testid="issuable-title"> + {{ issuable.title }} + </p> + </li> + </ul> + <div v-if="hasMoreIssuables" class="gl-mt-4"> + <p class="gl-mb-3" data-testid="hidden-blocking-count">{{ moreIssuablesText }}</p> + <gl-link + data-testid="view-all-issues" + :href="`${item.webUrl}#related-issues`" + class="gl-text-blue-500! gl-font-sm" + >{{ viewAllIssuablesText }}</gl-link + > + </div> + </template> + </gl-popover> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index d4d6b17a589..9ff2cdd76d0 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -10,6 +10,7 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import { ListType } from '../constants'; import eventHub from '../eventhub'; +import BoardBlockedIcon from './board_blocked_icon.vue'; import IssueDueDate from './issue_due_date.vue'; import IssueTimeEstimate from './issue_time_estimate.vue'; @@ -22,6 +23,7 @@ export default { IssueDueDate, IssueTimeEstimate, IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), + BoardBlockedIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -52,7 +54,7 @@ export default { }; }, computed: { - ...mapState(['isShowingLabels']), + ...mapState(['isShowingLabels', 'issuableType']), ...mapGetters(['isEpicBoard']), cappedAssignees() { // e.g. maxRender is 4, @@ -114,7 +116,7 @@ export default { }, }, methods: { - ...mapActions(['performSearch']), + ...mapActions(['performSearch', 'setError']), isIndexLessThanlimit(index) { return index < this.limitBeforeCounter; }, @@ -164,14 +166,12 @@ export default { <div> <div class="gl-display-flex" dir="auto"> <h4 class="board-card-title gl-mb-0 gl-mt-0"> - <gl-icon + <board-blocked-icon v-if="item.blocked" - v-gl-tooltip - name="issue-block" - :title="blockedLabel" - class="issue-blocked-icon gl-mr-2" - :aria-label="blockedLabel" - data-testid="issue-blocked-icon" + :item="item" + :unique-id="`${item.id}${list.id}`" + :issuable-type="issuableType" + @blocking-issuables-error="setError" /> <gl-icon v-if="item.confidential" @@ -181,13 +181,9 @@ export default { class="confidential-icon gl-mr-2" :aria-label="__('Confidential')" /> - <a - :href="item.path || item.webUrl || ''" - :title="item.title" - class="js-no-trigger" - @mousemove.stop - >{{ item.title }}</a - > + <a :href="item.path || item.webUrl || ''" :title="item.title" @mousemove.stop>{{ + item.title + }}</a> </h4> </div> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> diff --git a/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue b/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue new file mode 100644 index 00000000000..15bff1226a6 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card_loading_skeleton.vue @@ -0,0 +1,26 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + name: 'BoardCardLoading', + components: { + GlSkeletonLoader, + }, +}; +</script> + +<template> + <div + class="board-card-skeleton gl-mb-3 gl-bg-white gl-rounded-base gl-p-5 gl-border-1 gl-border-solid gl-border-gray-50" + > + <div class="board-card-skeleton-inner"> + <gl-skeleton-loader :width="340" :height="100"> + <rect width="340" height="16" rx="4" /> + <rect y="30" width="118" height="16" rx="8" /> + <rect x="122" y="30" width="130" height="16" rx="8" /> + <rect y="62" width="38" height="16" rx="4" /> + <circle cx="320" cy="68" r="16" /> + </gl-skeleton-loader> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index e9c4237d759..a4b1e6adacf 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -17,21 +17,20 @@ export default { gon.features?.graphqlBoardLists || gon.features?.epicBoards ? BoardColumn : BoardColumnDeprecated, - BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), + BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'), + EpicBoardContentSidebar: () => + import('ee_component/boards/components/epic_board_content_sidebar.vue'), EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, }, mixins: [glFeatureFlagMixin()], + inject: ['canAdminList'], props: { lists: { type: Array, required: false, default: () => [], }, - canAdminList: { - type: Boolean, - required: true, - }, disabled: { type: Boolean, required: true, @@ -69,7 +68,7 @@ export default { }, }, methods: { - ...mapActions(['moveList']), + ...mapActions(['moveList', 'unsetError']), afterFormEnters() { const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); @@ -99,8 +98,8 @@ export default { </script> <template> - <div> - <gl-alert v-if="error" variant="danger" :dismissible="false"> + <div v-cloak data-qa-selector="boards_list"> + <gl-alert v-if="error" variant="danger" :dismissible="true" @dismiss="unsetError"> {{ error }} </gl-alert> <component @@ -127,13 +126,23 @@ export default { </component> <epics-swimlanes - v-else + v-else-if="boardListsToUse.length" ref="swimlanes" :lists="boardListsToUse" :can-admin-list="canAdminList" :disabled="disabled" /> - <board-content-sidebar v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" /> + <board-content-sidebar + v-if="isSwimlanesOn || glFeatures.graphqlBoardLists" + class="boards-sidebar" + data-testid="issue-boards-sidebar" + /> + + <epic-board-content-sidebar + v-else-if="isEpicBoard" + class="boards-sidebar" + data-testid="epic-boards-sidebar" + /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue new file mode 100644 index 00000000000..46359cc2bca --- /dev/null +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -0,0 +1,96 @@ +<script> +import { GlDrawer } from '@gitlab/ui'; +import { mapState, mapActions, mapGetters } from 'vuex'; +import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue'; +import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue'; +import BoardSidebarMilestoneSelect from '~/boards/components/sidebar/board_sidebar_milestone_select.vue'; +import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue'; +import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; +import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; +import { ISSUABLE } from '~/boards/constants'; +import { contentTop } from '~/lib/utils/common_utils'; +import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + headerHeight: `${contentTop()}px`, + components: { + GlDrawer, + BoardSidebarTitle, + SidebarAssigneesWidget, + BoardSidebarTimeTracker, + BoardSidebarLabelsSelect, + BoardSidebarDueDate, + BoardSidebarSubscription, + BoardSidebarMilestoneSelect, + BoardSidebarEpicSelect: () => + import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'), + BoardSidebarWeightInput: () => + import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'), + SidebarIterationWidget: () => + import('ee_component/sidebar/components/sidebar_iteration_widget.vue'), + }, + mixins: [glFeatureFlagsMixin()], + computed: { + ...mapGetters([ + 'isSidebarOpen', + 'activeBoardItem', + 'groupPathForActiveIssue', + 'projectPathForActiveIssue', + ]), + ...mapState(['sidebarType', 'issuableType']), + isIssuableSidebar() { + return this.sidebarType === ISSUABLE; + }, + showSidebar() { + return this.isIssuableSidebar && this.isSidebarOpen; + }, + fullPath() { + return this.activeBoardItem?.referencePath?.split('#')[0] || ''; + }, + }, + methods: { + ...mapActions(['toggleBoardItem', 'setAssignees']), + handleClose() { + this.toggleBoardItem({ boardItem: this.activeBoardItem, sidebarType: this.sidebarType }); + }, + }, +}; +</script> + +<template> + <gl-drawer + v-if="showSidebar" + :open="isSidebarOpen" + :header-height="$options.headerHeight" + @close="handleClose" + > + <template #header>{{ __('Issue details') }}</template> + <template #default> + <board-sidebar-title /> + <sidebar-assignees-widget + :iid="activeBoardItem.iid" + :full-path="fullPath" + :initial-assignees="activeBoardItem.assignees" + class="assignee" + @assignees-updated="setAssignees" + /> + <board-sidebar-epic-select class="epic" /> + <div> + <board-sidebar-milestone-select /> + <sidebar-iteration-widget + :iid="activeBoardItem.iid" + :workspace-path="projectPathForActiveIssue" + :iterations-workspace-path="groupPathForActiveIssue" + :issuable-type="issuableType" + class="gl-mt-5" + /> + </div> + <board-sidebar-time-tracker class="swimlanes-sidebar-time-tracker" /> + <board-sidebar-due-date /> + <board-sidebar-labels-select class="labels" /> + <board-sidebar-weight-input v-if="glFeatures.issueWeights" class="weight" /> + <board-sidebar-subscription class="subscriptions" /> + </template> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/boards/components/board_extra_actions.vue b/app/assets/javascripts/boards/components/board_extra_actions.vue deleted file mode 100644 index b802ccc7882..00000000000 --- a/app/assets/javascripts/boards/components/board_extra_actions.vue +++ /dev/null @@ -1,57 +0,0 @@ -<script> -import { GlTooltip, GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - name: 'BoardExtraActions', - components: { - GlTooltip, - GlButton, - }, - props: { - canAdminList: { - type: Boolean, - required: true, - }, - disabled: { - type: Boolean, - required: true, - }, - openModal: { - type: Function, - required: true, - }, - }, - computed: { - tooltipTitle() { - if (this.disabled) { - return __('Please add a list to your board first'); - } - - return ''; - }, - }, -}; -</script> - -<template> - <div class="board-extra-actions"> - <span ref="addIssuesButtonTooltip" class="gl-ml-3"> - <gl-button - v-if="canAdminList" - type="button" - data-placement="bottom" - data-track-event="click_button" - data-track-label="board_add_issues" - :disabled="disabled" - :aria-disabled="disabled" - @click="openModal" - > - {{ __('Add issues') }} - </gl-button> - </span> - <gl-tooltip v-if="disabled" :target="() => $refs.addIssuesButtonTooltip" placement="bottom"> - {{ tooltipTitle }} - </gl-tooltip> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index d8504dcfb0f..78da4137d69 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -107,7 +107,7 @@ export default { }; }, computed: { - ...mapGetters(['isEpicBoard', 'isGroupBoard', 'isProjectBoard']), + ...mapGetters(['isIssueBoard', 'isGroupBoard', 'isProjectBoard']), isNewForm() { return this.currentPage === formType.new; }, @@ -127,7 +127,7 @@ export default { if (this.isDeleteForm) { return 'danger'; } - return 'info'; + return 'confirm'; }, title() { if (this.readonly) { @@ -163,6 +163,9 @@ export default { currentMutation() { return this.board.id ? updateBoardMutation : createBoardMutation; }, + deleteMutation() { + return destroyBoardMutation; + }, baseMutationVariables() { const { board } = this; const variables = { @@ -182,7 +185,7 @@ export default { groupPath: this.isGroupBoard ? this.fullPath : undefined, }; }, - boardScopeMutationVariables() { + issueBoardScopeMutationVariables() { /* eslint-disable @gitlab/require-i18n-strings */ return { weight: this.board.weight, @@ -193,13 +196,18 @@ export default { this.board.milestone?.id || this.board.milestone?.id === 0 ? convertToGraphQLId('Milestone', this.board.milestone.id) : null, - labelIds: this.board.labels.map(fullLabelId), iterationId: this.board.iteration_id ? convertToGraphQLId('Iteration', this.board.iteration_id) : null, }; /* eslint-enable @gitlab/require-i18n-strings */ }, + boardScopeMutationVariables() { + return { + labelIds: this.board.labels.map(fullLabelId), + ...(this.isIssueBoard && this.issueBoardScopeMutationVariables), + }; + }, mutationVariables() { return { ...this.baseMutationVariables, @@ -239,17 +247,20 @@ export default { return this.boardUpdateResponse(response.data); }, + async deleteBoard() { + await this.$apollo.mutate({ + mutation: this.deleteMutation, + variables: { + id: fullBoardId(this.board.id), + }, + }); + }, async submit() { if (this.board.name.length === 0) return; this.isLoading = true; if (this.isDeleteForm) { try { - await this.$apollo.mutate({ - mutation: destroyBoardMutation, - variables: { - id: fullBoardId(this.board.id), - }, - }); + await this.deleteBoard(); visitUrl(this.rootPath); } catch { Flash(this.$options.i18n.deleteErrorMessage); @@ -324,7 +335,7 @@ export default { /> <board-scope - v-if="scopedIssueBoardFeatureEnabled && !isEpicBoard" + v-if="scopedIssueBoardFeatureEnabled" :collapse-scope="isNewForm" :board="board" :can-admin-board="canAdminBoard" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index ae8434be312..94e29f3ad86 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -190,7 +190,7 @@ export default { } this.moveItem({ - itemId, + itemId: Number(itemId), itemIid, itemPath, fromListId: from.dataset.listId, diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index d59fbcc1b31..0534e027c86 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -134,9 +134,10 @@ export default { e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list'); const toBoardType = containerEl.dataset.boardType; const cloneActions = { - label: ['milestone', 'assignee'], - assignee: ['milestone', 'label'], - milestone: ['label', 'assignee'], + label: ['milestone', 'assignee', 'iteration'], + assignee: ['milestone', 'label', 'iteration'], + milestone: ['label', 'assignee', 'iteration'], + iteration: ['label', 'assignee', 'milestone'], }; if (toBoardType) { diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 6ccaec4a633..ca66ad6934a 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -328,6 +328,7 @@ export default { <div class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" + data-testid="issue-count-badge" :class="{ 'gl-display-none!': list.collapsed && isSwimlanesHeader, 'gl-p-0': list.collapsed, diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index a81c28733cd..144cae15ab3 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -2,23 +2,23 @@ import { GlButton } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; +import BoardNewIssueMixin from 'ee_else_ce/boards/mixins/board_new_issue'; import { __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../eventhub'; import ProjectSelect from './project_select.vue'; export default { name: 'BoardNewIssue', i18n: { - submit: __('Submit issue'), + submit: __('Create issue'), cancel: __('Cancel'), }, components: { ProjectSelect, GlButton, }, - mixins: [glFeatureFlagMixin()], - inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], + mixins: [BoardNewIssueMixin], + inject: ['groupId'], props: { list: { type: Object, @@ -53,14 +53,11 @@ export default { submit(e) { e.preventDefault(); + const { title } = this; const labels = this.list.label ? [this.list.label] : []; const assignees = this.list.assignee ? [this.list.assignee] : []; const milestone = getMilestone(this.list); - const weight = this.weightFeatureAvailable ? this.boardWeight : undefined; - - const { title } = this; - eventHub.$emit(`scroll-board-list-${this.list.id}`); return this.addListNewIssue({ @@ -70,7 +67,7 @@ export default { assigneeIds: assignees?.map((a) => a?.id), milestoneId: milestone?.id, projectPath: this.selectedProject.fullPath, - weight: weight >= 0 ? weight : null, + ...this.extraIssueInput(), }, list: this.list, }).then(() => { diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue index 16f23dfff0e..1218941065f 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue @@ -121,7 +121,7 @@ export default { variant="success" category="primary" type="submit" - >{{ __('Submit issue') }}</gl-button + >{{ __('Create issue') }}</gl-button > <gl-button ref="cancelButton" diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 7cfedad0aed..997655c346a 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -22,13 +22,7 @@ export default { import('ee_component/boards/components/board_settings_list_types.vue'), }, mixins: [glFeatureFlagMixin()], - props: { - canAdminList: { - type: Boolean, - required: false, - default: false, - }, - }, + inject: ['canAdminList'], data() { return { ListType, diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue index 7ec99e51f5b..fdb60d0ae6a 100644 --- a/app/assets/javascripts/boards/components/config_toggle.vue +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -15,7 +15,8 @@ export default { props: { boardsStore: { type: Object, - required: true, + required: false, + default: null, }, canAdminList: { type: Boolean, @@ -26,11 +27,6 @@ export default { required: true, }, }, - data() { - return { - state: this.boardsStore.state, - }; - }, computed: { buttonText() { return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope'); @@ -42,7 +38,9 @@ export default { methods: { showPage() { eventHub.$emit('showBoardModal', formType.edit); - return this.boardsStore.showPage(formType.edit); + if (this.boardsStore) { + this.boardsStore.showPage(formType.edit); + } }, }, }; diff --git a/app/assets/javascripts/boards/components/filtered_search.vue b/app/assets/javascripts/boards/components/filtered_search.vue deleted file mode 100644 index 8505ea39a6b..00000000000 --- a/app/assets/javascripts/boards/components/filtered_search.vue +++ /dev/null @@ -1,54 +0,0 @@ -<script> -import { mapActions } from 'vuex'; -import { historyPushState } from '~/lib/utils/common_utils'; -import { setUrlParams } from '~/lib/utils/url_utility'; -import { __ } from '~/locale'; -import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; - -export default { - i18n: { - search: __('Search'), - }, - components: { FilteredSearch }, - props: { - search: { - type: String, - required: false, - default: '', - }, - }, - computed: { - initialSearch() { - return [{ type: 'filtered-search-term', value: { data: this.search } }]; - }, - }, - methods: { - ...mapActions(['performSearch']), - handleSearch(filters) { - let itemValue = ''; - const [item] = filters; - - if (filters.length === 0) { - itemValue = ''; - } else { - itemValue = item?.value?.data; - } - - historyPushState(setUrlParams({ search: itemValue }, window.location.href)); - - this.performSearch(); - }, - }, -}; -</script> - -<template> - <filtered-search - class="gl-w-full" - namespace="" - :tokens="[]" - :search-input-placeholder="$options.i18n.search" - :initial-filter-value="initialSearch" - @onFilter="handleSearch" - /> -</template> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue deleted file mode 100644 index 486b012e3d2..00000000000 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ /dev/null @@ -1,84 +0,0 @@ -<script> -import { GlButton, GlSprintf } from '@gitlab/ui'; -import { __ } from '~/locale'; -import modalMixin from '../../mixins/modal_mixins'; -import ModalStore from '../../stores/modal_store'; - -export default { - components: { - GlButton, - GlSprintf, - }, - mixins: [modalMixin], - props: { - newIssuePath: { - type: String, - required: true, - }, - emptyStateSvg: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - contents() { - const obj = { - title: __("You haven't added any issues to your project yet"), - content: __( - 'An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable.', - ), - }; - - if (this.activeTab === 'selected') { - obj.title = __("You haven't selected any issues yet"); - obj.content = __( - 'Go back to %{tagStart}Open issues%{tagEnd} and select some issues to add to your board.', - ); - } - - return obj; - }, - }, -}; -</script> - -<template> - <section class="empty-state d-flex mt-0 h-100"> - <div class="row w-100 my-auto mx-0"> - <div class="col-12 col-md-6 order-md-last"> - <aside class="svg-content d-none d-md-block"><img :src="emptyStateSvg" /></aside> - </div> - <div class="col-12 col-md-6 order-md-first"> - <div class="text-content"> - <h4>{{ contents.title }}</h4> - <p> - <gl-sprintf :message="contents.content"> - <template #tag="{ content }"> - <strong>{{ content }}</strong> - </template> - </gl-sprintf> - </p> - <gl-button - v-if="activeTab === 'all'" - :href="newIssuePath" - category="secondary" - variant="success" - > - {{ __('New issue') }} - </gl-button> - <gl-button - v-if="activeTab === 'selected'" - category="primary" - variant="default" - @click="changeTab('all')" - > - {{ __('Open issues') }} - </gl-button> - </div> - </div> - </div> - </section> -</template> diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js deleted file mode 100644 index 2fb38a549f3..00000000000 --- a/app/assets/javascripts/boards/components/modal/filters.js +++ /dev/null @@ -1,27 +0,0 @@ -import FilteredSearchContainer from '../../../filtered_search/container'; -import FilteredSearchBoards from '../../filtered_search_boards'; - -export default { - name: 'modal-filters', - props: { - store: { - type: Object, - required: true, - }, - }, - mounted() { - FilteredSearchContainer.container = this.$el; - - this.filteredSearch = new FilteredSearchBoards(this.store); - this.filteredSearch.setup(); - this.filteredSearch.removeTokens(); - this.filteredSearch.handleInputPlaceholder(); - this.filteredSearch.toggleClearSearchButton(); - }, - destroyed() { - this.filteredSearch.cleanup(); - FilteredSearchContainer.container = document; - this.store.path = ''; - }, - template: '#js-board-modal-filter', -}; diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue deleted file mode 100644 index 05e1219bc70..00000000000 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer'; -import { deprecatedCreateFlash as Flash } from '../../../flash'; -import { __, n__ } from '../../../locale'; -import modalMixin from '../../mixins/modal_mixins'; -import boardsStore from '../../stores/boards_store'; -import ModalStore from '../../stores/modal_store'; -import ListsDropdown from './lists_dropdown.vue'; - -export default { - components: { - ListsDropdown, - GlButton, - }, - mixins: [modalMixin, footerEEMixin], - data() { - return { - modal: ModalStore.store, - state: boardsStore.state, - }; - }, - computed: { - submitDisabled() { - return !ModalStore.selectedCount(); - }, - submitText() { - const count = ModalStore.selectedCount(); - if (!count) return __('Add issues'); - return n__(`Add %d issue`, `Add %d issues`, count); - }, - }, - methods: { - buildUpdateRequest(list) { - return { - add_label_ids: [list.label.id], - }; - }, - addIssues() { - const firstListIndex = 1; - const list = this.modal.selectedList || this.state.lists[firstListIndex]; - const selectedIssues = ModalStore.getSelectedIssues(); - const issueIds = selectedIssues.map((issue) => issue.id); - const req = this.buildUpdateRequest(list); - - // Post the data to the backend - boardsStore.bulkUpdate(issueIds, req).catch(() => { - Flash(__('Failed to update issues, please try again.')); - - selectedIssues.forEach((issue) => { - list.removeIssue(issue); - list.issuesSize -= 1; - }); - }); - - // Add the issues on the frontend - selectedIssues.forEach((issue) => { - list.addIssue(issue); - list.issuesSize += 1; - }); - - this.toggleModal(false); - }, - }, -}; -</script> -<template> - <footer class="form-actions add-issues-footer"> - <div class="float-left"> - <gl-button :disabled="submitDisabled" category="primary" variant="success" @click="addIssues"> - {{ submitText }} - </gl-button> - <span class="inline add-issues-footer-to-list">{{ __('to list') }}</span> - <lists-dropdown /> - </div> - <gl-button class="float-right" @click="toggleModal(false)"> - {{ __('Cancel') }} - </gl-button> - </footer> -</template> diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue deleted file mode 100644 index c3a71e7177a..00000000000 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ /dev/null @@ -1,80 +0,0 @@ -<script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; -import modalMixin from '../../mixins/modal_mixins'; -import ModalStore from '../../stores/modal_store'; -import ModalFilters from './filters'; -import ModalTabs from './tabs.vue'; - -export default { - components: { - ModalTabs, - ModalFilters, - GlButton, - }, - mixins: [modalMixin], - props: { - projectId: { - type: Number, - required: true, - }, - labelPath: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - selectAllText() { - if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { - return __('Select all'); - } - - return __('Deselect all'); - }, - showSearch() { - return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; - }, - }, - methods: { - toggleAll() { - this.$refs.selectAllBtn.$el.blur(); - - ModalStore.toggleAll(); - }, - }, -}; -</script> -<template> - <div> - <header class="add-issues-header border-top-0 form-actions"> - <h2 class="m-0"> - Add issues - <gl-button - category="tertiary" - icon="close" - class="close" - data-dismiss="modal" - :aria-label="__('Close')" - @click="toggleModal(false)" - /> - </h2> - </header> - <modal-tabs v-if="!loading && issuesCount > 0" /> - <div v-if="showSearch" class="d-flex gl-mb-3"> - <modal-filters :store="filter" /> - <gl-button - ref="selectAllBtn" - category="secondary" - variant="success" - class="gl-ml-3" - @click="toggleAll" - > - {{ selectAllText }} - </gl-button> - </div> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue deleted file mode 100644 index 5af90c1ee66..00000000000 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ /dev/null @@ -1,151 +0,0 @@ -<script> -/* global ListIssue */ -import { GlLoadingIcon } from '@gitlab/ui'; -import boardsStore from '~/boards/stores/boards_store'; -import { urlParamsToObject } from '~/lib/utils/common_utils'; -import ModalStore from '../../stores/modal_store'; -import EmptyState from './empty_state.vue'; -import ModalFooter from './footer.vue'; -import ModalHeader from './header.vue'; -import ModalList from './list.vue'; - -export default { - components: { - EmptyState, - ModalHeader, - ModalList, - ModalFooter, - GlLoadingIcon, - }, - props: { - newIssuePath: { - type: String, - required: true, - }, - emptyStateSvg: { - type: String, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - labelPath: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - showList() { - if (this.activeTab === 'selected') { - return this.selectedIssues.length > 0; - } - - return this.issuesCount > 0; - }, - showEmptyState() { - if (!this.loading && this.issuesCount === 0) { - return true; - } - - return this.activeTab === 'selected' && this.selectedIssues.length === 0; - }, - }, - watch: { - page() { - this.loadIssues(); - }, - showAddIssuesModal() { - if (this.showAddIssuesModal && !this.issues.length) { - this.loading = true; - const loadingDone = () => { - this.loading = false; - }; - - this.loadIssues().then(loadingDone).catch(loadingDone); - } else if (!this.showAddIssuesModal) { - this.issues = []; - this.selectedIssues = []; - this.issuesCount = false; - } - }, - filter: { - handler() { - if (this.$el.tagName) { - this.page = 1; - this.filterLoading = true; - const loadingDone = () => { - this.filterLoading = false; - }; - - this.loadIssues(true).then(loadingDone).catch(loadingDone); - } - }, - deep: true, - }, - }, - created() { - this.page = 1; - }, - methods: { - loadIssues(clearIssues = false) { - if (!this.showAddIssuesModal) return false; - - return boardsStore - .getBacklog({ - ...urlParamsToObject(this.filter.path), - page: this.page, - per: this.perPage, - }) - .then((res) => res.data) - .then((data) => { - if (clearIssues) { - this.issues = []; - } - - data.issues.forEach((issueObj) => { - const issue = new ListIssue(issueObj); - const foundSelectedIssue = ModalStore.findSelectedIssue(issue); - issue.selected = Boolean(foundSelectedIssue); - - this.issues.push(issue); - }); - - this.loadingNewPage = false; - - if (!this.issuesCount) { - this.issuesCount = data.size; - } - }) - .catch(() => { - // TODO: handle request error - }); - }, - }, -}; -</script> -<template> - <div - v-if="showAddIssuesModal" - class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100" - > - <div class="add-issues-container d-flex flex-column m-auto rounded"> - <modal-header :project-id="projectId" :label-path="labelPath" /> - <modal-list v-if="!loading && showList && !filterLoading" :empty-state-svg="emptyStateSvg" /> - <empty-state - v-if="showEmptyState" - :new-issue-path="newIssuePath" - :empty-state-svg="emptyStateSvg" - /> - <section v-if="loading || filterLoading" class="add-issues-list d-flex h-100 text-center"> - <div class="add-issues-list-loading w-100 align-self-center"> - <gl-loading-icon size="md" /> - </div> - </section> - <modal-footer /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue deleted file mode 100644 index e66cae0ce18..00000000000 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ /dev/null @@ -1,141 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import ModalStore from '../../stores/modal_store'; -import BoardCardInner from '../board_card_inner.vue'; - -export default { - components: { - BoardCardInner, - GlIcon, - }, - props: { - emptyStateSvg: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - loopIssues() { - if (this.activeTab === 'all') { - return this.issues; - } - - return this.selectedIssues; - }, - groupedIssues() { - const groups = []; - this.loopIssues.forEach((issue, i) => { - const index = i % this.columns; - - if (!groups[index]) { - groups.push([]); - } - - groups[index].push(issue); - }); - - return groups; - }, - }, - watch: { - activeTab() { - if (this.activeTab === 'all') { - ModalStore.purgeUnselectedIssues(); - } - }, - }, - mounted() { - this.scrollHandlerWrapper = this.scrollHandler.bind(this); - this.setColumnCountWrapper = this.setColumnCount.bind(this); - this.setColumnCount(); - - this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); - window.addEventListener('resize', this.setColumnCountWrapper); - }, - beforeDestroy() { - this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); - window.removeEventListener('resize', this.setColumnCountWrapper); - }, - methods: { - scrollHandler() { - const currentPage = Math.floor(this.issues.length / this.perPage); - - if ( - this.scrollTop() > this.scrollHeight() - 100 && - !this.loadingNewPage && - currentPage === this.page - ) { - this.loadingNewPage = true; - this.page += 1; - } - }, - toggleIssue(e, issue) { - if (e.target.tagName !== 'A') { - ModalStore.toggleIssue(issue); - } - }, - listHeight() { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight() { - return this.$refs.list.scrollHeight; - }, - scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); - }, - showIssue(issue) { - if (this.activeTab === 'all') return true; - - const index = ModalStore.selectedIssueIndex(issue); - - return index !== -1; - }, - setColumnCount() { - const breakpoint = bp.getBreakpointSize(); - - if (breakpoint === 'xl' || breakpoint === 'lg') { - this.columns = 3; - } else if (breakpoint === 'md') { - this.columns = 2; - } else { - this.columns = 1; - } - }, - }, -}; -</script> -<template> - <section ref="list" class="add-issues-list add-issues-list-columns d-flex h-100"> - <div - v-if="issuesCount > 0 && issues.length === 0" - class="empty-state add-issues-empty-state-filter text-center" - > - <div class="svg-content"><img :src="emptyStateSvg" /></div> - <div class="text-content"> - <h4>{{ __('There are no issues to show.') }}</h4> - </div> - </div> - <div v-for="(group, index) in groupedIssues" :key="index" class="add-issues-list-column"> - <div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent"> - <div - :class="{ 'is-active': issue.selected }" - class="board-card position-relative p-3 rounded" - @click="toggleIssue($event, issue)" - > - <board-card-inner :item="issue" /> - <gl-icon - v-if="issue.selected" - :aria-label="'Issue #' + issue.id + ' selected'" - name="mobile-issue-close" - aria-checked="true" - class="issue-card-selected text-center" - /> - </div> - </div> - </div> - </section> -</template> diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue deleted file mode 100644 index 2065568d275..00000000000 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue +++ /dev/null @@ -1,49 +0,0 @@ -<script> -import { GlLink, GlIcon } from '@gitlab/ui'; -import boardsStore from '../../stores/boards_store'; -import ModalStore from '../../stores/modal_store'; - -export default { - components: { - GlLink, - GlIcon, - }, - data() { - return { - modal: ModalStore.store, - state: boardsStore.state, - }; - }, - computed: { - selected() { - return this.modal.selectedList || this.state.lists[1]; - }, - }, - destroyed() { - this.modal.selectedList = null; - }, -}; -</script> -<template> - <div class="dropdown inline"> - <button class="dropdown-menu-toggle" type="button" data-toggle="dropdown" aria-expanded="false"> - <span :style="{ backgroundColor: selected.label.color }" class="dropdown-label-box"> </span> - {{ selected.title }} <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon" /> - </button> - <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> - <ul> - <li v-for="(list, i) in state.lists" v-if="list.type == 'label'" :key="i"> - <gl-link - :class="{ 'is-active': list.id == selected.id }" - href="#" - role="button" - @click.prevent="modal.selectedList = list" - > - <span :style="{ backgroundColor: list.label.color }" class="dropdown-label-box"> </span> - {{ list.title }} - </gl-link> - </li> - </ul> - </div> - </div> -</template> diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue deleted file mode 100644 index 0b717f516db..00000000000 --- a/app/assets/javascripts/boards/components/modal/tabs.vue +++ /dev/null @@ -1,42 +0,0 @@ -<script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlTabs, GlTab, GlBadge } from '@gitlab/ui'; -import modalMixin from '../../mixins/modal_mixins'; -import ModalStore from '../../stores/modal_store'; - -export default { - components: { - GlTabs, - GlTab, - GlBadge, - }, - mixins: [modalMixin], - data() { - return ModalStore.store; - }, - computed: { - selectedCount() { - return ModalStore.selectedCount(); - }, - }, - destroyed() { - this.activeTab = 'all'; - }, -}; -</script> -<template> - <gl-tabs class="gl-mt-3"> - <gl-tab @click.prevent="changeTab('all')"> - <template slot="title"> - <span>Open issues</span> - <gl-badge size="sm" class="gl-tab-counter-badge">{{ issuesCount }}</gl-badge> - </template> - </gl-tab> - <gl-tab @click.prevent="changeTab('selected')"> - <template slot="title"> - <span>Selected issues</span> - <gl-badge size="sm" class="gl-tab-counter-badge">{{ selectedCount }}</gl-badge> - </template> - </gl-tab> - </gl-tabs> -</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue index 61863bbe2a9..352a25ef6d9 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -98,14 +98,14 @@ export default { <gl-button v-if="canUpdate" variant="link" - class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle" + class="gl-text-gray-900! gl-ml-5 js-sidebar-dropdown-toggle edit-link" data-testid="edit-button" @click="toggle" > {{ __('Edit') }} </gl-button> </header> - <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content"> + <div v-show="!edit" class="gl-text-gray-500 value" data-testid="collapsed-content"> <slot name="collapsed">{{ __('None') }}</slot> </div> <div v-show="edit" data-testid="expanded-content"> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue index 6d928337396..13e1e232676 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -18,16 +18,16 @@ export default { }; }, computed: { - ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), + ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']), hasDueDate() { - return this.activeIssue.dueDate != null; + return this.activeBoardItem.dueDate != null; }, parsedDueDate() { if (!this.hasDueDate) { return null; } - return parsePikadayDate(this.activeIssue.dueDate); + return parsePikadayDate(this.activeBoardItem.dueDate); }, formattedDueDate() { if (!this.hasDueDate) { @@ -69,6 +69,7 @@ export default { <board-editable-item ref="sidebarItem" class="board-sidebar-due-date" + data-testid="sidebar-due-date" :title="$options.i18n.dueDate" :loading="loading" @open="openDatePicker" diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index 55b1596ee18..f78be83cd82 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -21,9 +21,9 @@ export default { }; }, computed: { - ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), + ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']), selectedLabels() { - const { labels = [] } = this.activeIssue; + const { labels = [] } = this.activeBoardItem; return labels.map((label) => ({ ...label, @@ -31,7 +31,7 @@ export default { })); }, issueLabels() { - const { labels = [] } = this.activeIssue; + const { labels = [] } = this.activeBoardItem; return labels.map((label) => ({ ...label, @@ -40,7 +40,7 @@ export default { }, }, methods: { - ...mapActions(['setActiveIssueLabels']), + ...mapActions(['setActiveBoardItemLabels']), async setLabels(payload) { this.loading = true; this.$refs.sidebarItem.collapse(); @@ -52,7 +52,7 @@ export default { .map((label) => label.id); const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; - await this.setActiveIssueLabels(input); + await this.setActiveBoardItemLabels(input); } catch (e) { createFlash({ message: __('An error occurred while updating labels.') }); } finally { @@ -65,7 +65,7 @@ export default { try { const removeLabelIds = [getIdFromGraphQLId(id)]; const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue }; - await this.setActiveIssueLabels(input); + await this.setActiveBoardItemLabels(input); } catch (e) { createFlash({ message: __('An error occurred when removing the label.') }); } finally { diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue index 829f1c72806..ad225c7bf5c 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue @@ -56,20 +56,20 @@ export default { }, }, computed: { - ...mapGetters(['activeIssue']), + ...mapGetters(['activeBoardItem']), hasMilestone() { - return this.activeIssue.milestone !== null; + return this.activeBoardItem.milestone !== null; }, groupFullPath() { - const { referencePath = '' } = this.activeIssue; + const { referencePath = '' } = this.activeBoardItem; return referencePath.slice(0, referencePath.indexOf('/')); }, projectPath() { - const { referencePath = '' } = this.activeIssue; + const { referencePath = '' } = this.activeBoardItem; return referencePath.slice(0, referencePath.indexOf('#')); }, dropdownText() { - return this.activeIssue.milestone?.title ?? this.$options.i18n.noMilestone; + return this.activeBoardItem.milestone?.title ?? this.$options.i18n.noMilestone; }, }, methods: { @@ -113,11 +113,12 @@ export default { ref="sidebarItem" :title="$options.i18n.milestone" :loading="loading" - @open="handleOpen()" + data-testid="sidebar-milestones" + @open="handleOpen" @close="handleClose" > <template v-if="hasMilestone" #collapsed> - <strong class="gl-text-gray-900">{{ activeIssue.milestone.title }}</strong> + <strong class="gl-text-gray-900">{{ activeBoardItem.milestone.title }}</strong> </template> <gl-dropdown ref="dropdown" @@ -130,7 +131,7 @@ export default { <gl-dropdown-item data-testid="no-milestone-item" :is-check-item="true" - :is-checked="!activeIssue.milestone" + :is-checked="!activeBoardItem.milestone" @click="setMilestone(null)" > {{ $options.i18n.noMilestone }} @@ -142,7 +143,7 @@ export default { v-for="milestone in milestones" :key="milestone.id" :is-check-item="true" - :is-checked="activeIssue.milestone && milestone.id === activeIssue.milestone.id" + :is-checked="activeBoardItem.milestone && milestone.id === activeBoardItem.milestone.id" data-testid="milestone-item" @click="setMilestone(milestone.id)" > diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue index f01c8e8fa20..376985f7cb6 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue @@ -21,27 +21,31 @@ export default { components: { GlToggle, }, + inject: ['emailsDisabled'], data() { return { loading: false, }; }, computed: { - ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), + ...mapGetters(['activeBoardItem', 'projectPathForActiveIssue', 'isEpicBoard']), + isEmailsDisabled() { + return this.isEpicBoard ? this.emailsDisabled : this.activeBoardItem.emailsDisabled; + }, notificationText() { - return this.activeIssue.emailsDisabled + return this.isEmailsDisabled ? this.$options.i18n.header.subscribeDisabledDescription : this.$options.i18n.header.title; }, }, methods: { - ...mapActions(['setActiveIssueSubscribed']), + ...mapActions(['setActiveItemSubscribed']), async handleToggleSubscription() { this.loading = true; try { - await this.setActiveIssueSubscribed({ - subscribed: !this.activeIssue.subscribed, + await this.setActiveItemSubscribed({ + subscribed: !this.activeBoardItem.subscribed, projectPath: this.projectPathForActiveIssue, }); } catch (error) { @@ -61,8 +65,8 @@ export default { > <span data-testid="notification-header-text"> {{ notificationText }} </span> <gl-toggle - v-if="!activeIssue.emailsDisabled" - :value="activeIssue.subscribed" + v-if="!isEmailsDisabled" + :value="activeBoardItem.subscribed" :is-loading="loading" :label="$options.i18n.header.title" label-position="hidden" 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 new file mode 100644 index 00000000000..96d444980a8 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_time_tracker.vue @@ -0,0 +1,25 @@ +<script> +import { mapGetters } from 'vuex'; +import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; + +export default { + components: { + IssuableTimeTracker, + }, + inject: ['timeTrackingLimitToHours'], + computed: { + ...mapGetters(['activeBoardItem']), + }, +}; +</script> + +<template> + <issuable-time-tracker + :time-estimate="activeBoardItem.timeEstimate" + :time-spent="activeBoardItem.totalTimeSpent" + :human-time-estimate="activeBoardItem.humanTimeEstimate" + :human-time-spent="activeBoardItem.humanTotalTimeSpent" + :limit-to-hours="timeTrackingLimitToHours" + :show-collapsed="false" + /> +</template> diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue index 95864bd62a7..b8d3107c377 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_issue_title.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_title.vue @@ -27,12 +27,12 @@ export default { }; }, computed: { - ...mapGetters({ issue: 'activeIssue' }), + ...mapGetters({ item: 'activeBoardItem' }), pendingChangesStorageKey() { - return this.getPendingChangesKey(this.issue); + return this.getPendingChangesKey(this.item); }, projectPath() { - const referencePath = this.issue.referencePath || ''; + const referencePath = this.item.referencePath || ''; return referencePath.slice(0, referencePath.indexOf('#')); }, validationState() { @@ -40,29 +40,29 @@ export default { }, }, watch: { - issue: { - handler(updatedIssue, formerIssue) { - if (formerIssue?.title !== this.title) { - localStorage.setItem(this.getPendingChangesKey(formerIssue), this.title); + item: { + handler(updatedItem, formerItem) { + if (formerItem?.title !== this.title) { + localStorage.setItem(this.getPendingChangesKey(formerItem), this.title); } - this.title = updatedIssue.title; + this.title = updatedItem.title; this.setPendingState(); }, immediate: true, }, }, methods: { - ...mapActions(['setActiveIssueTitle']), - getPendingChangesKey(issue) { - if (!issue) { + ...mapActions(['setActiveItemTitle']), + getPendingChangesKey(item) { + if (!item) { return ''; } return joinPaths( window.location.pathname.slice(1), - String(issue.id), - 'issue-title-pending-changes', + String(item.id), + 'item-title-pending-changes', ); }, async setPendingState() { @@ -78,7 +78,7 @@ export default { } }, cancel() { - this.title = this.issue.title; + this.title = this.item.title; this.$refs.sidebarItem.collapse(); this.showChangesAlert = false; localStorage.removeItem(this.pendingChangesStorageKey); @@ -86,24 +86,24 @@ export default { async setTitle() { this.$refs.sidebarItem.collapse(); - if (!this.title || this.title === this.issue.title) { + if (!this.title || this.title === this.item.title) { return; } try { this.loading = true; - await this.setActiveIssueTitle({ title: this.title, projectPath: this.projectPath }); + await this.setActiveItemTitle({ title: this.title, projectPath: this.projectPath }); localStorage.removeItem(this.pendingChangesStorageKey); this.showChangesAlert = false; } catch (e) { - this.title = this.issue.title; + this.title = this.item.title; createFlash({ message: this.$options.i18n.updateTitleError }); } finally { this.loading = false; } }, handleOffClick() { - if (this.title !== this.issue.title) { + if (this.title !== this.item.title) { this.showChangesAlert = true; localStorage.setItem(this.pendingChangesStorageKey, this.title); } else { @@ -112,11 +112,11 @@ export default { }, }, i18n: { - issueTitlePlaceholder: __('Issue title'), + titlePlaceholder: __('Title'), submitButton: __('Save changes'), cancelButton: __('Cancel'), - updateTitleError: __('An error occurred when updating the issue title'), - invalidFeedback: __('An issue title is required'), + updateTitleError: __('An error occurred when updating the title'), + invalidFeedback: __('A title is required'), reviewYourChanges: __('Changes to the title have not been saved'), }, }; @@ -131,10 +131,10 @@ export default { @off-click="handleOffClick" > <template #title> - <span class="gl-font-weight-bold" data-testid="issue-title">{{ issue.title }}</span> + <span class="gl-font-weight-bold" data-testid="item-title">{{ item.title }}</span> </template> <template #collapsed> - <span class="gl-text-gray-800">{{ issue.referencePath }}</span> + <span class="gl-text-gray-800">{{ item.referencePath }}</span> </template> <gl-alert v-if="showChangesAlert" variant="warning" class="gl-mb-5" :dismissible="false"> {{ $options.i18n.reviewYourChanges }} @@ -144,7 +144,7 @@ export default { <gl-form-input v-model="title" v-autofocusonshow - :placeholder="$options.i18n.issueTitlePlaceholder" + :placeholder="$options.i18n.titlePlaceholder" :state="validationState" /> </gl-form-group> diff --git a/app/assets/javascripts/boards/components/toggle_focus.vue b/app/assets/javascripts/boards/components/toggle_focus.vue index 74805f8a681..49f5e7d20a9 100644 --- a/app/assets/javascripts/boards/components/toggle_focus.vue +++ b/app/assets/javascripts/boards/components/toggle_focus.vue @@ -38,14 +38,16 @@ export default { </script> <template> - <div class="board-extra-actions gl-ml-3 gl-display-flex gl-align-items-center"> + <div class="gl-ml-3 gl-display-none gl-md-display-flex gl-align-items-center"> <gl-button ref="toggleFocusModeButton" v-gl-tooltip + category="tertiary" :icon="isFullscreen ? 'minimize' : 'maximize'" class="js-focus-mode-btn" data-qa-selector="focus_mode_button" :title="$options.i18n.toggleFocusMode" + :aria-label="$options.i18n.toggleFocusMode" @click="toggleFocusMode" /> </div> diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js index 7f327c5764d..41938d8e284 100644 --- a/app/assets/javascripts/boards/config_toggle.js +++ b/app/assets/javascripts/boards/config_toggle.js @@ -2,14 +2,15 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import ConfigToggle from './components/config_toggle.vue'; -export default (boardsStore) => { +export default (boardsStore = undefined) => { const el = document.querySelector('.js-board-config'); if (!el) { return; } - gl.boardConfigToggle = new Vue({ + // eslint-disable-next-line no-new + new Vue({ el, render(h) { return h(ConfigToggle, { diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 65ebfe7be6c..4ebd30fe67b 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,4 +1,9 @@ import { __ } from '~/locale'; +import updateEpicSubscriptionMutation from '~/sidebar/queries/update_epic_subscription.mutation.graphql'; +import updateEpicTitleMutation from '~/sidebar/queries/update_epic_title.mutation.graphql'; +import boardBlockingIssuesQuery from './graphql/board_blocking_issues.query.graphql'; +import issueSetSubscriptionMutation from './graphql/issue_set_subscription.mutation.graphql'; +import issueSetTitleMutation from './graphql/issue_set_title.mutation.graphql'; export const issuableTypes = { issue: 'issue', @@ -45,3 +50,27 @@ export default { BoardType, ListType, }; + +export const blockingIssuablesQueries = { + [issuableTypes.issue]: { + query: boardBlockingIssuesQuery, + }, +}; + +export const titleQueries = { + [issuableTypes.issue]: { + mutation: issueSetTitleMutation, + }, + [issuableTypes.epic]: { + mutation: updateEpicTitleMutation, + }, +}; + +export const subscriptionQueries = { + [issuableTypes.issue]: { + mutation: issueSetSubscriptionMutation, + }, + [issuableTypes.epic]: { + mutation: updateEpicSubscriptionMutation, + }, +}; diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js index b6b34556663..62a0d930ec0 100644 --- a/app/assets/javascripts/boards/ee_functions.js +++ b/app/assets/javascripts/boards/ee_functions.js @@ -2,4 +2,3 @@ export const setWeightFetchingState = () => {}; export const setEpicFetchingState = () => {}; export const getMilestoneTitle = () => ({}); -export const getBoardsModalData = () => ({}); diff --git a/app/assets/javascripts/boards/filtered_search.js b/app/assets/javascripts/boards/filtered_search.js deleted file mode 100644 index 182a2cf3724..00000000000 --- a/app/assets/javascripts/boards/filtered_search.js +++ /dev/null @@ -1,25 +0,0 @@ -import Vue from 'vue'; -import store from '~/boards/stores'; -import { queryToObject } from '~/lib/utils/url_utility'; -import FilteredSearch from './components/filtered_search.vue'; - -export default () => { - const queryParams = queryToObject(window.location.search); - const el = document.getElementById('js-board-filtered-search'); - - /* - When https://github.com/vuejs/vue-apollo/pull/1153 is merged and deployed - we can remove apolloProvider option from here. Currently without it its causing - an error - */ - - return new Vue({ - el, - store, - apolloProvider: {}, - render: (createElement) => - createElement(FilteredSearch, { - props: { search: queryParams.search }, - }), - }); -}; diff --git a/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql new file mode 100644 index 00000000000..4dc245660a4 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_blocking_issues.query.graphql @@ -0,0 +1,16 @@ +query BoardBlockingIssues($id: IssueID!) { + issuable: issue(id: $id) { + __typename + id + blockingIssuables: blockedByIssues { + __typename + nodes { + id + iid + title + reference(full: true) + webUrl + } + } + } +} diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql index 1395bef39ed..7ecf9261214 100644 --- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql +++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql @@ -7,6 +7,10 @@ fragment IssueNode on Issue { referencePath: reference(full: true) dueDate timeEstimate + totalTimeSpent + humanTimeEstimate + humanTotalTimeSpent + emailsDisabled confidential webUrl subscribed diff --git a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql index 1f383245ac2..bfb87758e17 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql @@ -1,5 +1,5 @@ mutation issueSetSubscription($input: IssueSetSubscriptionInput!) { - issueSetSubscription(input: $input) { + updateIssuableSubscription: issueSetSubscription(input: $input) { issue { subscribed } diff --git a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql index 62e6c1352a6..6ad12d982e0 100644 --- a/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/issue_set_title.mutation.graphql @@ -1,5 +1,5 @@ mutation issueSetTitle($input: UpdateIssueInput!) { - updateIssue(input: $input) { + updateIssuableTitle: updateIssue(input: $input) { issue { title } diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index ceca5b0a451..e3f9d2f24c2 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -10,26 +10,21 @@ import { setWeightFetchingState, setEpicFetchingState, getMilestoneTitle, - getBoardsModalData, } from 'ee_else_ce/boards/ee_functions'; import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes'; import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; import BoardContent from '~/boards/components/board_content.vue'; -import BoardExtraActions from '~/boards/components/board_extra_actions.vue'; import './models/label'; import './models/assignee'; import '~/boards/models/milestone'; import '~/boards/models/project'; import '~/boards/filters/due_date_filters'; -import BoardAddIssuesModal from '~/boards/components/modal/index.vue'; import { issuableTypes } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import FilteredSearchBoards from '~/boards/filtered_search_boards'; -import modalMixin from '~/boards/mixins/modal_mixins'; import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; -import ModalStore from '~/boards/stores/modal_store'; import toggleFocusMode from '~/boards/toggle_focus'; import { deprecatedCreateFlash as Flash } from '~/flash'; import createDefaultClient from '~/lib/graphql'; @@ -72,21 +67,12 @@ export default () => { boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours); } - if (gon?.features?.boardsFilteredSearch) { - import('~/boards/filtered_search') - .then(({ default: initFilteredSearch }) => { - initFilteredSearch(apolloProvider); - }) - .catch(() => {}); - } - // eslint-disable-next-line @gitlab/no-runtime-template-compiler issueBoardsApp = new Vue({ el: $boardApp, components: { BoardContent, BoardSidebar, - BoardAddIssuesModal, BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'), }, provide: { @@ -95,6 +81,7 @@ export default () => { rootPath: $boardApp.dataset.rootPath, currentUserId: gon.current_user_id || null, canUpdate: parseBoolean($boardApp.dataset.canUpdate), + canAdminList: parseBoolean($boardApp.dataset.canAdminList), labelsFetchPath: $boardApp.dataset.labelsFetchPath, labelsManagePath: $boardApp.dataset.labelsManagePath, labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath, @@ -107,6 +94,8 @@ export default () => { milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable), assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable), iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable), + issuableType: issuableTypes.issue, + emailsDisabled: parseBoolean($boardApp.dataset.emailsDisabled), }, store, apolloProvider, @@ -174,15 +163,9 @@ export default () => { eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { - if (!gon.features?.boardsFilteredSearch) { - this.filterManager = new FilteredSearchBoards( - boardsStore.filter, - true, - boardsStore.cantEdit, - ); + this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); - this.filterManager.setup(); - } + this.filterManager.setup(); this.performSearch(); @@ -323,49 +306,7 @@ export default () => { boardConfigToggle(boardsStore); - const issueBoardsModal = document.getElementById('js-add-issues-btn'); - - if (issueBoardsModal && gon.features.addIssuesButton) { - // eslint-disable-next-line no-new - new Vue({ - el: issueBoardsModal, - mixins: [modalMixin], - data() { - return { - modal: ModalStore.store, - store: boardsStore.state, - ...getBoardsModalData(), - canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), - }; - }, - computed: { - disabled() { - if (!this.store) { - return true; - } - return !this.store.lists.filter((list) => !list.preset).length; - }, - }, - methods: { - openModal() { - if (!this.disabled) { - this.toggleModal(true); - } - }, - }, - render(createElement) { - return createElement(BoardExtraActions, { - props: { - canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), - openModal: this.openModal, - disabled: this.disabled, - }, - }); - }, - }); - } - - toggleFocusMode(ModalStore, boardsStore); + toggleFocusMode(); toggleLabels(); if (gon.licensed_features?.swimlanes) { diff --git a/app/assets/javascripts/boards/mixins/board_new_issue.js b/app/assets/javascripts/boards/mixins/board_new_issue.js new file mode 100644 index 00000000000..d4b74544735 --- /dev/null +++ b/app/assets/javascripts/boards/mixins/board_new_issue.js @@ -0,0 +1,6 @@ +export default { + // EE-only + methods: { + extraIssueInput: () => {}, + }, +}; diff --git a/app/assets/javascripts/boards/mixins/modal_footer.js b/app/assets/javascripts/boards/mixins/modal_footer.js deleted file mode 100644 index ff8b4c56321..00000000000 --- a/app/assets/javascripts/boards/mixins/modal_footer.js +++ /dev/null @@ -1 +0,0 @@ -export default {}; diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js deleted file mode 100644 index 6c97e1629bf..00000000000 --- a/app/assets/javascripts/boards/mixins/modal_mixins.js +++ /dev/null @@ -1,12 +0,0 @@ -import ModalStore from '../stores/modal_store'; - -export default { - methods: { - toggleModal(toggle) { - ModalStore.store.showAddIssuesModal = toggle; - }, - changeTab(tab) { - ModalStore.store.activeTab = tab; - }, - }, -}; diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 19b31ee7291..8005414962c 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,16 +1,21 @@ +import * as Sentry from '@sentry/browser'; import { pick } from 'lodash'; import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; +import issueMoveListMutation from 'ee_else_ce/boards/graphql/issue_move_list.mutation.graphql'; import { BoardType, ListType, inactiveId, flashAnimationDuration, ISSUABLE, + titleQueries, + subscriptionQueries, } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; import { formatBoardLists, formatListIssues, @@ -20,18 +25,17 @@ import { formatIssueInput, updateListPosition, transformNotFilters, + moveItemListHelper, + getMoveData, } from '../boards_util'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql'; import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; import issueCreateMutation from '../graphql/issue_create.mutation.graphql'; -import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql'; import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql'; import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql'; import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql'; -import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql'; -import issueSetTitleMutation from '../graphql/issue_set_title.mutation.graphql'; import listsIssuesQuery from '../graphql/lists_issues.query.graphql'; import * as types from './mutation_types'; @@ -68,6 +72,7 @@ export default { 'milestoneTitle', 'releaseTag', 'search', + 'myReactionEmoji', ]); filterParams.not = transformNotFilters(filters); commit(types.SET_FILTERS, filterParams); @@ -326,63 +331,155 @@ export default { commit(types.RESET_ISSUES); }, - moveItem: ({ dispatch }) => { - dispatch('moveIssue'); + moveItem: ({ dispatch }, payload) => { + dispatch('moveIssue', payload); }, - moveIssue: ( - { state, commit }, - { itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId }, + moveIssue: ({ dispatch, state }, params) => { + const moveData = getMoveData(state, params); + + dispatch('moveIssueCard', moveData); + dispatch('updateMovedIssue', moveData); + dispatch('updateIssueOrder', { moveData }); + }, + + moveIssueCard: ({ commit }, moveData) => { + const { + reordering, + shouldClone, + itemNotInToList, + originalIndex, + itemId, + fromListId, + toListId, + moveBeforeId, + moveAfterId, + } = moveData; + + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); + + if (reordering) { + commit(types.ADD_BOARD_ITEM_TO_LIST, { + itemId, + listId: toListId, + moveBeforeId, + moveAfterId, + }); + + return; + } + + if (itemNotInToList) { + commit(types.ADD_BOARD_ITEM_TO_LIST, { + itemId, + listId: toListId, + moveBeforeId, + moveAfterId, + }); + } + + if (shouldClone) { + commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex }); + } + }, + + updateMovedIssue: ( + { commit, state: { boardItems, boardLists } }, + { itemId, fromListId, toListId }, ) => { - const originalIssue = state.boardItems[itemId]; - const fromList = state.boardItemsByListId[fromListId]; - const originalIndex = fromList.indexOf(Number(itemId)); - commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }); + const updatedIssue = moveItemListHelper( + boardItems[itemId], + boardLists[fromListId], + boardLists[toListId], + ); - const { boardId } = state; - const [fullProjectPath] = itemPath.split(/[#]/); + commit(types.UPDATE_BOARD_ITEM, updatedIssue); + }, - gqlClient - .mutate({ + undoMoveIssueCard: ({ commit }, moveData) => { + const { + reordering, + shouldClone, + itemNotInToList, + itemId, + fromListId, + toListId, + originalIssue, + originalIndex, + } = moveData; + + commit(types.UPDATE_BOARD_ITEM, originalIssue); + + if (reordering) { + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); + commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex }); + return; + } + + if (shouldClone) { + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: fromListId }); + } + if (itemNotInToList) { + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { itemId, listId: toListId }); + } + + commit(types.ADD_BOARD_ITEM_TO_LIST, { itemId, listId: fromListId, atIndex: originalIndex }); + }, + + updateIssueOrder: async ({ commit, dispatch, state }, { moveData, mutationVariables = {} }) => { + try { + const { itemId, fromListId, toListId, moveBeforeId, moveAfterId } = moveData; + const { + boardId, + boardItems: { + [itemId]: { iid, referencePath }, + }, + } = state; + + const { data } = await gqlClient.mutate({ mutation: issueMoveListMutation, variables: { - projectPath: fullProjectPath, + iid, + projectPath: referencePath.split(/[#]/)[0], boardId: fullBoardId(boardId), - iid: itemIid, fromListId: getIdFromGraphQLId(fromListId), toListId: getIdFromGraphQLId(toListId), moveBeforeId, moveAfterId, + // 'mutationVariables' allows EE code to pass in extra parameters. + ...mutationVariables, }, - }) - .then(({ data }) => { - if (data?.issueMoveList?.errors.length) { - throw new Error(); - } else { - const issue = data.issueMoveList?.issue; - commit(types.MOVE_ISSUE_SUCCESS, { issue }); - } - }) - .catch(() => - commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }), + }); + + if (data?.issueMoveList?.errors.length || !data.issueMoveList) { + throw new Error('issueMoveList empty'); + } + + commit(types.MUTATE_ISSUE_SUCCESS, { issue: data.issueMoveList.issue }); + } catch { + commit( + types.SET_ERROR, + s__('Boards|An error occurred while moving the issue. Please try again.'), ); + dispatch('undoMoveIssueCard', moveData); + } }, setAssignees: ({ commit, getters }, assigneeUsernames) => { - commit('UPDATE_ISSUE_BY_ID', { - issueId: getters.activeIssue.id, + commit('UPDATE_BOARD_ITEM_BY_ID', { + itemId: getters.activeBoardItem.id, prop: 'assignees', value: assigneeUsernames, }); }, setActiveIssueMilestone: async ({ commit, getters }, input) => { - const { activeIssue } = getters; + const { activeBoardItem } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetMilestoneMutation, variables: { input: { - iid: String(activeIssue.iid), + iid: String(activeBoardItem.iid), milestoneId: getIdFromGraphQLId(input.milestoneId), projectPath: input.projectPath, }, @@ -393,65 +490,71 @@ export default { throw new Error(data.updateIssue.errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'milestone', value: data.updateIssue.issue.milestone, }); }, - createNewIssue: ({ commit, state }, issueInput) => { - const { boardConfig } = state; + addListItem: ({ commit }, { list, item, position }) => { + commit(types.ADD_BOARD_ITEM_TO_LIST, { listId: list.id, itemId: item.id, atIndex: position }); + commit(types.UPDATE_BOARD_ITEM, item); + }, + + removeListItem: ({ commit }, { listId, itemId }) => { + commit(types.REMOVE_BOARD_ITEM_FROM_LIST, { listId, itemId }); + commit(types.REMOVE_BOARD_ITEM, itemId); + }, + addListNewIssue: ( + { state: { boardConfig, boardType, fullPath }, dispatch, commit }, + { issueInput, list, placeholderId = `tmp-${new Date().getTime()}` }, + ) => { const input = formatIssueInput(issueInput, boardConfig); - const { boardType, fullPath } = state; if (boardType === BoardType.project) { input.projectPath = fullPath; } - return gqlClient + const placeholderIssue = formatIssue({ ...issueInput, id: placeholderId }); + dispatch('addListItem', { list, item: placeholderIssue, position: 0 }); + + gqlClient .mutate({ mutation: issueCreateMutation, variables: { input }, }) .then(({ data }) => { if (data.createIssue.errors.length) { - commit(types.CREATE_ISSUE_FAILURE); - } else { - return data.createIssue?.issue; + throw new Error(); } - return null; - }) - .catch(() => commit(types.CREATE_ISSUE_FAILURE)); - }, - addListIssue: ({ commit }, { list, issue, position }) => { - commit(types.ADD_ISSUE_TO_LIST, { list, issue, position }); + const rawIssue = data.createIssue?.issue; + const formattedIssue = formatIssue({ ...rawIssue, id: getIdFromGraphQLId(rawIssue.id) }); + dispatch('removeListItem', { listId: list.id, itemId: placeholderId }); + dispatch('addListItem', { list, item: formattedIssue, position: 0 }); + }) + .catch(() => { + dispatch('removeListItem', { listId: list.id, itemId: placeholderId }); + commit( + types.SET_ERROR, + s__('Boards|An error occurred while creating the issue. Please try again.'), + ); + }); }, - addListNewIssue: ({ commit, dispatch }, { issueInput, list }) => { - const issue = formatIssue({ ...issueInput, id: 'tmp' }); - commit(types.ADD_ISSUE_TO_LIST, { list, issue, position: 0 }); - - dispatch('createNewIssue', issueInput) - .then((res) => { - commit(types.ADD_ISSUE_TO_LIST, { - list, - issue: formatIssue({ ...res, id: getIdFromGraphQLId(res.id) }), - }); - commit(types.REMOVE_ISSUE_FROM_LIST, { list, issue }); - }) - .catch(() => commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issueId: issueInput.id })); + setActiveBoardItemLabels: ({ dispatch }, params) => { + dispatch('setActiveIssueLabels', params); }, setActiveIssueLabels: async ({ commit, getters }, input) => { - const { activeIssue } = getters; + const { activeBoardItem } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetLabelsMutation, variables: { input: { - iid: String(activeIssue.iid), + iid: String(activeBoardItem.iid), addLabelIds: input.addLabelIds ?? [], removeLabelIds: input.removeLabelIds ?? [], projectPath: input.projectPath, @@ -463,20 +566,20 @@ export default { throw new Error(data.updateIssue.errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'labels', value: data.updateIssue.issue.labels.nodes, }); }, setActiveIssueDueDate: async ({ commit, getters }, input) => { - const { activeIssue } = getters; + const { activeBoardItem } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetDueDateMutation, variables: { input: { - iid: String(activeIssue.iid), + iid: String(activeBoardItem.iid), projectPath: input.projectPath, dueDate: input.dueDate, }, @@ -487,57 +590,66 @@ export default { throw new Error(data.updateIssue.errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'dueDate', value: data.updateIssue.issue.dueDate, }); }, - setActiveIssueSubscribed: async ({ commit, getters }, input) => { + setActiveItemSubscribed: async ({ commit, getters, state }, input) => { + const { activeBoardItem, isEpicBoard } = getters; + const { fullPath, issuableType } = state; + const workspacePath = isEpicBoard + ? { groupPath: fullPath } + : { projectPath: input.projectPath }; const { data } = await gqlClient.mutate({ - mutation: issueSetSubscriptionMutation, + mutation: subscriptionQueries[issuableType].mutation, variables: { input: { - iid: String(getters.activeIssue.iid), - projectPath: input.projectPath, + ...workspacePath, + iid: String(activeBoardItem.iid), subscribedState: input.subscribed, }, }, }); - if (data.issueSetSubscription?.errors?.length > 0) { - throw new Error(data.issueSetSubscription.errors); + if (data.updateIssuableSubscription?.errors?.length > 0) { + throw new Error(data.updateIssuableSubscription[issuableType].errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: getters.activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'subscribed', - value: data.issueSetSubscription.issue.subscribed, + value: data.updateIssuableSubscription[issuableType].subscribed, }); }, - setActiveIssueTitle: async ({ commit, getters }, input) => { - const { activeIssue } = getters; + setActiveItemTitle: async ({ commit, getters, state }, input) => { + const { activeBoardItem, isEpicBoard } = getters; + const { fullPath, issuableType } = state; + const workspacePath = isEpicBoard + ? { groupPath: fullPath } + : { projectPath: input.projectPath }; const { data } = await gqlClient.mutate({ - mutation: issueSetTitleMutation, + mutation: titleQueries[issuableType].mutation, variables: { input: { - iid: String(activeIssue.iid), - projectPath: input.projectPath, + ...workspacePath, + iid: String(activeBoardItem.iid), title: input.title, }, }, }); - if (data.updateIssue?.errors?.length > 0) { - throw new Error(data.updateIssue.errors); + if (data.updateIssuableTitle?.errors?.length > 0) { + throw new Error(data.updateIssuableTitle.errors); } - commit(types.UPDATE_ISSUE_BY_ID, { - issueId: activeIssue.id, + commit(types.UPDATE_BOARD_ITEM_BY_ID, { + itemId: activeBoardItem.id, prop: 'title', - value: data.updateIssue.issue.title, + value: data.updateIssuableTitle[issuableType].title, }); }, @@ -576,10 +688,10 @@ export default { const { selectedBoardItems } = state; const index = selectedBoardItems.indexOf(boardItem); - // If user already selected an item (activeIssue) without using mult-select, + // If user already selected an item (activeBoardItem) without using mult-select, // include that item in the selection and unset state.ActiveId to hide the sidebar. - if (getters.activeIssue) { - commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue); + if (getters.activeBoardItem) { + commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeBoardItem); dispatch('unsetActiveId'); } @@ -608,6 +720,18 @@ export default { } }, + setError: ({ commit }, { message, error, captureError = false }) => { + commit(types.SET_ERROR, message); + + if (captureError) { + Sentry.captureException(error); + } + }, + + unsetError: ({ commit }) => { + commit(types.SET_ERROR, undefined); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index caa518f91ce..0589851c658 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,5 +1,5 @@ import { find } from 'lodash'; -import { BoardType, inactiveId } from '../constants'; +import { BoardType, inactiveId, issuableTypes } from '../constants'; export default { isGroupBoard: (state) => state.boardType === BoardType.group, @@ -15,17 +15,17 @@ export default { return listItemsIds.map((id) => getters.getBoardItemById(id)); }, - activeIssue: (state) => { + activeBoardItem: (state) => { return state.boardItems[state.activeId] || {}; }, groupPathForActiveIssue: (_, getters) => { - const { referencePath = '' } = getters.activeIssue; + const { referencePath = '' } = getters.activeBoardItem; return referencePath.slice(0, referencePath.indexOf('/')); }, projectPathForActiveIssue: (_, getters) => { - const { referencePath = '' } = getters.activeIssue; + const { referencePath = '' } = getters.activeBoardItem; return referencePath.slice(0, referencePath.indexOf('#')); }, @@ -44,6 +44,10 @@ export default { return find(state.boardLists, (l) => l.title === title); }, + isIssueBoard: (state) => { + return state.issuableType === issuableTypes.issue; + }, + isEpicBoard: () => { return false; }, diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js deleted file mode 100644 index 8a8fa61361c..00000000000 --- a/app/assets/javascripts/boards/stores/modal_store.js +++ /dev/null @@ -1,95 +0,0 @@ -class ModalStore { - constructor() { - this.store = { - columns: 3, - issues: [], - issuesCount: false, - selectedIssues: [], - showAddIssuesModal: false, - activeTab: 'all', - selectedList: null, - searchTerm: '', - loading: false, - loadingNewPage: false, - filterLoading: false, - page: 1, - perPage: 50, - filter: { - path: '', - }, - }; - } - - selectedCount() { - return this.getSelectedIssues().length; - } - - toggleIssue(issueObj) { - const issue = issueObj; - const { selected } = issue; - - issue.selected = !selected; - - if (!selected) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } - } - - toggleAll() { - const select = this.selectedCount() !== this.store.issues.length; - - this.store.issues.forEach((issue) => { - const issueUpdate = issue; - - if (issueUpdate.selected !== select) { - issueUpdate.selected = select; - - if (select) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } - } - }); - } - - getSelectedIssues() { - return this.store.selectedIssues.filter((issue) => issue.selected); - } - - addSelectedIssue(issue) { - const index = this.selectedIssueIndex(issue); - - if (index === -1) { - this.store.selectedIssues.push(issue); - } - } - - removeSelectedIssue(issue, forcePurge = false) { - if (this.store.activeTab === 'all' || forcePurge) { - this.store.selectedIssues = this.store.selectedIssues.filter( - (fIssue) => fIssue.id !== issue.id, - ); - } - } - - purgeUnselectedIssues() { - this.store.selectedIssues.forEach((issue) => { - if (!issue.selected) { - this.removeSelectedIssue(issue, true); - } - }); - } - - selectedIssueIndex(issue) { - return this.store.selectedIssues.indexOf(issue); - } - - findSelectedIssue(issue) { - return this.store.selectedIssues.filter((filteredIssue) => filteredIssue.id === issue.id)[0]; - } -} - -export default new ModalStore(); diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index e7c034fb087..22b9905ee62 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -20,23 +20,21 @@ export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; -export const CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_FAILURE'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; -export const MOVE_ISSUE = 'MOVE_ISSUE'; -export const MOVE_ISSUE_SUCCESS = 'MOVE_ISSUE_SUCCESS'; -export const MOVE_ISSUE_FAILURE = 'MOVE_ISSUE_FAILURE'; +export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM'; +export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM'; export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE'; +export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS'; export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS'; export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR'; -export const ADD_ISSUE_TO_LIST = 'ADD_ISSUE_TO_LIST'; -export const ADD_ISSUE_TO_LIST_FAILURE = 'ADD_ISSUE_TO_LIST_FAILURE'; -export const REMOVE_ISSUE_FROM_LIST = 'REMOVE_ISSUE_FROM_LIST'; +export const ADD_BOARD_ITEM_TO_LIST = 'ADD_BOARD_ITEM_TO_LIST'; +export const REMOVE_BOARD_ITEM_FROM_LIST = 'REMOVE_BOARD_ITEM_FROM_LIST'; export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; -export const UPDATE_ISSUE_BY_ID = 'UPDATE_ISSUE_BY_ID'; +export const UPDATE_BOARD_ITEM_BY_ID = 'UPDATE_BOARD_ITEM_BY_ID'; export const SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING'; export const RESET_ISSUES = 'RESET_ISSUES'; export const REQUEST_GROUP_PROJECTS = 'REQUEST_GROUP_PROJECTS'; @@ -49,3 +47,4 @@ export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE'; export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS'; export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS'; export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION'; +export const SET_ERROR = 'SET_ERROR'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 75b60366b6a..561c21b78c1 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -2,7 +2,7 @@ import { pull, union } from 'lodash'; import Vue from 'vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__ } from '~/locale'; -import { formatIssue, moveItemListHelper } from '../boards_util'; +import { formatIssue } from '../boards_util'; import { issuableTypes } from '../constants'; import * as mutationTypes from './mutation_types'; @@ -158,13 +158,13 @@ export default { }); }, - [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => { - if (!state.boardItems[issueId]) { + [mutationTypes.UPDATE_BOARD_ITEM_BY_ID]: (state, { itemId, prop, value }) => { + if (!state.boardItems[itemId]) { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('No issue found.'); } - Vue.set(state.boardItems[issueId], prop, value); + Vue.set(state.boardItems[itemId], prop, value); }, [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { @@ -183,40 +183,11 @@ export default { notImplemented(); }, - [mutationTypes.MOVE_ISSUE]: ( - state, - { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }, - ) => { - const fromList = state.boardLists[fromListId]; - const toList = state.boardLists[toListId]; - - const issue = moveItemListHelper(originalIssue, fromList, toList); - Vue.set(state.boardItems, issue.id, issue); - - removeItemFromList({ state, listId: fromListId, itemId: issue.id }); - addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId }); - }, - - [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => { + [mutationTypes.MUTATE_ISSUE_SUCCESS]: (state, { issue }) => { const issueId = getIdFromGraphQLId(issue.id); Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId })); }, - [mutationTypes.MOVE_ISSUE_FAILURE]: ( - state, - { originalIssue, fromListId, toListId, originalIndex }, - ) => { - state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); - Vue.set(state.boardItems, originalIssue.id, originalIssue); - removeItemFromList({ state, listId: toListId, itemId: originalIssue.id }); - addItemToList({ - state, - listId: fromListId, - itemId: originalIssue.id, - atIndex: originalIndex, - }); - }, - [mutationTypes.REQUEST_UPDATE_ISSUE]: () => { notImplemented(); }, @@ -229,28 +200,23 @@ export default { notImplemented(); }, - [mutationTypes.CREATE_ISSUE_FAILURE]: (state) => { - state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); + [mutationTypes.ADD_BOARD_ITEM_TO_LIST]: ( + state, + { itemId, listId, moveBeforeId, moveAfterId, atIndex }, + ) => { + addItemToList({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }); }, - [mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => { - addItemToList({ - state, - listId: list.id, - itemId: issue.id, - atIndex: position, - }); - Vue.set(state.boardItems, issue.id, issue); + [mutationTypes.REMOVE_BOARD_ITEM_FROM_LIST]: (state, { itemId, listId }) => { + removeItemFromList({ state, listId, itemId }); }, - [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => { - state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); - removeItemFromList({ state, listId: list.id, itemId: issueId }); + [mutationTypes.UPDATE_BOARD_ITEM]: (state, item) => { + Vue.set(state.boardItems, item.id, item); }, - [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => { - removeItemFromList({ state, listId: list.id, itemId: issue.id }); - Vue.delete(state.boardItems, issue.id); + [mutationTypes.REMOVE_BOARD_ITEM]: (state, itemId) => { + Vue.delete(state.boardItems, itemId); }, [mutationTypes.SET_CURRENT_PAGE]: () => { @@ -309,4 +275,8 @@ export default { [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => { state.selectedBoardItems = []; }, + + [mutationTypes.SET_ERROR]: (state, error) => { + state.error = error; + }, }; diff --git a/app/assets/javascripts/branches/branch_sort_dropdown.js b/app/assets/javascripts/branches/branch_sort_dropdown.js new file mode 100644 index 00000000000..9914ce05a95 --- /dev/null +++ b/app/assets/javascripts/branches/branch_sort_dropdown.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import SortDropdown from './components/sort_dropdown.vue'; + +const mountDropdownApp = (el) => { + const { mode, projectBranchesFilteredPath, sortOptions } = el.dataset; + + return new Vue({ + el, + name: 'SortBranchesDropdownApp', + components: { + SortDropdown, + }, + provide: { + mode, + projectBranchesFilteredPath, + sortOptions: JSON.parse(sortOptions), + }, + render: (createElement) => createElement(SortDropdown), + }); +}; + +export default () => { + const el = document.getElementById('js-branches-sort-dropdown'); + return el ? mountDropdownApp(el) : null; +}; diff --git a/app/assets/javascripts/branches/components/sort_dropdown.vue b/app/assets/javascripts/branches/components/sort_dropdown.vue new file mode 100644 index 00000000000..ddb4c5c0015 --- /dev/null +++ b/app/assets/javascripts/branches/components/sort_dropdown.vue @@ -0,0 +1,88 @@ +<script> +import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui'; +import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; + +const OVERVIEW_MODE = 'overview'; + +export default { + i18n: { + searchPlaceholder: s__('Branches|Filter by branch name'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByClick, + }, + inject: ['projectBranchesFilteredPath', 'sortOptions', 'mode'], + data() { + return { + selectedKey: 'updated_desc', + searchTerm: '', + }; + }, + computed: { + shouldShowDropdown() { + return this.mode !== OVERVIEW_MODE; + }, + selectedSortMethodName() { + return this.sortOptions[this.selectedKey]; + }, + }, + created() { + const sortValue = getParameterValues('sort'); + const searchValue = getParameterValues('search'); + + if (sortValue.length > 0) { + [this.selectedKey] = sortValue; + } + + if (searchValue.length > 0) { + [this.searchTerm] = searchValue; + } + }, + methods: { + isSortMethodSelected(sortKey) { + return sortKey === this.selectedKey; + }, + visitUrlFromOption(sortKey) { + this.selectedKey = sortKey; + const urlParams = {}; + + if (this.mode !== OVERVIEW_MODE) { + urlParams.sort = sortKey; + } + + urlParams.search = this.searchTerm.length > 0 ? this.searchTerm : null; + + const newUrl = mergeUrlParams(urlParams, this.projectBranchesFilteredPath); + visitUrl(newUrl); + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-pr-4"> + <gl-search-box-by-click + v-model="searchTerm" + :placeholder="$options.i18n.searchPlaceholder" + class="gl-pr-4" + data-testid="branch-search" + @submit="visitUrlFromOption(selectedKey)" + /> + <gl-dropdown + v-if="shouldShowDropdown" + :text="selectedSortMethodName" + 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> + </div> +</template> diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js index ca019bc4178..66e8d982113 100644 --- a/app/assets/javascripts/branches/divergence_graph.js +++ b/app/assets/javascripts/branches/divergence_graph.js @@ -4,13 +4,13 @@ import axios from '../lib/utils/axios_utils'; import { __ } from '../locale'; import DivergenceGraph from './components/divergence_graph.vue'; -export function createGraphVueApp(el, data, maxCommits) { +export function createGraphVueApp(el, data, maxCommits, defaultBranch) { return new Vue({ el, render(h) { return h(DivergenceGraph, { props: { - defaultBranch: 'master', + defaultBranch, distance: data.distance ? parseInt(data.distance, 10) : null, aheadCount: parseInt(data.ahead, 10), behindCount: parseInt(data.behind, 10), @@ -21,7 +21,7 @@ export function createGraphVueApp(el, data, maxCommits) { }); } -export default (endpoint) => { +export default (endpoint, defaultBranch) => { const names = [...document.querySelectorAll('.js-branch-item')].map( ({ dataset }) => dataset.name, ); @@ -47,7 +47,7 @@ export default (endpoint) => { if (!el) return; - createGraphVueApp(el, val, maxCommits); + createGraphVueApp(el, val, maxCommits, defaultBranch); }); }) .catch(() => diff --git a/app/assets/javascripts/captcha/apollo_captcha_link.js b/app/assets/javascripts/captcha/apollo_captcha_link.js new file mode 100644 index 00000000000..e49abc10b29 --- /dev/null +++ b/app/assets/javascripts/captcha/apollo_captcha_link.js @@ -0,0 +1,37 @@ +import { ApolloLink, Observable } from 'apollo-link'; + +export const apolloCaptchaLink = new ApolloLink((operation, forward) => + forward(operation).flatMap((result) => { + const { errors = [] } = result; + + // Our API will return with a top-level GraphQL error with extensions + // in case a captcha is required. + const captchaError = errors.find((e) => e?.extensions?.needs_captcha_response); + if (captchaError) { + const captchaSiteKey = captchaError.extensions.captcha_site_key; + const spamLogId = captchaError.extensions.spam_log_id; + + return new Observable((observer) => { + import('~/captcha/wait_for_captcha_to_be_solved') + .then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey)) + .then((captchaResponse) => { + // If the captcha was solved correctly, we re-do our action while setting + // captcha response headers. + operation.setContext({ + headers: { + 'X-GitLab-Captcha-Response': captchaResponse, + 'X-GitLab-Spam-Log-Id': spamLogId, + }, + }); + forward(operation).subscribe(observer); + }) + .catch((error) => { + observer.error(error); + observer.complete(); + }); + }); + } + + return Observable.of(result); + }), +); diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue index 9a55177b15f..ced07dea7be 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue @@ -32,7 +32,7 @@ export default { return { content: '', loading: false, - valid: false, + isValid: false, errors: null, warnings: null, jobs: [], @@ -61,7 +61,7 @@ export default { }); this.showingResults = true; - this.valid = valid; + this.isValid = valid; this.errors = errors; this.warnings = warnings; this.jobs = jobs; @@ -120,7 +120,7 @@ export default { <ci-lint-results v-if="showingResults" class="col-sm-12 gl-mt-5" - :valid="valid" + :is-valid="isValid" :jobs="jobs" :errors="errors" :warnings="warnings" diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index 0233ffaccdc..bc1e401d373 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -7,6 +7,10 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { + i18n: { + editButton: s__('Pipelines|Edit'), + revokeButton: s__('Pipelines|Revoke'), + }, components: { GlTable, GlButton, @@ -108,13 +112,15 @@ export default { </template> <template #cell(actions)="{ item }"> <gl-button - :title="s__('Pipelines|Edit')" + :title="$options.i18n.editButton" + :aria-label="$options.i18n.editButton" icon="pencil" data-testid="edit-btn" :href="item.editProjectTriggerPath" /> <gl-button - :title="s__('Pipelines|Revoke')" + :title="$options.i18n.revokeButton" + :aria-label="$options.i18n.revokeButton" icon="remove" variant="warning" :data-confirm=" 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 be7c0b68b4c..12def6e7eef 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 @@ -7,6 +7,7 @@ import { GlFormCombobox, GlFormGroup, GlFormSelect, + GlFormInput, GlFormTextarea, GlIcon, GlLink, @@ -41,6 +42,7 @@ export default { GlFormCombobox, GlFormGroup, GlFormSelect, + GlFormInput, GlFormTextarea, GlIcon, GlLink, @@ -128,6 +130,9 @@ export default { return true; }, + scopedVariablesAvailable() { + return !this.isGroup || this.glFeatures.groupScopedCiVariables; + }, variableValidationFeedback() { return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; }, @@ -222,28 +227,25 @@ export default { </gl-form-group> <div class="d-flex"> - <gl-form-group - :label="__('Type')" - label-for="ci-variable-type" - class="w-50 gl-mr-5" - :class="{ 'w-100': isGroup }" - > + <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="w-50 gl-mr-5"> <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" /> </gl-form-group> <gl-form-group - v-if="!isGroup" :label="__('Environment scope')" label-for="ci-variable-env" class="w-50" data-testid="environment-scope" > <ci-environments-dropdown + v-if="scopedVariablesAvailable" class="w-100" :value="environment_scope" @selectEnvironment="setEnvironmentScope" @createClicked="addWildCardScope" /> + + <gl-form-input v-else v-model="environment_scope" class="w-100" readonly /> </gl-form-group> </div> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue index 6e6527df63f..605da5d9352 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue @@ -37,7 +37,7 @@ export default { <template> <div id="popover-container"> - <gl-popover :target="target" triggers="hover" placement="top" container="popover-container"> + <gl-popover :target="target" placement="top" container="popover-container"> <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-word-break-all" > 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 c9943052356..e5923124653 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 @@ -2,6 +2,7 @@ import { GlTable, GlButton, GlModalDirective, GlIcon } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import { s__, __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; import CiVariablePopover from './ci_variable_popover.vue'; @@ -59,8 +60,9 @@ export default { directives: { GlModalDirective, }, + mixins: [glFeatureFlagsMixin()], computed: { - ...mapState(['variables', 'valuesHidden', 'isGroup', 'isLoading', 'isDeleting']), + ...mapState(['variables', 'valuesHidden', 'isLoading', 'isDeleting']), valuesButtonText() { return this.valuesHidden ? __('Reveal values') : __('Hide values'); }, @@ -68,9 +70,6 @@ export default { return this.variables && this.variables.length > 0; }, fields() { - if (this.isGroup) { - return this.$options.fields.filter((field) => field.key !== 'environment_scope'); - } return this.$options.fields; }, }, diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index 37b5f7e6df7..50856ca9533 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -3,8 +3,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; import CiVariableSettings from './components/ci_variable_settings.vue'; import createStore from './store'; -export default (containerId = 'js-ci-project-variables') => { - const containerEl = document.getElementById(containerId); +const mountCiVariableListApp = (containerEl) => { const { endpoint, projectId, @@ -43,3 +42,9 @@ export default (containerId = 'js-ci-project-variables') => { }, }); }; + +export default (containerId = 'js-ci-project-variables') => { + const el = document.getElementById(containerId); + + return !el ? {} : mountCiVariableListApp(el); +}; diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 76fe076d4ff..a53b63ea592 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -141,6 +141,9 @@ export default { isInstalling() { return this.status === APPLICATION_STATUS.INSTALLING; }, + isExternallyInstalled() { + return this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED; + }, canInstall() { return ( this.status === APPLICATION_STATUS.NOT_INSTALLABLE || @@ -193,10 +196,17 @@ export default { label = __('Installing'); } else if (this.installed) { label = __('Installed'); + } else if (this.isExternallyInstalled) { + label = __('Externally installed'); } return label; }, + buttonGridCellClass() { + return this.showManageButton || this.status === APPLICATION_STATUS.EXTERNALLY_INSTALLED + ? 'section-25' + : 'section-15'; + }, showManageButton() { return this.manageLink && this.status === APPLICATION_STATUS.INSTALLED; }, @@ -427,8 +437,7 @@ export default { </div> </div> <div - :class="{ 'section-25': showManageButton, 'section-15': !showManageButton }" - class="table-section table-button-footer section-align-top" + :class="[buttonGridCellClass, 'table-section', 'table-button-footer', 'section-align-top']" role="gridcell" > <div v-if="showManageButton" class="btn-group table-action-buttons"> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index e2227c61cee..90ec3f2377c 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -26,6 +26,7 @@ export const APPLICATION_STATUS = { ERROR: 'errored', PRE_INSTALLED: 'pre_installed', UNINSTALLED: 'uninstalled', + EXTERNALLY_INSTALLED: 'externally_installed', }; /* diff --git a/app/assets/javascripts/clusters/forms/show/index.js b/app/assets/javascripts/clusters/forms/show/index.js index 47a3016c777..102b240042f 100644 --- a/app/assets/javascripts/clusters/forms/show/index.js +++ b/app/assets/javascripts/clusters/forms/show/index.js @@ -1,9 +1,12 @@ import Vue from 'vue'; +import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import IntegrationForm from '../components/integration_form.vue'; import { createStore } from '../stores'; export default () => { - const entryPoint = document.querySelector('#js-cluster-integration-form'); + dirtySubmitFactory(document.querySelectorAll('.js-cluster-integrations-form')); + + const entryPoint = document.querySelector('#js-cluster-details-form'); if (!entryPoint) { return; diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js index 1dd815ae44d..2ff604af9a7 100644 --- a/app/assets/javascripts/clusters/services/application_state_machine.js +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -15,6 +15,7 @@ const { UNINSTALL_ERRORED, PRE_INSTALLED, UNINSTALLED, + EXTERNALLY_INSTALLED, } = APPLICATION_STATUS; const applicationStateMachine = { @@ -71,6 +72,9 @@ const applicationStateMachine = { [UNINSTALLED]: { target: UNINSTALLED, }, + [EXTERNALLY_INSTALLED]: { + target: EXTERNALLY_INSTALLED, + }, }, }, [NOT_INSTALLABLE]: { diff --git a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue index 1a396694bc8..9903a1bdb3e 100644 --- a/app/assets/javascripts/clusters_list/components/node_error_help_text.vue +++ b/app/assets/javascripts/clusters_list/components/node_error_help_text.vue @@ -34,7 +34,7 @@ export default { <gl-icon name="status_warning" :size="24" class="gl-p-2" /> - <gl-popover :container="popoverId" :target="popoverId" placement="top" triggers="hover focus"> + <gl-popover :container="popoverId" :target="popoverId" placement="top"> <template #title> <span class="gl-display-block gl-text-left">{{ errorContent.title }}</span> </template> diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 2e050c066f1..6f496ffc6ae 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -5,8 +5,8 @@ import CommitPipelinesTable from './pipelines_table.vue'; * Used in: * - Project Pipelines List (projects:pipelines:index) * - Commit details View > Pipelines Tab > Pipelines Table (projects:commit:pipelines) - * - Merge Request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show) - * - New Merge Request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new) + * - Merge request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show) + * - New merge request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new) */ export default () => { const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index e1cca5adc73..ddca5bc7d4f 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -1,7 +1,6 @@ <script> -import { GlButton, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui'; import { getParameterByName } from '~/lib/utils/common_utils'; -import SvgBlankState from '~/pipelines/components/pipelines_list/blank_state.vue'; import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue'; import eventHub from '~/pipelines/event_hub'; import PipelinesMixin from '~/pipelines/mixins/pipelines_mixin'; @@ -13,12 +12,12 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { GlButton, + GlEmptyState, GlLink, GlLoadingIcon, GlModal, PipelinesTableComponent, TablePagination, - SvgBlankState, }, mixins: [PipelinesMixin, glFeatureFlagMixin()], props: { @@ -82,7 +81,7 @@ export default { return this.hasError && !this.isLoading; }, /** - * The Run Pipeline button can only be rendered when: + * The "Run pipeline" button can only be rendered when: * - In MR view - we use `canCreatePipelineInTargetProject` for that purpose * - If the latest pipeline has the `detached_merge_request_pipeline` flag * @@ -91,9 +90,6 @@ export default { canRenderPipelineButton() { return this.latestPipelineDetachedFlag; }, - pipelineButtonClass() { - return !this.glFeatures.newPipelinesTable ? 'gl-md-display-none' : 'gl-lg-display-none'; - }, isForkMergeRequest() { return this.sourceProjectFullPath !== this.targetProjectFullPath; }, @@ -149,7 +145,7 @@ export default { } }, /** - * When the user clicks on the Run Pipeline button + * When the user clicks on the "Run pipeline" button * we need to make a post request and * to update the table content once the request is finished. * @@ -178,17 +174,17 @@ export default { <div class="content-list pipelines"> <gl-loading-icon v-if="isLoading" - :label="s__('Pipelines|Loading Pipelines')" + :label="s__('Pipelines|Loading pipelines')" size="lg" class="prepend-top-20" /> - <svg-blank-state + <gl-empty-state v-else-if="shouldRenderErrorState" :svg-path="errorStateSvgPath" - :message=" + :title=" s__(`Pipelines|There was an error fetching the pipelines. - Try again in a few moments or contact your support team.`) + Try again in a few moments or contact your support team.`) " /> @@ -196,14 +192,13 @@ export default { <gl-button v-if="canRenderPipelineButton" block - class="gl-mt-3 gl-mb-3" - :class="pipelineButtonClass" - variant="success" + class="gl-mt-3 gl-mb-3 gl-lg-display-none" + variant="confirm" data-testid="run_pipeline_button_mobile" :loading="state.isRunningMergeRequestPipeline" @click="tryRunPipeline" > - {{ s__('Pipelines|Run Pipeline') }} + {{ s__('Pipeline|Run pipeline') }} </gl-button> <pipelines-table-component @@ -214,12 +209,12 @@ export default { <template #table-header-actions> <div v-if="canRenderPipelineButton" class="gl-text-right"> <gl-button - variant="success" + variant="confirm" data-testid="run_pipeline_button" :loading="state.isRunningMergeRequestPipeline" @click="tryRunPipeline" > - {{ s__('Pipelines|Run Pipeline') }} + {{ s__('Pipeline|Run pipeline') }} </gl-button> </div> </template> @@ -232,7 +227,7 @@ export default { ref="modal" :modal-id="modalId" :title="s__('Pipelines|Are you sure you want to run this pipeline?')" - :ok-title="s__('Pipelines|Run Pipeline')" + :ok-title="s__('Pipeline|Run pipeline')" ok-variant="danger" @ok="onClickRunPipeline" > diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index 5cbe9a24fc4..da7fc88d8ac 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -10,7 +10,7 @@ export default class CommitsList { this.$contentList = $('.content_list'); - Pager.init(parseInt(limit, 10), false, false, this.processCommits.bind(this)); + Pager.init({ limit: parseInt(limit, 10), prepareData: this.processCommits.bind(this) }); this.content = $('#commits-list'); this.searchField = $('#commits-search'); diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue new file mode 100644 index 00000000000..839d4de912d --- /dev/null +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -0,0 +1,18 @@ +<script> +import { EditorContent } from 'tiptap'; +import createEditor from '../services/create_editor'; + +export default { + components: { + EditorContent, + }, + data() { + return { + editor: createEditor(), + }; + }, +}; +</script> +<template> + <editor-content :editor="editor" /> +</template> diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js new file mode 100644 index 00000000000..eb6deff434d --- /dev/null +++ b/app/assets/javascripts/content_editor/constants.js @@ -0,0 +1,5 @@ +import { s__ } from '~/locale'; + +export const PROVIDE_SERIALIZER_OR_RENDERER_ERROR = s__( + 'ContentEditor|You have to provide a renderMarkdown function or a custom serializer', +); diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js new file mode 100644 index 00000000000..1d050ed208b --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -0,0 +1,38 @@ +import { CodeBlockHighlight as BaseCodeBlockHighlight } from 'tiptap-extensions'; + +export default class GlCodeBlockHighlight extends BaseCodeBlockHighlight { + get schema() { + const baseSchema = super.schema; + + return { + ...baseSchema, + attrs: { + params: { + default: null, + }, + }, + parseDOM: [ + { + tag: 'pre', + preserveWhitespace: 'full', + getAttrs: (node) => { + const code = node.querySelector('code'); + + if (!code) { + return null; + } + + return { + /* `params` is the name of the attribute that + prosemirror-markdown uses to extract the language + of a codeblock. + https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.js#L62 + */ + params: code.getAttribute('lang'), + }; + }, + }, + ], + }; + } +} diff --git a/app/assets/javascripts/content_editor/index.js b/app/assets/javascripts/content_editor/index.js new file mode 100644 index 00000000000..e6ef3965da1 --- /dev/null +++ b/app/assets/javascripts/content_editor/index.js @@ -0,0 +1,2 @@ +export { default as createEditor } from './services/create_editor'; +export { default as ContentEditor } from './components/content_editor.vue'; diff --git a/app/assets/javascripts/content_editor/services/create_editor.js b/app/assets/javascripts/content_editor/services/create_editor.js new file mode 100644 index 00000000000..128d332b0a2 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/create_editor.js @@ -0,0 +1,60 @@ +import { isFunction, isString } from 'lodash'; +import { Editor } from 'tiptap'; +import { + Bold, + Italic, + Code, + Link, + Image, + Heading, + Blockquote, + HorizontalRule, + BulletList, + OrderedList, + ListItem, +} from 'tiptap-extensions'; +import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; +import CodeBlockHighlight from '../extensions/code_block_highlight'; +import createMarkdownSerializer from './markdown_serializer'; + +const createEditor = async ({ content, renderMarkdown, serializer: customSerializer } = {}) => { + if (!customSerializer && !isFunction(renderMarkdown)) { + throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); + } + + const editor = new Editor({ + extensions: [ + new Bold(), + new Italic(), + new Code(), + new Link(), + new Image(), + new Heading({ levels: [1, 2, 3, 4, 5, 6] }), + new Blockquote(), + new HorizontalRule(), + new BulletList(), + new ListItem(), + new OrderedList(), + new CodeBlockHighlight(), + ], + }); + const serializer = customSerializer || createMarkdownSerializer({ render: renderMarkdown }); + + editor.setSerializedContent = async (serializedContent) => { + editor.setContent( + await serializer.deserialize({ schema: editor.schema, content: serializedContent }), + ); + }; + + editor.getSerializedContent = () => { + return serializer.serialize({ schema: editor.schema, content: editor.getJSON() }); + }; + + if (isString(content)) { + await editor.setSerializedContent(content); + } + + return editor; +}; + +export default createEditor; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js new file mode 100644 index 00000000000..e3b5775e320 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -0,0 +1,73 @@ +import { + MarkdownSerializer as ProseMirrorMarkdownSerializer, + defaultMarkdownSerializer, +} from 'prosemirror-markdown'; +import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; + +const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; + +/** + * A markdown serializer converts arbitrary Markdown content + * into a ProseMirror document and viceversa. To convert Markdown + * into a ProseMirror document, the Markdown should be rendered. + * + * The client should provide a render function to allow flexibility + * on the desired rendering approach. + * + * @param {Function} params.render Render function + * that parses the Markdown and converts it into HTML. + * @returns a markdown serializer + */ +const create = ({ render = () => null }) => { + return { + /** + * Converts a Markdown string into a ProseMirror JSONDocument based + * on a ProseMirror schema. + * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines + * the types of content supported in the document + * @param {String} params.content An arbitrary markdown string + * @returns A ProseMirror JSONDocument + */ + deserialize: async ({ schema, content }) => { + const html = await render(content); + + if (!html) { + return null; + } + + const parser = new DOMParser(); + const { + body: { firstElementChild }, + } = parser.parseFromString(wrapHtmlPayload(html), 'text/html'); + const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild); + + return state.toJSON(); + }, + + /** + * Converts a ProseMirror JSONDocument based + * on a ProseMirror schema into Markdown + * @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines + * the types of content supported in the document + * @param {String} params.content A ProseMirror JSONDocument + * @returns A Markdown string + */ + serialize: ({ schema, content }) => { + const document = schema.nodeFromJSON(content); + const serializer = new ProseMirrorMarkdownSerializer(defaultMarkdownSerializer.nodes, { + ...defaultMarkdownSerializer.marks, + bold: { + // creates a bold alias for the strong mark converter + ...defaultMarkdownSerializer.marks.strong, + }, + italic: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true }, + }); + + return serializer.serialize(document, { + tightLists: true, + }); + }, + }; +}; + +export default create; diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue index 7426e570864..25ce6500094 100644 --- a/app/assets/javascripts/contributors/components/contributors.vue +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -201,11 +201,12 @@ export default { </div> <div v-else-if="showChart" class="contributors-charts"> - <h4>{{ __('Commits to') }} {{ branch }}</h4> + <h4 class="gl-mb-2 gl-mt-5">{{ __('Commits to') }} {{ branch }}</h4> <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> <resizable-chart-container> <gl-area-chart slot-scope="{ width }" + class="gl-mb-5" :width="width" :data="masterChartData" :option="masterChartOptions" @@ -218,10 +219,12 @@ export default { <div v-for="(contributor, index) in individualChartsData" :key="index" - class="col-lg-6 col-12" + class="col-lg-6 col-12 gl-my-5" > - <h4>{{ contributor.name }}</h4> - <p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p> + <h4 class="gl-mb-2 gl-mt-0">{{ contributor.name }}</h4> + <p class="gl-mb-3"> + {{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }}) + </p> <resizable-chart-container> <gl-area-chart slot-scope="{ width }" diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js index b6063589734..f66133a074d 100644 --- a/app/assets/javascripts/contributors/index.js +++ b/app/assets/javascripts/contributors/index.js @@ -1,12 +1,15 @@ import Vue from 'vue'; import ContributorsGraphs from './components/contributors.vue'; -import store from './stores'; +import { createStore } from './stores'; export default () => { const el = document.querySelector('.js-contributors-graph'); if (!el) return null; + const { projectGraphPath, projectBranch, defaultBranch } = el.dataset; + const store = createStore(defaultBranch); + return new Vue({ el, store, @@ -14,8 +17,8 @@ export default () => { render(createElement) { return createElement(ContributorsGraphs, { props: { - endpoint: el.dataset.projectGraphPath, - branch: el.dataset.projectBranch, + endpoint: projectGraphPath, + branch: projectBranch, }, }); }, diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js index 38259f46d4c..a4d0004cee5 100644 --- a/app/assets/javascripts/contributors/stores/index.js +++ b/app/assets/javascripts/contributors/stores/index.js @@ -7,12 +7,12 @@ import state from './state'; Vue.use(Vuex); -export const createStore = () => +export const createStore = (defaultBranch) => new Vuex.Store({ actions, mutations, getters, - state: state(), + state: state(defaultBranch), }); -export default createStore(); +export default createStore; diff --git a/app/assets/javascripts/contributors/stores/state.js b/app/assets/javascripts/contributors/stores/state.js index 1dc1a3c7b75..9c6b993e5cb 100644 --- a/app/assets/javascripts/contributors/stores/state.js +++ b/app/assets/javascripts/contributors/stores/state.js @@ -1,5 +1,5 @@ -export default () => ({ +export default (branch) => ({ loading: false, chartData: null, - branch: 'master', + branch, }); diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 35176c19f69..000faacb7d7 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -1,4 +1,3 @@ -/* eslint-disable no-new */ import { debounce } from 'lodash'; import { init as initConfidentialMergeRequest, @@ -8,7 +7,7 @@ import { import confidentialMergeRequestState from './confidential_merge_request/state'; import DropLab from './droplab/drop_lab'; import ISetter from './droplab/plugins/input_setter'; -import { deprecatedCreateFlash as Flash } from './flash'; +import createFlash from './flash'; import axios from './lib/utils/axios_utils'; import { __, sprintf } from './locale'; @@ -36,6 +35,7 @@ export default class CreateMergeRequestDropdown { this.branchInput = this.wrapperEl.querySelector('.js-branch-name'); this.branchMessage = this.wrapperEl.querySelector('.js-branch-message'); this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request'); + this.createMergeRequestLoading = this.createMergeRequestButton.querySelector('.js-spinner'); this.createTargetButton = this.wrapperEl.querySelector('.js-create-target'); this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu'); this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); @@ -132,7 +132,9 @@ export default class CreateMergeRequestDropdown { .catch(() => { this.unavailable(); this.disable(); - Flash(__('Failed to check related branches.')); + createFlash({ + message: __('Failed to check related branches.'), + }); }); } @@ -147,7 +149,11 @@ export default class CreateMergeRequestDropdown { this.branchCreated = true; window.location.href = data.url; }) - .catch(() => Flash(__('Failed to create a branch for this issue. Please try again.'))); + .catch(() => + createFlash({ + message: __('Failed to create a branch for this issue. Please try again.'), + }), + ); } createMergeRequest() { @@ -163,13 +169,21 @@ export default class CreateMergeRequestDropdown { this.mergeRequestCreated = true; window.location.href = data.url; }) - .catch(() => Flash(__('Failed to create Merge Request. Please try again.'))); + .catch(() => + createFlash({ + message: __('Failed to create merge request. Please try again.'), + }), + ); } disable() { this.disableCreateAction(); } + setLoading(loading) { + this.createMergeRequestLoading.classList.toggle('gl-display-none', !loading); + } + disableCreateAction() { this.createMergeRequestButton.classList.add('disabled'); this.createMergeRequestButton.setAttribute('disabled', 'disabled'); @@ -256,7 +270,9 @@ export default class CreateMergeRequestDropdown { .catch(() => { this.unavailable(); this.disable(); - new Flash(__('Failed to get ref.')); + createFlash({ + message: __('Failed to get ref.'), + }); this.isGettingRef = false; @@ -376,8 +392,10 @@ export default class CreateMergeRequestDropdown { this.isCreatingBranch = false; this.enable(); + this.setLoading(false); }); + this.setLoading(true); this.disable(); } diff --git a/app/assets/javascripts/delete_label_modal.js b/app/assets/javascripts/delete_label_modal.js new file mode 100644 index 00000000000..cf7c9e7734f --- /dev/null +++ b/app/assets/javascripts/delete_label_modal.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import DeleteLabelModal from '~/vue_shared/components/delete_label_modal.vue'; + +const mountDeleteLabelModal = (optionalProps) => + new Vue({ + render(h) { + return h(DeleteLabelModal, { + props: { + selector: '.js-delete-label-modal-button', + ...optionalProps, + }, + }); + }, + }).$mount(); + +export default (optionalProps = {}) => mountDeleteLabelModal(optionalProps); diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue index d05a0761ae3..051ab710e5f 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue @@ -18,7 +18,6 @@ export default { modalOptions: { ref: 'modal', modalId: 'deploy-freeze-modal', - title: __('Add deploy freeze'), actionCancel: { text: __('Cancel'), }, @@ -30,10 +29,13 @@ export default { cronSyntaxInstructions: __( 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}', ), + addTitle: __('Add deploy freeze'), + editTitle: __('Edit deploy freeze'), }, computed: { ...mapState([ 'projectId', + 'selectedId', 'selectedTimezone', 'timezoneData', 'freezeStartCron', @@ -45,9 +47,9 @@ export default { ]), addDeployFreezeButton() { return { - text: __('Add deploy freeze'), + text: this.isEditing ? __('Save deploy freeze') : __('Add deploy freeze'), attributes: [ - { variant: 'success' }, + { variant: 'confirm' }, { disabled: !isValidCron(this.freezeStartCron) || @@ -77,9 +79,17 @@ export default { this.setSelectedTimezone(selectedTimezone); }, }, + isEditing() { + return Boolean(this.selectedId); + }, + modalTitle() { + return this.isEditing + ? this.$options.translations.editTitle + : this.$options.translations.addTitle; + }, }, methods: { - ...mapActions(['addFreezePeriod', 'setSelectedTimezone', 'resetModal']), + ...mapActions(['addFreezePeriod', 'updateFreezePeriod', 'setSelectedTimezone', 'resetModal']), resetModalHandler() { this.resetModal(); }, @@ -89,6 +99,13 @@ export default { } return ''; }, + submit() { + if (this.isEditing) { + this.updateFreezePeriod(); + } else { + this.addFreezePeriod(); + } + }, }, }; </script> @@ -96,8 +113,9 @@ export default { <template> <gl-modal v-bind="$options.modalOptions" + :title="modalTitle" :action-primary="addDeployFreezeButton" - @primary="addFreezePeriod" + @primary="submit" @canceled="resetModalHandler" > <p> diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue index 0d6657973c3..8282f1d910a 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue @@ -1,7 +1,7 @@ <script> import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import { s__, __ } from '~/locale'; +import { s__ } from '~/locale'; export default { fields: [ @@ -17,9 +17,16 @@ export default { key: 'cronTimezone', label: s__('DeployFreeze|Time zone'), }, + { + key: 'edit', + label: s__('DeployFreeze|Edit'), + }, ], translations: { - addDeployFreeze: __('Add deploy freeze'), + addDeployFreeze: s__('DeployFreeze|Add deploy freeze'), + emptyStateText: s__( + 'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd}', + ), }, components: { GlTable, @@ -39,7 +46,7 @@ export default { this.fetchFreezePeriods(); }, methods: { - ...mapActions(['fetchFreezePeriods']), + ...mapActions(['fetchFreezePeriods', 'setFreezePeriod']), }, }; </script> @@ -53,15 +60,21 @@ export default { show-empty stacked="lg" > + <template #cell(cronTimezone)="{ item }"> + {{ item.cronTimezone.formattedTimezone }} + </template> + <template #cell(edit)="{ item }"> + <gl-button + v-gl-modal.deploy-freeze-modal + icon="pencil" + data-testid="edit-deploy-freeze" + :aria-label="__('Edit deploy freeze')" + @click="setFreezePeriod(item)" + /> + </template> <template #empty> <p data-testid="empty-freeze-periods" class="gl-text-center text-plain"> - <gl-sprintf - :message=" - s__( - 'DeployFreeze|No deploy freezes exist for this project. To add one, click %{strongStart}Add deploy freeze%{strongEnd}', - ) - " - > + <gl-sprintf :message="$options.translations.emptyStateText"> <template #strong="{ content }"> <strong>{{ content }}</strong> </template> @@ -73,7 +86,7 @@ export default { v-gl-modal.deploy-freeze-modal data-testid="add-deploy-freeze" category="primary" - variant="success" + variant="confirm" > {{ $options.translations.addDeployFreeze }} </gl-button> diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js index 62045d2517d..56e45595dc5 100644 --- a/app/assets/javascripts/deploy_freeze/store/actions.js +++ b/app/assets/javascripts/deploy_freeze/store/actions.js @@ -3,37 +3,53 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import * as types from './mutation_types'; -export const requestAddFreezePeriod = ({ commit }) => { +export const requestFreezePeriod = ({ commit }) => { commit(types.REQUEST_ADD_FREEZE_PERIOD); }; -export const receiveAddFreezePeriodSuccess = ({ commit }) => { +export const receiveFreezePeriodSuccess = ({ commit }) => { commit(types.RECEIVE_ADD_FREEZE_PERIOD_SUCCESS); }; -export const receiveAddFreezePeriodError = ({ commit }, error) => { +export const receiveFreezePeriodError = ({ commit }, error) => { commit(types.RECEIVE_ADD_FREEZE_PERIOD_ERROR, error); }; -export const addFreezePeriod = ({ state, dispatch, commit }) => { - dispatch('requestAddFreezePeriod'); +const receiveFreezePeriod = (store, request) => { + const { dispatch, commit } = store; + dispatch('requestFreezePeriod'); - return Api.createFreezePeriod(state.projectId, { - freeze_start: state.freezeStartCron, - freeze_end: state.freezeEndCron, - cron_timezone: state.selectedTimezoneIdentifier, - }) + request(store) .then(() => { - dispatch('receiveAddFreezePeriodSuccess'); + dispatch('receiveFreezePeriodSuccess'); commit(types.RESET_MODAL); dispatch('fetchFreezePeriods'); }) .catch((error) => { createFlash(__('Error: Unable to create deploy freeze')); - dispatch('receiveAddFreezePeriodError', error); + dispatch('receiveFreezePeriodError', error); }); }; +export const addFreezePeriod = (store) => + receiveFreezePeriod(store, ({ state }) => + Api.createFreezePeriod(state.projectId, { + freeze_start: state.freezeStartCron, + freeze_end: state.freezeEndCron, + cron_timezone: state.selectedTimezoneIdentifier, + }), + ); + +export const updateFreezePeriod = (store) => + receiveFreezePeriod(store, ({ state }) => + Api.updateFreezePeriod(state.projectId, { + id: state.selectedId, + freeze_start: state.freezeStartCron, + freeze_end: state.freezeEndCron, + cron_timezone: state.selectedTimezoneIdentifier, + }), + ); + export const fetchFreezePeriods = ({ commit, state }) => { commit(types.REQUEST_FREEZE_PERIODS); @@ -46,6 +62,13 @@ export const fetchFreezePeriods = ({ commit, state }) => { }); }; +export const setFreezePeriod = ({ commit }, freezePeriod) => { + commit(types.SET_SELECTED_ID, freezePeriod.id); + commit(types.SET_SELECTED_TIMEZONE, freezePeriod.cronTimezone); + commit(types.SET_FREEZE_START_CRON, freezePeriod.freezeStart); + commit(types.SET_FREEZE_END_CRON, freezePeriod.freezeEnd); +}; + export const setSelectedTimezone = ({ commit }, timezone) => { commit(types.SET_SELECTED_TIMEZONE, timezone); }; diff --git a/app/assets/javascripts/deploy_freeze/store/mutation_types.js b/app/assets/javascripts/deploy_freeze/store/mutation_types.js index 47a4874a5cf..8e6fdfd4443 100644 --- a/app/assets/javascripts/deploy_freeze/store/mutation_types.js +++ b/app/assets/javascripts/deploy_freeze/store/mutation_types.js @@ -6,6 +6,7 @@ export const RECEIVE_ADD_FREEZE_PERIOD_SUCCESS = 'RECEIVE_ADD_FREEZE_PERIOD_SUCC export const RECEIVE_ADD_FREEZE_PERIOD_ERROR = 'RECEIVE_ADD_FREEZE_PERIOD_ERROR'; export const SET_SELECTED_TIMEZONE = 'SET_SELECTED_TIMEZONE'; +export const SET_SELECTED_ID = 'SET_SELECTED_ID'; export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON'; export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON'; diff --git a/app/assets/javascripts/deploy_freeze/store/mutations.js b/app/assets/javascripts/deploy_freeze/store/mutations.js index 3b34f3950e6..e62000c007c 100644 --- a/app/assets/javascripts/deploy_freeze/store/mutations.js +++ b/app/assets/javascripts/deploy_freeze/store/mutations.js @@ -4,7 +4,11 @@ import * as types from './mutation_types'; const formatTimezoneName = (freezePeriod, timezoneList) => convertObjectPropsToCamelCase({ ...freezePeriod, - cron_timezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone)?.name, + cron_timezone: { + formattedTimezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone) + ?.name, + identifier: freezePeriod.cronTimezone, + }, }); export default { @@ -45,10 +49,15 @@ export default { state.freezeEndCron = freezeEndCron; }, + [types.SET_SELECTED_ID](state, id) { + state.selectedId = id; + }, + [types.RESET_MODAL](state) { state.freezeStartCron = ''; state.freezeEndCron = ''; state.selectedTimezone = ''; state.selectedTimezoneIdentifier = ''; + state.selectedId = ''; }, }; diff --git a/app/assets/javascripts/deploy_freeze/store/state.js b/app/assets/javascripts/deploy_freeze/store/state.js index 4cc38c097b6..1b16b4c645b 100644 --- a/app/assets/javascripts/deploy_freeze/store/state.js +++ b/app/assets/javascripts/deploy_freeze/store/state.js @@ -6,6 +6,7 @@ export default ({ selectedTimezoneIdentifier = '', freezeStartCron = '', freezeEndCron = '', + selectedId = '', }) => ({ projectId, freezePeriods, @@ -14,4 +15,5 @@ export default ({ selectedTimezoneIdentifier, freezeStartCron, freezeEndCron, + selectedId, }); diff --git a/app/assets/javascripts/deploy_tokens/components/revoke_button.vue b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue new file mode 100644 index 00000000000..e026391ae22 --- /dev/null +++ b/app/assets/javascripts/deploy_tokens/components/revoke_button.vue @@ -0,0 +1,81 @@ +<script> +import { GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; + +export default { + components: { + GlModal, + GlSprintf, + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: { + token: { + default: null, + }, + revokePath: { + default: '', + }, + buttonClass: { + default: '', + }, + }, + computed: { + modalId() { + return `revoke-modal-${this.token.id}`; + }, + }, + methods: { + cancelHandler() { + this.$refs.modal.hide(); + }, + }, +}; +</script> + +<template> + <div> + <gl-button + v-gl-modal="modalId" + :class="buttonClass" + category="primary" + variant="danger" + class="float-right" + data-testid="revoke-button" + >{{ s__('DeployTokens|Revoke') }}</gl-button + > + <gl-modal ref="modal" :modal-id="modalId"> + <template #modal-title> + <gl-sprintf :message="s__(`DeployTokens|Revoke %{boldStart}${token.name}%{boldEnd}?`)"> + <template #bold="{ content }" + ><b>{{ content }}</b></template + > + </gl-sprintf> + </template> + <gl-sprintf + :message="s__(`DeployTokens|You are about to revoke %{boldStart}${token.name}%{boldEnd}.`)" + > + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + {{ s__('DeployTokens|This action cannot be undone.') }} + <template #modal-footer> + <gl-button category="secondary" @click="cancelHandler">{{ s__('Cancel') }}</gl-button> + <gl-button + category="primary" + variant="danger" + :href="revokePath" + data-method="put" + class="text-truncate" + data-testid="primary-revoke-btn" + > + <gl-sprintf :message="s__('DeployTokens|Revoke %{name}')"> + <template #name>{{ token.name }}</template> + </gl-sprintf> + </gl-button> + </template> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/deploy_tokens/init_revoke_button.js b/app/assets/javascripts/deploy_tokens/init_revoke_button.js new file mode 100644 index 00000000000..20187150a60 --- /dev/null +++ b/app/assets/javascripts/deploy_tokens/init_revoke_button.js @@ -0,0 +1,26 @@ +import Vue from 'vue'; +import RevokeButton from './components/revoke_button.vue'; + +export default () => { + const containers = document.querySelectorAll('.js-deploy-token-revoke-button'); + + if (!containers.length) { + return false; + } + + return containers.forEach((el) => { + const { token, revokePath, buttonClass } = el.dataset; + + return new Vue({ + el, + provide: { + token: JSON.parse(token), + revokePath, + buttonClass, + }, + render(h) { + return h(RevokeButton); + }, + }); + }); +}; diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js index b1d486c5d66..8ca4dc587a8 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown_filter.js @@ -2,6 +2,7 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; import $ from 'jquery'; +import { debounce } from 'lodash'; import { isObject } from '~/lib/utils/type_utility'; const BLUR_KEYCODES = [27, 40]; @@ -11,13 +12,21 @@ const HAS_VALUE_CLASS = 'has-value'; export class GitLabDropdownFilter { constructor(input, options) { let ref; - let timeout; this.input = input; this.options = options; // eslint-disable-next-line no-cond-assign this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; const $inputContainer = this.input.parent(); const $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + const filterRemoteDebounced = debounce(() => { + $inputContainer.parent().addClass('is-loading'); + + return this.options.query(this.input.val(), (data) => { + $inputContainer.parent().removeClass('is-loading'); + return this.options.callback(data); + }); + }, 500); + $clearButton.on('click', (e) => { // Clear click e.preventDefault(); @@ -25,7 +34,6 @@ export class GitLabDropdownFilter { return this.input.val('').trigger('input').focus(); }); // Key events - timeout = ''; this.input .on('keydown', (e) => { const keyCode = e.which; @@ -41,16 +49,7 @@ export class GitLabDropdownFilter { } // Only filter asynchronously only if option remote is set if (this.options.remote) { - clearTimeout(timeout); - // eslint-disable-next-line no-return-assign - return (timeout = setTimeout(() => { - $inputContainer.parent().addClass('is-loading'); - - return this.options.query(this.input.val(), (data) => { - $inputContainer.parent().removeClass('is-loading'); - return this.options.callback(data); - }); - }, 250)); + return filterRemoteDebounced(); } return this.filter(this.input.val()); }); diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue index fbcce22ec1e..ae2ce7c3e5e 100644 --- a/app/assets/javascripts/design_management/components/delete_button.vue +++ b/app/assets/javascripts/design_management/components/delete_button.vue @@ -63,7 +63,7 @@ export default { title: s__('DesignManagement|Are you sure you want to archive the selected designs?'), actionPrimary: { text: s__('DesignManagement|Archive designs'), - attributes: { variant: 'warning', 'data-qa-selector': 'confirm_archiving_button' }, + attributes: { variant: 'confirm', 'data-qa-selector': 'confirm_archiving_button' }, }, actionCancel: { text: __('Cancel'), 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 2b867217327..833d7081a2c 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,6 +1,7 @@ <script> import { GlTooltipDirective, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; +import { __ } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -10,6 +11,9 @@ import { findNoteId, extractDesignNoteId } from '../../utils/design_management_u import DesignReplyForm from './design_reply_form.vue'; export default { + i18n: { + editCommentLabel: __('Edit comment'), + }, components: { UserAvatarLink, TimelineEntryItem, @@ -113,7 +117,8 @@ export default { v-if="isEditButtonVisible" v-gl-tooltip type="button" - :title="__('Edit comment')" + :title="$options.i18n.editCommentLabel" + :aria-label="$options.i18n.editCommentLabel" class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button" @click="isEditing = true" > diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue index 85c6bd4d79e..c9273f97bed 100644 --- a/app/assets/javascripts/design_management/components/design_scaler.vue +++ b/app/assets/javascripts/design_management/components/design_scaler.vue @@ -51,8 +51,18 @@ export default { <template> <gl-button-group class="gl-z-index-1"> - <gl-button icon="dash" :disabled="disableDecrease" @click="decrementScale" /> - <gl-button icon="redo" :disabled="disableReset" @click="resetScale" /> - <gl-button icon="plus" :disabled="disableIncrease" @click="incrementScale" /> + <gl-button + icon="dash" + :disabled="disableDecrease" + :aria-label="__('Decrease')" + @click="decrementScale" + /> + <gl-button icon="redo" :disabled="disableReset" :aria-label="__('Reset')" @click="resetScale" /> + <gl-button + icon="plus" + :disabled="disableIncrease" + :aria-label="__('Increase')" + @click="incrementScale" + /> </gl-button-group> </template> diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 2169c9111d2..b6163491abc 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -137,8 +137,7 @@ export default { <span :title="icon.tooltip" :aria-label="icon.tooltip"> <gl-icon :name="icon.name" - :size="18" - use-deprecated-sizes + :size="16" :class="icon.classes" data-qa-selector="design_status_icon" :data-qa-status="icon.name" diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue index 6091a3183ac..3ebcde817f9 100644 --- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue +++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue @@ -2,11 +2,20 @@ /* global Mousetrap */ import 'mousetrap'; import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; +import { + keysFor, + ISSUE_PREVIOUS_DESIGN, + ISSUE_NEXT_DESIGN, +} from '~/behaviors/shortcuts/keybindings'; import { s__, sprintf } from '~/locale'; import allDesignsMixin from '../../mixins/all_designs'; import { DESIGN_ROUTE_NAME } from '../../router/constants'; export default { + i18n: { + nextButton: s__('DesignManagement|Go to next design'), + previousButton: s__('DesignManagement|Go to previous design'), + }, components: { GlButton, GlButtonGroup, @@ -46,11 +55,14 @@ export default { }, }, mounted() { - Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign)); - Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign)); + Mousetrap.bind(keysFor(ISSUE_PREVIOUS_DESIGN), () => + this.navigateToDesign(this.previousDesign), + ); + Mousetrap.bind(keysFor(ISSUE_NEXT_DESIGN), () => this.navigateToDesign(this.nextDesign)); }, beforeDestroy() { - Mousetrap.unbind(['left', 'right'], this.navigateToDesign); + Mousetrap.unbind(keysFor(ISSUE_PREVIOUS_DESIGN)); + Mousetrap.unbind(keysFor(ISSUE_NEXT_DESIGN)); }, methods: { navigateToDesign(design) { @@ -73,7 +85,8 @@ export default { <gl-button v-gl-tooltip.bottom :disabled="!previousDesign" - :title="s__('DesignManagement|Go to previous design')" + :title="$options.i18n.previousButton" + :aria-label="$options.i18n.previousButton" icon="angle-left" class="js-previous-design" @click="navigateToDesign(previousDesign)" @@ -81,7 +94,8 @@ export default { <gl-button v-gl-tooltip.bottom :disabled="!nextDesign" - :title="s__('DesignManagement|Go to next design')" + :title="$options.i18n.nextButton" + :aria-label="$options.i18n.nextButton" icon="angle-right" class="js-next-design" @click="navigateToDesign(nextDesign)" diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index 8abf1529f3c..b84fe45b77e 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -1,13 +1,16 @@ <script> import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql'; -import { __, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; import DeleteButton from '../delete_button.vue'; import DesignNavigation from './design_navigation.vue'; export default { + i18n: { + downloadButtonLabel: s__('DesignManagement|Download design'), + }, components: { GlButton, GlIcon, @@ -119,7 +122,8 @@ export default { v-gl-tooltip.bottom :href="image" icon="download" - :title="s__('DesignManagement|Download design')" + :title="$options.i18n.downloadButtonLabel" + :aria-label="$options.i18n.downloadButtonLabel" /> <delete-button v-if="isLatestVersion && canDeleteDesign" diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue index 394ccb3c483..98b7ab5c094 100644 --- a/app/assets/javascripts/design_management/components/upload/button.vue +++ b/app/assets/javascripts/design_management/components/upload/button.vue @@ -38,7 +38,8 @@ export default { " :disabled="isSaving" :loading="isSaving" - variant="default" + category="secondary" + variant="confirm" size="small" @click="openFileUpload" > diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index f0930ade1b5..aa9f377ef16 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import App from './components/app.vue'; import apolloProvider from './graphql'; +import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql'; import createRouter from './router'; export default () => { @@ -8,7 +9,8 @@ export default () => { const { issueIid, projectPath, issuePath } = el.dataset; const router = createRouter(issuePath); - apolloProvider.clients.defaultClient.cache.writeData({ + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: activeDiscussionQuery, data: { activeDiscussion: { __typename: 'ActiveDiscussion', diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 8a11c25a795..ad78433c7ce 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import Mousetrap from 'mousetrap'; import { ApolloMutation } from 'vue-apollo'; +import { keysFor, ISSUE_CLOSE_DESIGN } from '~/behaviors/shortcuts/keybindings'; import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -171,7 +172,7 @@ export default { }, }, mounted() { - Mousetrap.bind('esc', this.closeDesign); + Mousetrap.bind(keysFor(ISSUE_CLOSE_DESIGN), this.closeDesign); this.trackPageViewEvent(); // Set active discussion immediately. @@ -180,7 +181,7 @@ export default { this.updateActiveDiscussionFromUrl(); }, beforeDestroy() { - Mousetrap.unbind('esc', this.closeDesign); + Mousetrap.unbind(keysFor(ISSUE_CLOSE_DESIGN)); }, methods: { addImageDiffNoteToStore(store, { data: { createImageDiffNote } }) { diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 99ac38fc554..04d80dc0069 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -379,8 +379,7 @@ export default { <delete-button v-if="isLatestVersion" :is-deleting="loading" - button-variant="warning" - button-category="secondary" + button-variant="default" button-class="gl-mr-3" button-size="small" data-qa-selector="archive_button" @@ -485,9 +484,7 @@ export default { <template #upload-text="{ openFileUpload }"> <gl-sprintf :message="$options.i18n.dropzoneDescriptionText"> <template #link="{ content }"> - <gl-link @click.stop="openFileUpload"> - {{ content }} - </gl-link> + <gl-link @click.stop="openFileUpload">{{ content }}</gl-link> </template> </gl-sprintf> </template> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 253e1e3b70e..7c610968209 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -3,6 +3,13 @@ import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Mousetrap from 'mousetrap'; import { mapState, mapGetters, mapActions } from 'vuex'; +import { + keysFor, + MR_PREVIOUS_FILE_IN_DIFF, + MR_NEXT_FILE_IN_DIFF, + MR_COMMITS_NEXT_COMMIT, + MR_COMMITS_PREVIOUS_COMMIT, +} from '~/behaviors/shortcuts/keybindings'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { isSingleViewStyle } from '~/helpers/diffs_helper'; import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; @@ -77,6 +84,16 @@ export default { required: false, default: '', }, + endpointCodequality: { + type: String, + required: false, + default: '', + }, + endpointUpdateUser: { + type: String, + required: false, + default: '', + }, projectPath: { type: String, required: true, @@ -153,6 +170,7 @@ export default { plainDiffPath: (state) => state.diffs.plainDiffPath, emailPatchPath: (state) => state.diffs.emailPatchPath, retrievingBatches: (state) => state.diffs.retrievingBatches, + codequalityDiff: (state) => state.diffs.codequalityDiff, }), ...mapState('diffs', [ 'showTreeList', @@ -167,6 +185,7 @@ export default { 'mrReviews', ]), ...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']), + ...mapGetters('batchComments', ['draftsCount']), ...mapGetters(['isNotesFetched', 'getNoteableData']), diffs() { if (!this.viewDiffsFileByFile) { @@ -264,6 +283,7 @@ export default { endpointMetadata: this.endpointMetadata, endpointBatch: this.endpointBatch, endpointCoverage: this.endpointCoverage, + endpointUpdateUser: this.endpointUpdateUser, projectPath: this.projectPath, dismissEndpoint: this.dismissEndpoint, showSuggestPopover: this.showSuggestPopover, @@ -272,6 +292,10 @@ export default { mrReviews: this.rehydratedMrReviews, }); + if (this.endpointCodequality) { + this.setCodequalityEndpoint(this.endpointCodequality); + } + if (this.shouldShow) { this.fetchData(); } @@ -316,9 +340,11 @@ export default { ...mapActions('diffs', [ 'moveToNeighboringCommit', 'setBaseConfig', + 'setCodequalityEndpoint', 'fetchDiffFilesMeta', 'fetchDiffFilesBatch', 'fetchCoverageFiles', + 'fetchCodequality', 'startRenderDiffsQueue', 'assignDiscussionsToDiff', 'setHighlightedRow', @@ -342,14 +368,6 @@ export default { refetchDiffData() { this.fetchData(false); }, - startDiffRendering() { - requestIdleCallback( - () => { - this.startRenderDiffsQueue(); - }, - { timeout: 1000 }, - ); - }, needsReload() { return this.diffFiles.length && isSingleViewStyle(this.diffFiles[0]); }, @@ -361,8 +379,6 @@ export default { .then(({ real_size }) => { this.diffFilesLength = parseInt(real_size, 10); if (toggleTree) this.setTreeDisplay(); - - this.startDiffRendering(); }) .catch(() => { createFlash(__('Something went wrong on our end. Please try again!')); @@ -377,7 +393,6 @@ export default { // change when loading the other half of the diff files. this.setDiscussions(); }) - .then(() => this.startDiffRendering()) .catch(() => { createFlash(__('Something went wrong on our end. Please try again!')); }); @@ -386,6 +401,10 @@ export default { this.fetchCoverageFiles(); } + if (this.endpointCodequality) { + this.fetchCodequality(); + } + if (!this.isNotesFetched) { notesEventHub.$emit('fetchNotesData'); } @@ -406,30 +425,23 @@ export default { } }, setEventListeners() { - Mousetrap.bind(['[', 'k', ']', 'j'], (e, combo) => { - switch (combo) { - case '[': - case 'k': - this.jumpToFile(-1); - break; - case ']': - case 'j': - this.jumpToFile(+1); - break; - default: - break; - } - }); + Mousetrap.bind(keysFor(MR_PREVIOUS_FILE_IN_DIFF), () => this.jumpToFile(-1)); + Mousetrap.bind(keysFor(MR_NEXT_FILE_IN_DIFF), () => this.jumpToFile(+1)); if (this.commit) { - Mousetrap.bind('c', () => this.moveToNeighboringCommit({ direction: 'next' })); - Mousetrap.bind('x', () => this.moveToNeighboringCommit({ direction: 'previous' })); + Mousetrap.bind(keysFor(MR_COMMITS_NEXT_COMMIT), () => + this.moveToNeighboringCommit({ direction: 'next' }), + ); + Mousetrap.bind(keysFor(MR_COMMITS_PREVIOUS_COMMIT), () => + this.moveToNeighboringCommit({ direction: 'previous' }), + ); } }, removeEventListeners() { - Mousetrap.unbind(['[', 'k', ']', 'j']); - Mousetrap.unbind('c'); - Mousetrap.unbind('x'); + Mousetrap.unbind(keysFor(MR_PREVIOUS_FILE_IN_DIFF)); + Mousetrap.unbind(keysFor(MR_NEXT_FILE_IN_DIFF)); + Mousetrap.unbind(keysFor(MR_COMMITS_NEXT_COMMIT)); + Mousetrap.unbind(keysFor(MR_COMMITS_PREVIOUS_COMMIT)); }, jumpToFile(step) { const targetIndex = this.currentDiffIndex + step; @@ -489,6 +501,7 @@ export default { <div v-if="renderFileTree" :style="{ width: `${treeWidth}px` }" + :class="{ 'review-bar-visible': draftsCount > 0 }" class="diff-tree-list js-diff-tree-list px-3 pr-md-0" > <panel-resizer diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 92b317eb3f0..bc0f2fb0b69 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -1,7 +1,6 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlButtonGroup, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { mapActions } from 'vuex'; +import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -9,7 +8,6 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { setUrlParams } from '../../lib/utils/url_utility'; import initUserPopovers from '../../user_popovers'; /** @@ -24,14 +22,6 @@ import initUserPopovers from '../../user_popovers'; * coexist, but there is an issue to remove the duplication. * https://gitlab.com/gitlab-org/gitlab-foss/issues/51613 * - * EXCEPTION WARNING - * 1. The commit navigation buttons (next neighbor, previous neighbor) - * are not duplicated because: - * - We don't have the same data available on the Rails side (yet, - * without backend work) - * - This Vue component should always be what's used when in the - * context of an MR diff, so the HAML should never have any idea - * about navigating among commits. */ export default { @@ -42,7 +32,6 @@ export default { CommitPipelineStatus, GlButtonGroup, GlButton, - GlIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -94,28 +83,12 @@ export default { // Strip the newline at the beginning return this.commit.description_html.replace(/^
/, ''); }, - nextCommitUrl() { - return this.commit.next_commit_id - ? setUrlParams({ commit_id: this.commit.next_commit_id }) - : ''; - }, - previousCommitUrl() { - return this.commit.prev_commit_id - ? setUrlParams({ commit_id: this.commit.prev_commit_id }) - : ''; - }, - hasNeighborCommits() { - return this.commit.next_commit_id || this.commit.prev_commit_id; - }, }, created() { this.$nextTick(() => { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); }); }, - methods: { - ...mapActions('diffs', ['moveToNeighboringCommit']), - }, }; </script> @@ -146,38 +119,6 @@ export default { class="input-group-text" /> </gl-button-group> - <div v-if="hasNeighborCommits" class="commit-nav-buttons ml-3"> - <gl-button-group> - <gl-button - :href="previousCommitUrl" - :disabled="!commit.prev_commit_id" - @click.prevent="moveToNeighboringCommit({ direction: 'previous' })" - > - <span - v-if="!commit.prev_commit_id" - v-gl-tooltip - class="h-100 w-100 position-absolute" - :title="__('You\'re at the first commit')" - ></span> - <gl-icon name="chevron-left" /> - {{ __('Prev') }} - </gl-button> - <gl-button - :href="nextCommitUrl" - :disabled="!commit.next_commit_id" - @click.prevent="moveToNeighboringCommit({ direction: 'next' })" - > - <span - v-if="!commit.next_commit_id" - v-gl-tooltip - class="h-100 w-100 position-absolute" - :title="__('You\'re at the last commit')" - ></span> - {{ __('Next') }} - <gl-icon name="chevron-right" /> - </gl-button> - </gl-button-group> - </div> </div> <div> <div class="d-flex float-left align-items-center align-self-start"> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 6b1e2bfb34e..7526c5347f7 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -1,7 +1,8 @@ <script> -import { GlTooltipDirective, GlLink, GlButton, GlSprintf } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlLink, GlButtonGroup, GlButton, GlSprintf } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; +import { setUrlParams } from '../../lib/utils/url_utility'; import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants'; import eventHub from '../event_hub'; import CompareDropdownLayout from './compare_dropdown_layout.vue'; @@ -11,7 +12,9 @@ import SettingsDropdown from './settings_dropdown.vue'; export default { components: { CompareDropdownLayout, + GlIcon, GlLink, + GlButtonGroup, GlButton, GlSprintf, SettingsDropdown, @@ -56,6 +59,19 @@ export default { hasSourceVersions() { return this.diffCompareDropdownSourceVersions.length > 0; }, + nextCommitUrl() { + return this.commit.next_commit_id + ? setUrlParams({ commit_id: this.commit.next_commit_id }) + : ''; + }, + previousCommitUrl() { + return this.commit.prev_commit_id + ? setUrlParams({ commit_id: this.commit.prev_commit_id }) + : ''; + }, + hasNeighborCommits() { + return this.commit && (this.commit.next_commit_id || this.commit.prev_commit_id); + }, }, created() { this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; @@ -65,6 +81,7 @@ export default { expandAllFiles() { eventHub.$emit(EVT_EXPAND_ALL_FILES); }, + ...mapActions('diffs', ['moveToNeighboringCommit']), }, }; </script> @@ -84,6 +101,7 @@ export default { icon="file-tree" class="gl-mr-3 js-toggle-tree-list" :title="toggleFileBrowserTitle" + :aria-label="toggleFileBrowserTitle" :selected="showTreeList" @click="setShowTreeList({ showTreeList: !showTreeList })" /> @@ -91,6 +109,38 @@ export default { {{ __('Viewing commit') }} <gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link> </div> + <div v-if="hasNeighborCommits" class="commit-nav-buttons ml-3"> + <gl-button-group> + <gl-button + :href="previousCommitUrl" + :disabled="!commit.prev_commit_id" + @click.prevent="moveToNeighboringCommit({ direction: 'previous' })" + > + <span + v-if="!commit.prev_commit_id" + v-gl-tooltip + class="h-100 w-100 position-absolute position-top-0 position-left-0" + :title="__('You\'re at the first commit')" + ></span> + <gl-icon name="chevron-left" /> + {{ __('Prev') }} + </gl-button> + <gl-button + :href="nextCommitUrl" + :disabled="!commit.next_commit_id" + @click.prevent="moveToNeighboringCommit({ direction: 'next' })" + > + <span + v-if="!commit.next_commit_id" + v-gl-tooltip + class="h-100 w-100 position-absolute position-top-0 position-left-0" + :title="__('You\'re at the last commit')" + ></span> + {{ __('Next') }} + <gl-icon name="chevron-right" /> + </gl-button> + </gl-button-group> + </div> <gl-sprintf v-else-if="hasSourceVersions" class="d-flex align-items-center compare-versions-container" diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index d0d457d8582..5e05ec87f84 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -68,6 +68,7 @@ export default { }" type="button" class="js-diff-notes-toggle" + :aria-label="__('Show comments')" @click="toggleDiscussion({ discussionId: discussion.id })" > <gl-icon v-if="discussion.expanded" name="collapse" class="collapse-icon" /> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index ca4543f7002..bdbc13a38c4 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -80,7 +80,7 @@ export default { genericError: GENERIC_ERROR, }, computed: { - ...mapState('diffs', ['currentDiffFileId']), + ...mapState('diffs', ['currentDiffFileId', 'codequalityDiff']), ...mapGetters(['isNotesFetched']), ...mapGetters('diffs', ['getDiffFileDiscussions']), viewBlobHref() { @@ -148,6 +148,11 @@ export default { return loggedIn && featureOn; }, + hasCodequalityChanges() { + return ( + this.codequalityDiff?.files && this.codequalityDiff?.files[this.file.file_path]?.length > 0 + ); + }, }, watch: { 'file.id': { @@ -294,6 +299,7 @@ export default { :add-merge-request-buttons="true" :view-diffs-file-by-file="viewDiffsFileByFile" :show-local-file-reviews="showLocalFileReviews" + :has-codequality-changes="hasCodequalityChanges" class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100" :class="hasBodyClasses.header" @toggleFile="handleToggle" diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 1f50b3a38a6..3b4e21ab61b 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -41,6 +41,7 @@ export default { GlDropdownDivider, GlFormCheckbox, GlLoadingIcon, + CodeQualityBadge: () => import('ee_component/diffs/components/code_quality_badge.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -49,6 +50,7 @@ export default { mixins: [glFeatureFlagsMixin()], i18n: { ...DIFF_FILE_HEADER, + compareButtonLabel: s__('Compare submodule commit revisions'), }, props: { discussionPath: { @@ -94,6 +96,11 @@ export default { required: false, default: false, }, + hasCodequalityChanges: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -192,6 +199,9 @@ export default { isReviewable() { return reviewable(this.diffFile); }, + externalUrlLabel() { + return sprintf(__('View on %{url}'), { url: this.diffFile.formatted_external_url }); + }, }, methods: { ...mapActions('diffs', [ @@ -323,6 +333,8 @@ export default { data-track-property="diff_copy_file" /> + <code-quality-badge v-if="hasCodequalityChanges" class="gl-mr-2" /> + <small v-if="isModeChanged" ref="fileMode" class="mr-1"> {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> @@ -352,7 +364,8 @@ export default { ref="externalLink" v-gl-tooltip.hover :href="diffFile.external_url" - :title="`View on ${diffFile.formatted_external_url}`" + :title="externalUrlLabel" + :aria-label="externalUrlLabel" target="_blank" data-track-event="click_toggle_external_button" data-track-label="diff_toggle_external_button" @@ -444,7 +457,8 @@ export default { v-gl-tooltip.hover v-safe-html="submoduleDiffCompareLinkText" class="submodule-compare" - :title="s__('Compare submodule commit revisions')" + :title="$options.i18n.compareButtonLabel" + :aria-label="$options.i18n.compareButtonLabel" :href="diffFile.submodule_compare.url" /> </div> diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index 2f09f2e24b2..51da1966630 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -10,7 +10,12 @@ import { } from '../../notes/components/multiline_comment_utils'; import noteForm from '../../notes/components/note_form.vue'; import autosave from '../../notes/mixins/autosave'; -import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; +import { + DIFF_NOTE_TYPE, + INLINE_DIFF_LINES_KEY, + PARALLEL_DIFF_VIEW_TYPE, + OLD_LINE_TYPE, +} from '../constants'; export default { components: { @@ -113,6 +118,34 @@ export default { const lines = getDiffLines(); return commentLineOptions(lines, this.line, this.line.line_code, side); }, + commentLines() { + if (!this.selectedCommentPosition) return []; + + const lines = []; + const { start, end } = this.selectedCommentPosition; + const diffLines = this.diffFile[INLINE_DIFF_LINES_KEY]; + let isAdding = false; + + for (let i = 0, diffLinesLength = diffLines.length - 1; i <= diffLinesLength; i += 1) { + const line = diffLines[i]; + + if (start.line_code === line.line_code) { + isAdding = true; + } + + if (isAdding) { + if (line.type !== OLD_LINE_TYPE) { + lines.push(line); + } + + if (end.line_code === line.line_code) { + break; + } + } + } + + return lines; + }, }, mounted() { if (this.isLoggedIn) { @@ -177,6 +210,7 @@ export default { :is-editing="true" :line-code="line.line_code" :line="line" + :lines="commentLines" :help-page-path="helpPagePath" :diff-file="diffFile" :show-suggest-popover="showSuggestPopover" diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue index ab6890d66b5..8d398a2ded4 100644 --- a/app/assets/javascripts/diffs/components/diff_row.vue +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -1,5 +1,6 @@ <script> -import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +/* eslint-disable vue/no-v-html */ +import { GlTooltipDirective } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -12,17 +13,20 @@ import { CONFLICT_THEIR, CONFLICT_MARKER, } from '../constants'; +import { + getInteropInlineAttributes, + getInteropOldSideAttributes, + getInteropNewSideAttributes, +} from '../utils/interoperability'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; import * as utils from './diff_row_utils'; export default { components: { - GlIcon, DiffGutterAvatars, }, directives: { GlTooltip: GlTooltipDirective, - SafeHtml, }, mixins: [glFeatureFlagsMixin()], props: { @@ -117,6 +121,16 @@ export default { isLeftConflictMarker() { return [CONFLICT_MARKER_OUR, CONFLICT_MARKER_THEIR].includes(this.line.left?.type); }, + interopLeftAttributes() { + if (this.inline) { + return getInteropInlineAttributes(this.line.left); + } + + return getInteropOldSideAttributes(this.line.left); + }, + interopRightAttributes() { + return getInteropNewSideAttributes(this.line.right); + }, }, mounted() { this.scrollToLineIfNeededParallel(this.line); @@ -182,6 +196,7 @@ export default { <div data-testid="left-side" class="diff-grid-left left-side" + v-bind="interopLeftAttributes" @dragover.prevent @dragenter="onDragEnter(line.left, index)" @dragend="onDragEnd" @@ -203,14 +218,13 @@ export default { <button :draggable="glFeatures.dragCommentSelection" type="button" - class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" + class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button qa-diff-comment" + data-qa-selector="diff_comment_button" :class="{ 'gl-cursor-grab': dragging }" :disabled="line.left.commentsDisabled" @click="handleCommentButton(line.left)" @dragstart="onDragStart({ ...line.left, index })" - > - <gl-icon :size="12" name="comment" /> - </button> + ></button> </span> </template> <a @@ -258,7 +272,7 @@ export default { @mousedown="handleParallelLineMouseDown" > <strong v-if="isLeftConflictMarker">{{ conflictText(line.left) }}</strong> - <span v-else v-safe-html="line.left.rich_text"></span> + <span v-else v-html="line.left.rich_text"></span> </div> </template> <template v-else-if="!inline || (line.left && line.left.type === $options.CONFLICT_MARKER)"> @@ -288,6 +302,7 @@ export default { v-if="!inline" data-testid="right-side" class="diff-grid-right right-side" + v-bind="interopRightAttributes" @dragover.prevent @dragenter="onDragEnter(line.right, index)" @dragend="onDragEnd" @@ -305,14 +320,12 @@ export default { <button :draggable="glFeatures.dragCommentSelection" type="button" - class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" + class="add-diff-note unified-diff-components-diff-note-button note-button js-add-diff-note-button qa-diff-comment" :class="{ 'gl-cursor-grab': dragging }" :disabled="line.right.commentsDisabled" @click="handleCommentButton(line.right)" @dragstart="onDragStart({ ...line.right, index })" - > - <gl-icon :size="12" name="comment" /> - </button> + ></button> </span> </template> <a @@ -349,7 +362,6 @@ export default { <div :id="line.right.line_code" :key="line.right.rich_text" - v-safe-html="line.right.rich_text" :class="[ line.right.type, { @@ -364,7 +376,7 @@ export default { <strong v-if="line.right.type === $options.CONFLICT_MARKER_THEIR">{{ conflictText(line.right) }}</strong> - <span v-else v-safe-html="line.right.rich_text"></span> + <span v-else v-html="line.right.rich_text"></span> </div> </template> <template v-else> diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue index 3d05202fb2d..5572338908f 100644 --- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue +++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue @@ -122,6 +122,7 @@ export default { :disabled="!shouldToggleDiscussion" class="js-image-badge" type="button" + :aria-label="__('Show comments')" @click="clickedToggle(discussion)" > <gl-icon v-if="showCommentIcon" name="image-comment-dark" :size="24" /> diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index fb9202c5aab..25403b1547e 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -2,6 +2,7 @@ import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { CONTEXT_LINE_CLASS_NAME } from '../constants'; +import { getInteropInlineAttributes } from '../utils/interoperability'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; import { isHighlighted, @@ -96,6 +97,9 @@ export default { shouldShowAvatarsOnGutter() { return this.line.hasDiscussions; }, + interopAttrs() { + return getInteropInlineAttributes(this.line); + }, }, mounted() { this.scrollToLineIfNeededInline(this.line); @@ -124,6 +128,7 @@ export default { :id="inlineRowId" :class="classNameMap" class="line_holder" + v-bind="interopAttrs" @mouseover="handleMouseMove" @mouseout="handleMouseMove" > @@ -140,8 +145,8 @@ export default { ref="addDiffNoteButton" type="button" class="add-diff-note note-button js-add-diff-note-button" - data-qa-selector="diff_comment_button" :disabled="line.commentsDisabled" + :aria-label="addCommentTooltip" @click="handleCommentButton" > <gl-icon :size="12" name="comment" /> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index 3d20dfd0c9b..96946d0fd88 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -3,6 +3,10 @@ import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gi import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; +import { + getInteropOldSideAttributes, + getInteropNewSideAttributes, +} from '../utils/interoperability'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; import * as utils from './diff_row_utils'; @@ -108,6 +112,12 @@ export default { this.line.hasDiscussionsRight, ); }, + interopLeftAttributes() { + return getInteropOldSideAttributes(this.line.left); + }, + interopRightAttributes() { + return getInteropNewSideAttributes(this.line.right); + }, }, mounted() { this.scrollToLineIfNeededParallel(this.line); @@ -185,6 +195,7 @@ export default { type="button" class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" :disabled="line.left.commentsDisabled" + :aria-label="addCommentTooltipLeft" @click="handleCommentButton(line.left)" > <gl-icon :size="12" name="comment" /> @@ -217,6 +228,7 @@ export default { :key="line.left.line_code" v-safe-html="line.left.rich_text" :class="parallelViewLeftLineType" + v-bind="interopLeftAttributes" class="line_content with-coverage parallel left-side" @mousedown="handleParallelLineMouseDown" ></td> @@ -241,6 +253,7 @@ export default { type="button" class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" :disabled="line.right.commentsDisabled" + :aria-label="addCommentTooltipRight" @click="handleCommentButton(line.right)" > <gl-icon :size="12" name="comment" /> @@ -283,6 +296,7 @@ export default { hll: isHighlighted, }, ]" + v-bind="interopRightAttributes" class="line_content with-coverage parallel right-side" @mousedown="handleParallelLineMouseDown" ></td> diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 87e9af174e5..5a8862c2b70 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -73,6 +73,8 @@ export default function initDiffsApp(store) { endpointMetadata: dataset.endpointMetadata || '', endpointBatch: dataset.endpointBatch || '', endpointCoverage: dataset.endpointCoverage || '', + endpointCodequality: dataset.endpointCodequality || '', + endpointUpdateUser: dataset.updateCurrentUserPath, projectPath: dataset.projectPath, helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, @@ -114,6 +116,8 @@ export default function initDiffsApp(store) { endpointMetadata: this.endpointMetadata, endpointBatch: this.endpointBatch, endpointCoverage: this.endpointCoverage, + endpointCodequality: this.endpointCodequality, + endpointUpdateUser: this.endpointUpdateUser, currentUser: this.currentUser, projectPath: this.projectPath, helpPagePath: this.helpPagePath, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 8796016def9..428faf693b0 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -49,7 +49,6 @@ import { convertExpandLines, idleCallback, allDiscussionWrappersExpanded, - prepareDiffData, prepareLineForRenamedFile, } from './utils'; @@ -59,6 +58,7 @@ export const setBaseConfig = ({ commit }, options) => { endpointMetadata, endpointBatch, endpointCoverage, + endpointUpdateUser, projectPath, dismissEndpoint, showSuggestPopover, @@ -71,6 +71,7 @@ export const setBaseConfig = ({ commit }, options) => { endpointMetadata, endpointBatch, endpointCoverage, + endpointUpdateUser, projectPath, dismissEndpoint, showSuggestPopover, @@ -163,7 +164,15 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { return pagination.next_page; }) - .then((nextPage) => nextPage && getBatch(nextPage)) + .then((nextPage) => { + dispatch('startRenderDiffsQueue'); + + if (nextPage) { + return getBatch(nextPage); + } + + return null; + }) .catch(() => commit(types.SET_RETRIEVING_BATCHES, false)); return getBatch() @@ -197,13 +206,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []); commit(types.SET_DIFF_METADATA, strippedData); - worker.postMessage( - prepareDiffData({ - diff: data, - priorFiles: state.diffFiles, - meta: true, - }), - ); + worker.postMessage(data.diff_files); return data; }) @@ -304,33 +307,41 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi }; export const startRenderDiffsQueue = ({ state, commit }) => { - const checkItem = () => - new Promise((resolve) => { - const nextFile = state.diffFiles.find( - (file) => - !file.renderIt && - file.viewer && - (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text), - ); - - if (nextFile) { - requestAnimationFrame(() => { - commit(types.RENDER_FILE, nextFile); + const diffFilesToRender = state.diffFiles.filter( + (file) => + !file.renderIt && + file.viewer && + (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text), + ); + let currentDiffFileIndex = 0; + + const checkItem = () => { + const nextFile = diffFilesToRender[currentDiffFileIndex]; + + if (nextFile) { + let retryCount = 0; + currentDiffFileIndex += 1; + commit(types.RENDER_FILE, nextFile); + + const requestIdle = () => + requestIdleCallback((idleDeadline) => { + // Wait for at least 5ms before trying to render + // or for 5 tries and then force render the file + if (idleDeadline.timeRemaining() >= 5 || retryCount > 4) { + checkItem(); + } else { + requestIdle(); + retryCount += 1; + } }); - requestIdleCallback( - () => { - checkItem() - .then(resolve) - .catch(() => {}); - }, - { timeout: 1000 }, - ); - } else { - resolve(); - } - }); - return checkItem(); + requestIdle(); + } + }; + + if (diffFilesToRender.length) { + checkItem(); + } }; export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file); @@ -738,10 +749,22 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => { commit(types.VIEW_DIFF_FILE, fileHash); }; -export const setFileByFile = ({ commit }, { fileByFile }) => { +export const setFileByFile = ({ state, commit }, { fileByFile }) => { const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES; commit(types.SET_FILE_BY_FILE, fileByFile); Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode); + + return axios + .put(state.endpointUpdateUser, { + view_diffs_file_by_file: fileByFile, + }) + .then(() => { + // https://gitlab.com/gitlab-org/gitlab/-/issues/326961 + // We can't even do a simple console warning here because + // the pipeline will fail. However, the issue above will + // eventually handle errors appropriately. + // console.warn('Saving the file-by-fil user preference failed.'); + }); }; export function reviewFile({ commit, state }, { file, reviewed = true }) { diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 1fc2a684e95..dec3f87b03e 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -156,16 +156,16 @@ export const diffLines = (state) => (file, unifiedDiffComponents) => { ); }; -export function suggestionCommitMessage(state) { +export function suggestionCommitMessage(state, _, rootState) { return (values = {}) => computeSuggestionCommitMessage({ message: state.defaultSuggestionCommitMessage, values: { - branch_name: state.branchName, - project_path: state.projectPath, - project_name: state.projectName, - username: state.username, - user_full_name: state.userFullName, + branch_name: rootState.page.mrMetadata.branch_name, + project_path: rootState.page.mrMetadata.project_path, + project_name: rootState.page.mrMetadata.project_name, + username: rootState.page.mrMetadata.username, + user_full_name: rootState.page.mrMetadata.user_full_name, ...values, }, }); diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index f93435363ec..1674d3d3b5a 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -9,7 +9,8 @@ import { import { fileByFile } from '../../utils/preferences'; import { getDefaultWhitespace } from '../utils'; -const viewTypeFromQueryString = getParameterValues('view')[0]; +const getViewTypeFromQueryString = () => getParameterValues('view')[0]; + const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME); const defaultViewType = INLINE_DIFF_VIEW_TYPE; const whiteSpaceFromQueryString = getParameterValues('w')[0]; @@ -23,6 +24,7 @@ export default () => ({ addedLines: null, removedLines: null, endpoint: '', + endpointUpdateUser: '', basePath: '', commit: null, startVersion: null, // Null unless a target diff is selected for comparison that is not the "base" diff @@ -30,7 +32,7 @@ export default () => ({ coverageFiles: {}, mergeRequestDiffs: [], mergeRequestDiff: null, - diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType, + diffViewType: getViewTypeFromQueryString() || viewTypeFromCookie || defaultViewType, tree: [], treeEntries: {}, showTreeList: true, diff --git a/app/assets/javascripts/diffs/store/modules/index.js b/app/assets/javascripts/diffs/store/modules/index.js index 6860e24db6b..03d11e60745 100644 --- a/app/assets/javascripts/diffs/store/modules/index.js +++ b/app/assets/javascripts/diffs/store/modules/index.js @@ -1,7 +1,7 @@ -import * as actions from '../actions'; +import * as actions from 'ee_else_ce/diffs/store/actions'; +import createState from 'ee_else_ce/diffs/store/modules/diff_state'; +import mutations from 'ee_else_ce/diffs/store/mutations'; import * as getters from '../getters'; -import mutations from '../mutations'; -import createState from './diff_state'; export default () => ({ namespaced: true, diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index d06793c05af..9ff9a02d444 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -33,6 +33,7 @@ export default { endpointMetadata, endpointBatch, endpointCoverage, + endpointUpdateUser, projectPath, dismissEndpoint, showSuggestPopover, @@ -45,6 +46,7 @@ export default { endpointMetadata, endpointBatch, endpointCoverage, + endpointUpdateUser, projectPath, dismissEndpoint, showSuggestPopover, @@ -77,15 +79,10 @@ export default { }, [types.SET_DIFF_DATA_BATCH](state, data) { - const files = prepareDiffData({ + state.diffFiles = prepareDiffData({ diff: data, priorFiles: state.diffFiles, }); - - Object.assign(state, { - ...convertObjectPropsToCamelCase(data), - }); - updateDiffFilesInState(state, files); }, [types.SET_COVERAGE_DATA](state, coverageFiles) { diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index b37a75eb2a3..7fa51b9ddea 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -381,22 +381,13 @@ function prepareDiffFileLines(file) { inlineLines.forEach((line) => prepareLine(line, file)); // WARNING: In-Place Mutations! - Object.assign(file, { - inlineLinesCount: inlineLines.length, - }); - return file; } -function getVisibleDiffLines(file) { - return file.inlineLinesCount; -} - -function finalizeDiffFile(file) { - const lines = getVisibleDiffLines(file); - +function finalizeDiffFile(file, index) { Object.assign(file, { - renderIt: lines < LINES_TO_BE_RENDERED_DIRECTLY, + renderIt: + index < 3 ? file[INLINE_DIFF_LINES_KEY].length < LINES_TO_BE_RENDERED_DIRECTLY : false, isShowingFullFile: false, isLoadingFullFile: false, discussions: [], @@ -424,7 +415,7 @@ export function prepareDiffData({ diff, priorFiles = [], meta = false }) { .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta })) .map(ensureBasicDiffFileLines) .map(prepareDiffFileLines) - .map(finalizeDiffFile); + .map((file, index) => finalizeDiffFile(file, priorFiles.length + index)); return deduplicateFilesList([...priorFiles, ...cleanedFiles]); } diff --git a/app/assets/javascripts/diffs/utils/interoperability.js b/app/assets/javascripts/diffs/utils/interoperability.js new file mode 100644 index 00000000000..a52e8fd25f5 --- /dev/null +++ b/app/assets/javascripts/diffs/utils/interoperability.js @@ -0,0 +1,49 @@ +const OLD = 'old'; +const NEW = 'new'; +const ATTR_PREFIX = 'data-interop-'; + +export const ATTR_TYPE = `${ATTR_PREFIX}type`; +export const ATTR_LINE = `${ATTR_PREFIX}line`; +export const ATTR_NEW_LINE = `${ATTR_PREFIX}new-line`; +export const ATTR_OLD_LINE = `${ATTR_PREFIX}old-line`; + +export const getInteropInlineAttributes = (line) => { + if (!line) { + return null; + } + + const interopType = line.type?.startsWith(OLD) ? OLD : NEW; + + const interopLine = interopType === OLD ? line.old_line : line.new_line; + + return { + [ATTR_TYPE]: interopType, + [ATTR_LINE]: interopLine, + [ATTR_NEW_LINE]: line.new_line, + [ATTR_OLD_LINE]: line.old_line, + }; +}; + +export const getInteropOldSideAttributes = (line) => { + if (!line) { + return null; + } + + return { + [ATTR_TYPE]: OLD, + [ATTR_LINE]: line.old_line, + [ATTR_OLD_LINE]: line.old_line, + }; +}; + +export const getInteropNewSideAttributes = (line) => { + if (!line) { + return null; + } + + return { + [ATTR_TYPE]: NEW, + [ATTR_LINE]: line.new_line, + [ATTR_NEW_LINE]: line.new_line, + }; +}; diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js index 74fa6887ba5..6f068aaa800 100644 --- a/app/assets/javascripts/droplab/drop_lab.js +++ b/app/assets/javascripts/droplab/drop_lab.js @@ -60,21 +60,24 @@ class DropLab { addEvents() { this.eventWrapper.documentClicked = this.documentClicked.bind(this); - document.addEventListener('mousedown', this.eventWrapper.documentClicked); + document.addEventListener('click', this.eventWrapper.documentClicked); } documentClicked(e) { - let thisTag = e.target; + if (e.defaultPrevented) return; - if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL'); - if (utils.isDropDownParts(thisTag, this.hooks)) return; - if (utils.isDropDownParts(e.target, this.hooks)) return; + if (utils.isDropDownParts(e.target)) return; + + if (e.target.tagName !== 'UL') { + const closestUl = utils.closest(e.target, 'UL'); + if (utils.isDropDownParts(closestUl)) return; + } this.hooks.forEach((hook) => hook.list.hide()); } removeEvents() { - document.removeEventListener('mousedown', this.eventWrapper.documentClicked); + document.removeEventListener('click', this.eventWrapper.documentClicked); } changeHookList(trigger, list, plugins, config) { diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js index c58d0052251..c51d6167fa3 100644 --- a/app/assets/javascripts/droplab/hook_button.js +++ b/app/assets/javascripts/droplab/hook_button.js @@ -18,6 +18,8 @@ class HookButton extends Hook { } clicked(e) { + e.preventDefault(); + const buttonEvent = new CustomEvent('click.dl', { detail: { hook: this, diff --git a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js index 8d350068973..3d4f08131c1 100644 --- a/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js +++ b/app/assets/javascripts/editor/extensions/editor_lite_extension_base.js @@ -1,11 +1,85 @@ -import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from '../constants'; +import { Range } from 'monaco-editor'; +import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION, EDITOR_TYPE_CODE } from '../constants'; + +const hashRegexp = new RegExp('#?L', 'g'); + +const createAnchor = (href) => { + const fragment = new DocumentFragment(); + const el = document.createElement('a'); + el.classList.add('link-anchor'); + el.href = href; + fragment.appendChild(el); + el.addEventListener('contextmenu', (e) => { + e.stopPropagation(); + }); + return fragment; +}; export class EditorLiteExtension { constructor({ instance, ...options } = {}) { if (instance) { Object.assign(instance, options); + EditorLiteExtension.highlightLines(instance); + if (instance.getEditorType && instance.getEditorType() === EDITOR_TYPE_CODE) { + EditorLiteExtension.setupLineLinking(instance); + } } else if (Object.entries(options).length) { throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION); } } + + static highlightLines(instance) { + const { hash } = window.location; + if (!hash) { + return; + } + const [start, end] = hash.replace(hashRegexp, '').split('-'); + let startLine = start ? parseInt(start, 10) : null; + let endLine = end ? parseInt(end, 10) : startLine; + if (endLine < startLine) { + [startLine, endLine] = [endLine, startLine]; + } + if (startLine) { + window.requestAnimationFrame(() => { + instance.revealLineInCenter(startLine); + Object.assign(instance, { + lineDecorations: instance.deltaDecorations( + [], + [ + { + range: new Range(startLine, 1, endLine, 1), + options: { isWholeLine: true, className: 'active-line-text' }, + }, + ], + ), + }); + }); + } + } + + static onMouseMoveHandler(e) { + const target = e.target.element; + if (target.classList.contains('line-numbers')) { + const lineNum = e.target.position.lineNumber; + const hrefAttr = `#L${lineNum}`; + let el = target.querySelector('a'); + if (!el) { + el = createAnchor(hrefAttr); + target.appendChild(el); + } + } + } + + static setupLineLinking(instance) { + instance.onMouseMove(EditorLiteExtension.onMouseMoveHandler); + instance.onMouseDown((e) => { + const isCorrectAnchor = e.target.element.classList.contains('link-anchor'); + if (!isCorrectAnchor) { + return; + } + if (instance.lineDecorations) { + instance.deltaDecorations(instance.lineDecorations, []); + } + }); + } } diff --git a/app/assets/javascripts/emoji/awards_app/index.js b/app/assets/javascripts/emoji/awards_app/index.js new file mode 100644 index 00000000000..16268910f49 --- /dev/null +++ b/app/assets/javascripts/emoji/awards_app/index.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import { mapActions, mapState } from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import AwardsList from '~/vue_shared/components/awards_list.vue'; +import createstore from './store'; + +export default (el) => { + const { + dataset: { path }, + } = el; + const canAwardEmoji = parseBoolean(el.dataset.canAwardEmoji); + + return new Vue({ + el, + store: createstore(), + computed: { + ...mapState(['currentUserId', 'canAwardEmoji', 'awards']), + }, + created() { + this.setInitialData({ path, currentUserId: window.gon.current_user_id, canAwardEmoji }); + }, + mounted() { + this.fetchAwards(); + }, + methods: { + ...mapActions(['setInitialData', 'fetchAwards', 'toggleAward']), + }, + render(createElement) { + return createElement(AwardsList, { + props: { + awards: this.awards, + canAwardEmoji: this.canAwardEmoji, + currentUserId: this.currentUserId, + defaultAwards: ['thumbsup', 'thumbsdown'], + selectedClass: 'gl-bg-blue-50! is-active', + }, + on: { + award: this.toggleAward, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js new file mode 100644 index 00000000000..482acc5a3a9 --- /dev/null +++ b/app/assets/javascripts/emoji/awards_app/store/actions.js @@ -0,0 +1,51 @@ +import * as Sentry from '@sentry/browser'; +import axios from '~/lib/utils/axios_utils'; +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import showToast from '~/vue_shared/plugins/global_toast'; +import { + SET_INITIAL_DATA, + FETCH_AWARDS_SUCCESS, + ADD_NEW_AWARD, + REMOVE_AWARD, +} from './mutation_types'; + +export const setInitialData = ({ commit }, data) => commit(SET_INITIAL_DATA, data); + +export const fetchAwards = async ({ commit, dispatch, state }, page = '1') => { + try { + const { data, headers } = await axios.get(state.path, { params: { per_page: 100, page } }); + const normalizedHeaders = normalizeHeaders(headers); + const nextPage = normalizedHeaders['X-NEXT-PAGE']; + + commit(FETCH_AWARDS_SUCCESS, data); + + if (nextPage) { + dispatch('fetchAwards', nextPage); + } + } catch (error) { + Sentry.captureException(error); + } +}; + +export const toggleAward = async ({ commit, state }, name) => { + const award = state.awards.find((a) => a.name === name && a.user.id === state.currentUserId); + + try { + if (award) { + await axios.delete(`${state.path}/${award.id}`); + + commit(REMOVE_AWARD, award.id); + + showToast(__('Award removed')); + } else { + const { data } = await axios.post(state.path, { name }); + + commit(ADD_NEW_AWARD, data); + + showToast(__('Award added')); + } + } catch (error) { + Sentry.captureException(error); + } +}; diff --git a/app/assets/javascripts/emoji/awards_app/store/index.js b/app/assets/javascripts/emoji/awards_app/store/index.js new file mode 100644 index 00000000000..53ed50f9f5d --- /dev/null +++ b/app/assets/javascripts/emoji/awards_app/store/index.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +const createState = () => ({ + awards: [], + awardPath: '', + currentUserId: null, + canAwardEmoji: false, +}); + +export default () => + new Vuex.Store({ + state: createState(), + actions, + mutations, + }); diff --git a/app/assets/javascripts/emoji/awards_app/store/mutation_types.js b/app/assets/javascripts/emoji/awards_app/store/mutation_types.js new file mode 100644 index 00000000000..af6289d0943 --- /dev/null +++ b/app/assets/javascripts/emoji/awards_app/store/mutation_types.js @@ -0,0 +1,6 @@ +export const SET_INITIAL_DATA = 'SET_INITIAL_DATA'; + +export const FETCH_AWARDS_SUCCESS = 'FETCH_AWARDS_SUCCESS'; + +export const ADD_NEW_AWARD = 'ADD_NEW_AWARD'; +export const REMOVE_AWARD = 'REMOVE_AWARD'; diff --git a/app/assets/javascripts/emoji/awards_app/store/mutations.js b/app/assets/javascripts/emoji/awards_app/store/mutations.js new file mode 100644 index 00000000000..8edcfa92885 --- /dev/null +++ b/app/assets/javascripts/emoji/awards_app/store/mutations.js @@ -0,0 +1,23 @@ +import { + SET_INITIAL_DATA, + FETCH_AWARDS_SUCCESS, + ADD_NEW_AWARD, + REMOVE_AWARD, +} from './mutation_types'; + +export default { + [SET_INITIAL_DATA](state, { path, currentUserId, canAwardEmoji }) { + state.path = path; + state.currentUserId = currentUserId; + state.canAwardEmoji = canAwardEmoji; + }, + [FETCH_AWARDS_SUCCESS](state, data) { + state.awards.push(...data); + }, + [ADD_NEW_AWARD](state, data) { + state.awards.push(data); + }, + [REMOVE_AWARD](state, awardId) { + state.awards = state.awards.filter(({ id }) => id !== awardId); + }, +}; diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue index db6ead3ff69..39881979c4f 100644 --- a/app/assets/javascripts/emoji/components/category.vue +++ b/app/assets/javascripts/emoji/components/category.vue @@ -39,7 +39,7 @@ export default { <template> <gl-intersection-observer class="gl-px-5 gl-h-full" @appear="categoryAppeared"> - <div class="gl-top-0 gl-py-3 gl-w-full emoji-picker-category-header"> + <div class="gl-top-0 gl-py-3 gl-w-full gl-z-index-1 emoji-picker-category-header"> <b>{{ categoryTitle }}</b> </div> <template v-if="emojis.length"> diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index 37f3433b781..71cabe80529 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -82,6 +82,8 @@ export default { no-flip right lazy + @shown="$emit('shown')" + @hidden="$emit('hidden')" > <template #button-content><slot name="button-content"></slot></template> <gl-search-box-by-type @@ -99,10 +101,11 @@ export default { v-for="(category, index) in categoryNames" :key="category.name" :class="{ - 'gl-text-black-normal! emoji-picker-category-active': index === currentCategory, + 'gl-text-body! emoji-picker-category-active': index === currentCategory, }" type="button" class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab" + :aria-label="category.name" @click="scrollToCategory(category.name)" > <gl-icon :name="category.icon" :size="12" /> diff --git a/app/assets/javascripts/ensure_data.js b/app/assets/javascripts/ensure_data.js new file mode 100644 index 00000000000..5b4d1afc9d0 --- /dev/null +++ b/app/assets/javascripts/ensure_data.js @@ -0,0 +1,56 @@ +import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg'; +import { GlEmptyState } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import { __ } from '~/locale'; + +const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly'); +const ERROR_FETCHING_DATA_DESCRIPTION = __( + 'Please try and refresh the page. If the problem persists please contact support.', +); + +/** + * This function takes a Component and extends it with data from the `parseData` function. + * The data will be made available through `props` and `proivde`. + * If the `parseData` throws, the `GlEmptyState` will be returned. + * @param {Component} Component a component to render + * @param {Object} options + * @param {Function} options.parseData a function to parse `data` + * @param {Object} options.data an object to pass to `parseData` + * @param {Boolean} options.shouldLog to tell whether to log any thrown error by `parseData` to Sentry + * @param {Object} options.props to override passed `props` data + * @param {Object} options.provide to override passed `provide` data + * @param {*} ...options the remaining options will be passed as properties to `createElement` + * @return {Component} a Vue component to render, either the GlEmptyState or the extended Component + */ +export default function ensureData(Component, options = {}) { + const { parseData, data, shouldLog = false, props, provide, ...rest } = options; + try { + const parsedData = parseData(data); + return { + provide: { ...parsedData, ...provide }, + render(createElement) { + return createElement(Component, { + props: { ...parsedData, ...props }, + ...rest, + }); + }, + }; + } catch (error) { + if (shouldLog) { + Sentry.captureException(error); + } + + return { + functional: true, + render(createElement) { + return createElement(GlEmptyState, { + props: { + title: ERROR_FETCHING_DATA_HEADER, + description: ERROR_FETCHING_DATA_DESCRIPTION, + svgPath: `data:image/svg+xml;utf8,${encodeURIComponent(emptySvg)}`, + }, + }); + }, + }; + } +} diff --git a/app/assets/javascripts/environments/components/enable_review_app_modal.vue b/app/assets/javascripts/environments/components/enable_review_app_modal.vue index 2494968857c..b0c0f83b88a 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_modal.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_modal.vue @@ -10,6 +10,7 @@ export default { GlSprintf, ModalCopyButton, }, + inject: ['defaultBranchName'], props: { modalId: { type: String, @@ -28,7 +29,11 @@ export default { modalInfo: { closeText: s__('EnableReviewApp|Close'), copyToClipboardText: s__('EnableReviewApp|Copy snippet text'), - copyString: `deploy_review: + title: s__('ReviewApp|Enable Review App'), + }, + computed: { + modalInfoCopyStr() { + return `deploy_review: stage: deploy script: - echo "Deploy a review app" @@ -38,8 +43,8 @@ export default { only: - branches except: - - master`, - title: s__('ReviewApp|Enable Review App'), + - ${this.defaultBranchName}`; + }, }, }; </script> @@ -75,7 +80,9 @@ export default { </gl-sprintf> </p> <div class="gl-display-flex align-items-start"> - <pre class="gl-w-full"> {{ $options.modalInfo.copyString }} </pre> + <pre class="gl-w-full" data-testid="enable-review-app-copy-string"> + {{ modalInfoCopyStr }} </pre + > <modal-copy-button :title="$options.modalInfo.copyToClipboardText" :text="$options.modalInfo.copyString" @@ -90,7 +97,9 @@ export default { <strong>{{ content }}</strong> </template> <template #link="{ content }"> - <gl-link href="blob/master/.gitlab-ci.yml" target="_blank">{{ content }}</gl-link> + <gl-link :href="`blob/${defaultBranchName}/.gitlab-ci.yml`" target="_blank">{{ + content + }}</gl-link> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue index 397616c654f..c0b4e96cea2 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.vue +++ b/app/assets/javascripts/environments/components/environment_rollback.vue @@ -71,6 +71,7 @@ export default { class="gl-display-none gl-md-display-block text-secondary" :loading="isLoading" :title="title" + :aria-label="title" :icon="isLastDeployment ? 'repeat' : 'redo'" @click="onClick" /> diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js index 68348648e61..b99872f7a6c 100644 --- a/app/assets/javascripts/environments/index.js +++ b/app/assets/javascripts/environments/index.js @@ -22,6 +22,7 @@ export default () => { apolloProvider, provide: { projectPath: el.dataset.projectPath, + defaultBranchName: el.dataset.defaultBranchName, }, data() { const environmentsData = el.dataset; diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 03b8df50c54..f05f0cb7c6d 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -322,7 +322,7 @@ export default { <gl-button v-if="!error.gitlabIssuePath" category="primary" - variant="success" + variant="confirm" :loading="issueCreationInProgress" data-qa-selector="create_issue_button" @click="createIssue" diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue index db61957d452..9438900c736 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue @@ -51,6 +51,7 @@ export default { v-gl-tooltip.hover class="gl-display-block gl-mb-4 mb-md-0 gl-w-full" :title="ignoreBtn.title" + :aria-label="ignoreBtn.title" @click="$emit('update-issue-status', { errorId: error.id, status: ignoreBtn.status })" > <gl-icon class="gl-display-none d-md-inline gl-m-0" :name="ignoreBtn.icon" :size="12" /> @@ -62,6 +63,7 @@ export default { v-gl-tooltip.hover class="gl-display-block gl-mb-4 mb-md-0 gl-w-full" :title="resolveBtn.title" + :aria-label="resolveBtn.title" @click="$emit('update-issue-status', { errorId: error.id, status: resolveBtn.status })" > <gl-icon class="gl-display-none d-md-inline gl-m-0" :name="resolveBtn.icon" :size="12" /> diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index dbcda0877b4..4df324b396c 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -33,7 +33,7 @@ export default { <p class="form-text text-muted"> {{ s__( - "ErrorTracking|If you self-host Sentry, enter the full URL of your Sentry instance. If you're using Sentry's hosted solution, enter https://sentry.io", + "ErrorTracking|If you self-host Sentry, enter your Sentry instance's full URL. If you use Sentry's hosted solution, enter https://sentry.io", ) }} </p> @@ -75,12 +75,12 @@ export default { </div> </div> <p v-if="connectError" class="gl-field-error"> - {{ s__('ErrorTracking|Connection has failed. Re-check Auth Token and try again.') }} + {{ s__('ErrorTracking|Connection failed. Check Auth Token and try again.') }} </p> <p v-else class="form-text text-muted"> {{ s__( - "ErrorTracking|After adding your Auth Token, use the 'Connect' button to load projects", + 'ErrorTracking|After adding your Auth Token, select the Connect button to load projects.', ) }} </p> diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js index 30828778574..f203a259b16 100644 --- a/app/assets/javascripts/error_tracking_settings/store/getters.js +++ b/app/assets/javascripts/error_tracking_settings/store/getters.js @@ -34,8 +34,8 @@ export const invalidProjectLabel = (state) => { export const projectSelectionLabel = (state) => { if (state.token) { return s__( - "ErrorTracking|Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.", + 'ErrorTracking|Click Connect to reestablish the connection to Sentry and activate the dropdown.', ); } - return s__('ErrorTracking|To enable project selection, enter a valid Auth Token'); + return s__('ErrorTracking|To enable project selection, enter a valid Auth Token.'); }; diff --git a/app/assets/javascripts/experimentation/components/experiment.vue b/app/assets/javascripts/experimentation/components/experiment.vue new file mode 100644 index 00000000000..294dbf77991 --- /dev/null +++ b/app/assets/javascripts/experimentation/components/experiment.vue @@ -0,0 +1,15 @@ +<script> +import { getExperimentVariant } from '../utils'; + +export default { + props: { + name: { + type: String, + required: true, + }, + }, + render() { + return this.$slots?.[getExperimentVariant(this.name)]; + }, +}; +</script> diff --git a/app/assets/javascripts/experimentation/constants.js b/app/assets/javascripts/experimentation/constants.js index b7e61d43b11..76e8fdb684b 100644 --- a/app/assets/javascripts/experimentation/constants.js +++ b/app/assets/javascripts/experimentation/constants.js @@ -1 +1,3 @@ export const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0'; +export const DEFAULT_VARIANT = 'control'; +export const CANDIDATE_VARIANT = 'candidate'; diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js index d3e7800f643..572907f226d 100644 --- a/app/assets/javascripts/experimentation/utils.js +++ b/app/assets/javascripts/experimentation/utils.js @@ -1,5 +1,6 @@ // This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment import { get } from 'lodash'; +import { DEFAULT_VARIANT, CANDIDATE_VARIANT } from './constants'; export function getExperimentData(experimentName) { return get(window, ['gon', 'experiment', experimentName]); @@ -8,3 +9,20 @@ export function getExperimentData(experimentName) { export function isExperimentVariant(experimentName, variantName) { return getExperimentData(experimentName)?.variant === variantName; } + +export function getExperimentVariant(experimentName) { + return getExperimentData(experimentName)?.variant || DEFAULT_VARIANT; +} + +export function experiment(experimentName, variants) { + const variant = getExperimentVariant(experimentName); + + switch (variant) { + case DEFAULT_VARIANT: + return variants.use.call(); + case CANDIDATE_VARIANT: + return variants.try.call(); + default: + return variants[variant].call(); + } +} 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 222815407ea..9220077af71 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue @@ -7,6 +7,8 @@ import { labelForStrategy } from '../utils'; export default { i18n: { + deleteLabel: __('Delete'), + editLabel: __('Edit'), toggleLabel: __('Feature flag status'), }, components: { @@ -215,19 +217,21 @@ export default { <div class="table-action-buttons btn-group"> <template v-if="featureFlag.edit_path"> <gl-button - v-gl-tooltip.hover.bottom="__('Edit')" + v-gl-tooltip.hover.bottom="$options.i18n.editLabel" class="js-feature-flag-edit-button" icon="pencil" + :aria-label="$options.i18n.editLabel" :href="featureFlag.edit_path" /> </template> <template v-if="featureFlag.destroy_path"> <gl-button - v-gl-tooltip.hover.bottom="__('Delete')" + v-gl-tooltip.hover.bottom="$options.i18n.deleteLabel" class="js-feature-flag-delete-button" variant="danger" icon="remove" :disabled="!canDeleteFlag(featureFlag)" + :aria-label="$options.i18n.deleteLabel" @click="setDeleteModalData(featureFlag)" /> </template> diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index f6a14d9996f..67ddceaf080 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -29,6 +29,10 @@ import EnvironmentsDropdown from './environments_dropdown.vue'; import Strategy from './strategy.vue'; export default { + i18n: { + removeLabel: s__('FeatureFlags|Remove'), + statusLabel: s__('FeatureFlags|Status'), + }, components: { GlButton, GlBadge, @@ -314,7 +318,7 @@ export default { <h4>{{ s__('FeatureFlags|Strategies') }}</h4> <div class="flex align-items-baseline justify-content-between"> <p class="mr-3">{{ $options.translations.newHelpText }}</p> - <gl-button variant="success" category="secondary" @click="addStrategy"> + <gl-button variant="confirm" category="secondary" @click="addStrategy"> {{ s__('FeatureFlags|Add strategy') }} </gl-button> </div> @@ -396,12 +400,14 @@ export default { <div class="table-section section-20 text-center" role="gridcell"> <div class="table-mobile-header" role="rowheader"> - {{ s__('FeatureFlags|Status') }} + {{ $options.i18n.statusLabel }} </div> <div class="table-mobile-content gl-display-flex gl-justify-content-center"> <gl-toggle :value="scope.active" :disabled="!active || !canUpdateScope(scope)" + :label="$options.i18n.statusLabel" + label-position="hidden" @change="(status) => (scope.active = status)" /> </div> @@ -502,7 +508,8 @@ export default { <gl-button v-if="!isAllEnvironment(scope.environmentScope) && canUpdateScope(scope)" v-gl-tooltip - :title="s__('FeatureFlags|Remove')" + :title="$options.i18n.removeLabel" + :aria-label="$options.i18n.removeLabel" class="js-delete-scope btn-transparent pr-3 pl-3" icon="clear" data-testid="feature-flag-delete" @@ -529,11 +536,13 @@ export default { <div class="table-section section-20 text-center" role="gridcell"> <div class="table-mobile-header" role="rowheader"> - {{ s__('FeatureFlags|Status') }} + {{ $options.i18n.statusLabel }} </div> <div class="table-mobile-content gl-display-flex gl-justify-content-center"> <gl-toggle :disabled="!active" + :label="$options.i18n.statusLabel" + label-position="hidden" :value="false" @change="createNewScope({ active: true })" /> @@ -575,7 +584,7 @@ export default { ref="submitButton" :disabled="readOnly" type="button" - variant="success" + variant="confirm" class="js-ff-submit col-xs-12" @click="handleSubmit" >{{ submitText }}</gl-button diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue index 170f120b036..3f515dcdf18 100644 --- a/app/assets/javascripts/feature_flags/components/strategy.vue +++ b/app/assets/javascripts/feature_flags/components/strategy.vue @@ -165,6 +165,7 @@ export default { data-testid="delete-strategy-button" variant="danger" icon="remove" + :aria-label="__('Delete')" @click="$emit('delete')" /> </div> diff --git a/app/assets/javascripts/feature_flags/components/user_lists_table.vue b/app/assets/javascripts/feature_flags/components/user_lists_table.vue index 0bfd18f992c..765f59228a6 100644 --- a/app/assets/javascripts/feature_flags/components/user_lists_table.vue +++ b/app/assets/javascripts/feature_flags/components/user_lists_table.vue @@ -7,7 +7,7 @@ import { GlTooltipDirective, GlModalDirective, } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { @@ -24,11 +24,12 @@ export default { createdTimeagoLabel: s__('UserList|created %{timeago}'), deleteListTitle: s__('UserList|Delete %{name}?'), deleteListMessage: s__('User list %{name} will be removed. Are you sure?'), + editUserListLabel: s__('FeatureFlags|Edit User List'), }, modal: { id: 'deleteListModal', actionPrimary: { - text: s__('Delete user list'), + text: __('Delete user list'), attributes: { variant: 'danger', 'data-testid': 'modal-confirm' }, }, }, @@ -93,6 +94,7 @@ export default { :href="list.path" category="secondary" icon="pencil" + :aria-label="$options.translations.editUserListLabel" data-testid="edit-user-list" /> <gl-button @@ -100,6 +102,7 @@ export default { category="secondary" variant="danger" icon="remove" + :aria-label="$options.modal.actionPrimary.text" data-testid="delete-user-list" @click="confirmDeleteList(list)" /> diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue index 2fd92a1bb11..79d7eb94569 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue +++ b/app/assets/javascripts/feature_highlight/feature_highlight_popover.vue @@ -71,7 +71,6 @@ export default { ref="popover" :target="$options.targetId" :css-classes="['feature-highlight-popover']" - triggers="hover" container="body" placement="right" boundary="viewport" diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index a22430833a3..91af3a6b812 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -1,7 +1,7 @@ import { __ } from '~/locale'; import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; -import { deprecatedCreateFlash as Flash } from '../flash'; +import createFlash from '../flash'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; @@ -14,9 +14,9 @@ export default class DropdownEmoji extends FilteredSearchDropdown { method: 'setData', loadingTemplate: this.loadingTemplate, onError() { - /* eslint-disable no-new */ - new Flash(__('An error occurred fetching the dropdown data.')); - /* eslint-enable no-new */ + createFlash({ + message: __('An error occurred fetching the dropdown data.'), + }); }, }, Filter: { diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index 4df1120f169..93051b00756 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,7 +1,7 @@ import { __ } from '~/locale'; import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; -import { deprecatedCreateFlash as Flash } from '../flash'; +import createFlash from '../flash'; import DropdownUtils from './dropdown_utils'; import FilteredSearchDropdown from './filtered_search_dropdown'; @@ -17,9 +17,9 @@ export default class DropdownNonUser extends FilteredSearchDropdown { loadingTemplate: this.loadingTemplate, preprocessing, onError() { - /* eslint-disable no-new */ - new Flash(__('An error occurred fetching the dropdown data.')); - /* eslint-enable no-new */ + createFlash({ + message: __('An error occurred fetching the dropdown data.'), + }); }, }, Filter: { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 69d19074cd0..d0996c9200b 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -10,7 +10,7 @@ import { DOWN_KEY_CODE, } from '~/lib/utils/keycodes'; import { __ } from '~/locale'; -import { deprecatedCreateFlash as Flash } from '../flash'; +import createFlash from '../flash'; import { addClassIfElementExists } from '../lib/utils/dom_utils'; import { visitUrl } from '../lib/utils/url_utility'; import FilteredSearchContainer from './container'; @@ -92,8 +92,9 @@ export default class FilteredSearchManager { .fetch() .catch((error) => { if (error.name === 'RecentSearchesServiceError') return undefined; - // eslint-disable-next-line no-new - new Flash(__('An error occurred while parsing recent searches')); + createFlash({ + message: __('An error occurred while parsing recent searches'), + }); // Gracefully fail to empty array return []; }) diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index d26a6bc5f6b..2bec39ff4d8 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -66,55 +66,6 @@ const removeFlashClickListener = (flashEl, fadeTransition) => { * along with ability to provide actionConfig which can be used to show * additional action or link on banner next to message * - * @param {String} message Flash message text - * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default) - * @param {Object} parent Reference to parent element under which Flash needs to appear - * @param {Object} actionConfig Map of config to show action on banner - * @param {String} href URL to which action config should point to (default: '#') - * @param {String} title Title of action - * @param {Function} clickHandler Method to call when action is clicked on - * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out - */ -const deprecatedCreateFlash = function deprecatedCreateFlash( - message, - type = FLASH_TYPES.ALERT, - parent = document, - actionConfig = null, - fadeTransition = true, - addBodyClass = false, -) { - const flashContainer = parent.querySelector('.flash-container'); - - if (!flashContainer) return null; - - flashContainer.innerHTML = createFlashEl(message, type); - - const flashEl = flashContainer.querySelector(`.flash-${type}`); - - if (actionConfig) { - flashEl.innerHTML += createAction(actionConfig); - - if (actionConfig.clickHandler) { - flashEl - .querySelector('.flash-action') - .addEventListener('click', (e) => actionConfig.clickHandler(e)); - } - } - - removeFlashClickListener(flashEl, fadeTransition); - - flashContainer.style.display = 'block'; - - if (addBodyClass) document.body.classList.add('flash-shown'); - - return flashContainer; -}; - -/* - * Flash banner supports different types of Flash configurations - * along with ability to provide actionConfig which can be used to show - * additional action or link on banner next to message - * * @param {Object} options Options to control the flash message * @param {String} options.message Flash message text * @param {String} options.type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default) @@ -166,6 +117,31 @@ const createFlash = function createFlash({ return flashContainer; }; +/* + * Flash banner supports different types of Flash configurations + * along with ability to provide actionConfig which can be used to show + * additional action or link on banner next to message + * + * @param {String} message Flash message text + * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default) + * @param {Object} parent Reference to parent element under which Flash needs to appear + * @param {Object} actionConfig Map of config to show action on banner + * @param {String} href URL to which action config should point to (default: '#') + * @param {String} title Title of action + * @param {Function} clickHandler Method to call when action is clicked on + * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out + */ +const deprecatedCreateFlash = function deprecatedCreateFlash( + message, + type, + parent, + actionConfig, + fadeTransition, + addBodyClass, +) { + return createFlash({ message, type, parent, actionConfig, fadeTransition, addBodyClass }); +}; + export { createFlash as default, deprecatedCreateFlash, diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index c5ea4cc92fd..22f88b1caa7 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '~/lib/utils/jquery_at_who'; -import { escape, template } from 'lodash'; +import { escape, sortBy, template } from 'lodash'; import * as Emoji from '~/emoji'; import axios from '~/lib/utils/axios_utils'; import { s__, __, sprintf } from '~/locale'; @@ -325,25 +325,7 @@ class GfmAutoComplete { return items; } - const lowercaseQuery = query.toLowerCase(); - const members = items.slice(); - const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members; - - return members.sort((a, b) => { - if (nameOrUsernameStartsWith(a, lowercaseQuery)) { - return -1; - } - if (nameOrUsernameStartsWith(b, lowercaseQuery)) { - return 1; - } - if (nameOrUsernameIncludes(a, lowercaseQuery)) { - return -1; - } - if (nameOrUsernameIncludes(b, lowercaseQuery)) { - return 1; - } - return 0; - }); + return GfmAutoComplete.Members.sort(query, items); }, }, }); @@ -837,6 +819,15 @@ GfmAutoComplete.Members = { // `member.search` is a name:username string like `MargeSimpson msimpson` return member.search.toLowerCase().includes(query); }, + sort(query, members) { + const lowercaseQuery = query.toLowerCase(); + const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members; + + return sortBy(members, [ + (member) => (nameOrUsernameStartsWith(member, lowercaseQuery) ? -1 : 0), + (member) => (nameOrUsernameIncludes(member, lowercaseQuery) ? -1 : 0), + ]); + }, }; GfmAutoComplete.Labels = { templateFunction(color, title) { diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue index ab13450bb1e..e941318dce0 100644 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -61,7 +61,9 @@ export default { <template> <section id="grafana" class="settings no-animate js-grafana-integration"> <div class="settings-header"> - <h4 class="js-section-header"> + <h4 + class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" + > {{ s__('GrafanaIntegration|Grafana authentication') }} </h4> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js new file mode 100644 index 00000000000..7e897be9e9a --- /dev/null +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -0,0 +1,2 @@ +/* eslint-disable @gitlab/require-i18n-strings */ +export const IssueType = 'Issue'; diff --git a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql deleted file mode 100644 index b5b4ba4e772..00000000000 --- a/app/assets/javascripts/graphql_shared/fragments/epic.fragment.graphql +++ /dev/null @@ -1,11 +0,0 @@ -fragment EpicNode on Epic { - id - iid - title - state - reference - webPath - webUrl - createdAt - closedAt -} diff --git a/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql new file mode 100644 index 00000000000..0b451262b5a --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/user_availability.fragment.graphql @@ -0,0 +1,5 @@ +fragment UserAvailability on User { + status { + availability + } +} diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql index aaaaf3485ad..e18eea33041 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -1,11 +1,13 @@ #import "../fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" query usersSearch($search: String!, $fullPath: ID!) { workspace: project(fullPath: $fullPath) { - users: projectMembers(search: $search) { + users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { nodes { user { ...User + ...UserAvailability } } } diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index 39c8a88d485..c1fc75fbea6 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -16,9 +16,7 @@ export default class Group { if (groupName.value === '') { groupName.addEventListener('keyup', this.updateHandler); - if (!this.parentId.value) { - groupName.addEventListener('blur', this.updateGroupPathSlugHandler); - } + groupName.addEventListener('blur', this.updateGroupPathSlugHandler); } }); @@ -53,7 +51,7 @@ export default class Group { const slug = this.groupPaths[0]?.value || slugify(value); if (!slug) return; - fetchGroupPathAvailability(slug) + fetchGroupPathAvailability(slug, this.parentId?.value) .then(({ data }) => data) .then(({ exists, suggests }) => { if (exists && suggests.length) { diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 9d46fcec09b..f2c608a8912 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -180,16 +180,12 @@ export default { <div v-if="isGroupPendingRemoval"> <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge> </div> - <div - class="metadata align-items-md-center d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between" - > - <item-actions - v-if="isGroup" - :group="group" - :parent-group="parentGroup" - :action="action" + <div class="metadata d-flex flex-grow-1 flex-shrink-0 flex-wrap justify-content-md-between"> + <item-actions v-if="isGroup" :group="group" :parent-group="parentGroup" /> + <item-stats + :item="group" + class="group-stats gl-mt-2 d-none d-md-flex gl-align-items-center" /> - <item-stats :item="group" class="group-stats gl-mt-2 d-none d-md-flex" /> </div> </div> </div> diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 22c648a76a7..4fed7f555f6 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -46,6 +46,7 @@ function initStatusTriggers() { currentMessage, currentAvailability, canSetUserAvailability, + currentClearStatusAfter, } = setStatusModalWrapperEl.dataset; return { @@ -54,6 +55,7 @@ function initStatusTriggers() { currentMessage, currentAvailability, canSetUserAvailability, + currentClearStatusAfter, }; }, render(createElement) { @@ -63,6 +65,7 @@ function initStatusTriggers() { currentMessage, currentAvailability, canSetUserAvailability, + currentClearStatusAfter, } = this; return createElement(SetStatusModalWrapper, { @@ -72,6 +75,7 @@ function initStatusTriggers() { currentMessage, currentAvailability, canSetUserAvailability, + currentClearStatusAfter, }, }); }, diff --git a/app/assets/javascripts/ide/components/cannot_push_code_alert.vue b/app/assets/javascripts/ide/components/cannot_push_code_alert.vue new file mode 100644 index 00000000000..d3e51e6e140 --- /dev/null +++ b/app/assets/javascripts/ide/components/cannot_push_code_alert.vue @@ -0,0 +1,40 @@ +<script> +import { GlAlert, GlButton } from '@gitlab/ui'; + +export default { + components: { + GlAlert, + GlButton, + }, + props: { + message: { + type: String, + required: true, + }, + action: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + hasAction() { + return Boolean(this.action?.href); + }, + actionButtonMethod() { + return this.action?.isForm ? 'post' : null; + }, + }, +}; +</script> + +<template> + <gl-alert :dismissible="false"> + {{ message }} + <template v-if="hasAction" #actions> + <gl-button variant="confirm" :href="action.href" :data-method="actionButtonMethod"> + {{ action.text }} + </gl-button> + </template> + </gl-alert> +</template> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index ff2644704d9..0c9fd324f8c 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import { __ } from '~/locale'; import { @@ -14,6 +14,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { modalTypes } from '../constants'; import eventHub from '../eventhub'; import { measurePerformance } from '../utils'; +import CannotPushCodeAlert from './cannot_push_code_alert.vue'; import IdeSidebar from './ide_side_bar.vue'; import RepoEditor from './repo_editor.vue'; @@ -29,7 +30,6 @@ export default { components: { IdeSidebar, RepoEditor, - GlAlert, GlButton, GlLoadingIcon, ErrorMessage: () => import(/* webpackChunkName: 'ide_runtime' */ './error_message.vue'), @@ -41,6 +41,7 @@ export default { import(/* webpackChunkName: 'ide_runtime' */ '~/vue_shared/components/file_finder/index.vue'), RightPane: () => import(/* webpackChunkName: 'ide_runtime' */ './panes/right.vue'), NewModal: () => import(/* webpackChunkName: 'ide_runtime' */ './new_dropdown/modal.vue'), + CannotPushCodeAlert, }, mixins: [glFeatureFlagsMixin()], data() { @@ -120,9 +121,11 @@ export default { class="ide position-relative d-flex flex-column align-items-stretch" :class="{ [`theme-${themeName}`]: themeName }" > - <gl-alert v-if="!canPushCodeStatus.isAllowed" :dismissible="false">{{ - canPushCodeStatus.message - }}</gl-alert> + <cannot-push-code-alert + v-if="!canPushCodeStatus.isAllowed" + :message="canPushCodeStatus.message" + :action="canPushCodeStatus.action" + /> <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> <template v-if="loadDeferred"> diff --git a/app/assets/javascripts/ide/components/ide_status_mr.vue b/app/assets/javascripts/ide/components/ide_status_mr.vue index a3b26d23a17..d05ca4141c8 100644 --- a/app/assets/javascripts/ide/components/ide_status_mr.vue +++ b/app/assets/javascripts/ide/components/ide_status_mr.vue @@ -20,7 +20,7 @@ export default { </script> <template> - <div class="d-flex-center flex-nowrap text-nowrap js-ide-status-mr"> + <div class="d-flex-center gl-flex-nowrap text-nowrap js-ide-status-mr"> <gl-icon name="merge-request" /> <span class="ml-1 d-none d-sm-block">{{ s__('WebIDE|Merge request') }}</span> <gl-link class="ml-1" :href="url">{{ text }}</gl-link> diff --git a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue index f4859b9f312..6e1929a1948 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/scroll_button.vue @@ -55,6 +55,7 @@ export default { :disabled="disabled" class="btn-scroll btn-transparent btn-blank" type="button" + :aria-label="tooltipTitle" @click="clickedScroll" > <gl-icon :name="iconName" /> diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index f7cfe80df5c..829a9d64cb7 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -87,7 +87,7 @@ export default { @input="searchMergeRequests" @removeToken="setSearchType(null)" /> - <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes /> + <gl-icon :size="16" name="search" class="ml-3 input-icon" /> </label> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <gl-loading-icon @@ -105,7 +105,7 @@ export default { @click.stop="setSearchType(searchType)" > <span class="d-flex gl-mr-3 ide-search-list-current-icon"> - <gl-icon :size="18" name="search" use-deprecated-sizes /> + <gl-icon :size="16" name="search" /> </span> <span>{{ searchType.label }}</span> </button> diff --git a/app/assets/javascripts/ide/components/nav_dropdown_button.vue b/app/assets/javascripts/ide/components/nav_dropdown_button.vue index 0db43123562..3699073adb8 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown_button.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown_button.vue @@ -31,12 +31,12 @@ export default { <template> <dropdown-button> - <span class="row flex-nowrap"> + <span class="row gl-flex-nowrap"> <span class="col-auto flex-fill text-truncate"> <gl-icon :size="16" :aria-label="__('Current Branch')" name="branch" /> {{ branchLabel }} </span> <span v-if="showMergeRequests" class="col-5 pl-0 text-truncate"> - <gl-icon :size="16" :aria-label="__('Merge Request')" name="merge-request" /> + <gl-icon :size="16" :aria-label="__('Merge request')" name="merge-request" /> {{ mergeRequestLabel }} </span> </span> diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue index 98f0504298b..750c2c3e215 100644 --- a/app/assets/javascripts/ide/components/nav_form.vue +++ b/app/assets/javascripts/ide/components/nav_form.vue @@ -26,7 +26,7 @@ export default { <gl-tab :title="__('Branches')"> <branches-search-list /> </gl-tab> - <gl-tab :title="__('Merge Requests')"> + <gl-tab :title="__('Merge requests')"> <merge-request-search-list /> </gl-tab> </gl-tabs> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 056df3739ee..6304423a3c0 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -110,5 +110,5 @@ export const SIDE_RIGHT = 'right'; export const LIVE_PREVIEW_DEBOUNCE = 2000; // This is the maximum number of files to auto open when opening the Web IDE -// from a Merge Request +// from a merge request export const MAX_MR_FILES_AUTO_OPEN = 10; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index f4a0f324e4a..2ce5bf7e271 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -54,6 +54,7 @@ export function initIde(el, options = {}) { }); this.setLinks({ webIDEHelpPagePath: el.dataset.webIdeHelpPagePath, + forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null, }); this.setInitialData({ clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled), diff --git a/app/assets/javascripts/ide/lib/editor_options.js b/app/assets/javascripts/ide/lib/editor_options.js index 9f2a9a8cf4a..52da9942efe 100644 --- a/app/assets/javascripts/ide/lib/editor_options.js +++ b/app/assets/javascripts/ide/lib/editor_options.js @@ -7,6 +7,7 @@ export const defaultEditorOptions = { enabled: false, }, wordWrap: 'on', + glyphMargin: true, }; export const defaultDiffOptions = { @@ -21,6 +22,7 @@ export const defaultDiffEditorOptions = { readOnly: false, renderLineHighlight: 'none', hideCursorInOverviewRuler: true, + glyphMargin: true, }; export const defaultModelOptions = { diff --git a/app/assets/javascripts/ide/lib/languages/README.md b/app/assets/javascripts/ide/lib/languages/README.md index c4f3de00783..86c5045ec71 100644 --- a/app/assets/javascripts/ide/lib/languages/README.md +++ b/app/assets/javascripts/ide/lib/languages/README.md @@ -1,21 +1,21 @@ # Web IDE Languages -The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting. -The Web IDE currently supports all languages defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository. +The Web IDE uses the [Monaco editor](https://microsoft.github.io/monaco-editor/) which uses the [Monarch library](https://microsoft.github.io/monaco-editor/monarch.html) for syntax highlighting. +The Web IDE currently supports all languages defined in the [monaco-languages](https://github.com/microsoft/monaco-languages/tree/master/src) repository. ## Adding New Languages -While Monaco supports a wide variety of languages, there's always the chance that it's missing something. +While Monaco supports a wide variety of languages, there's always the chance that it's missing something. You'll find a list of [unsupported languages in this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), which is the right place to add more if needed. Should you be willing to help us and add support to GitLab for any missing languages, here are the steps to do so: 1. Create a new issue and add it to [this epic](https://gitlab.com/groups/gitlab-org/-/epics/1474), if it doesn't already exist. 2. Create a new file in this folder called `{languageName}.js`, where `{languageName}` is the name of the language you want to add support for. -3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language. +3. Follow the [Monarch documentation](https://microsoft.github.io/monaco-editor/monarch.html) to add a configuration for the new language. - Example: The [`vue.js`](./vue.js) file in the current directory adds support for Vue.js Syntax Highlighting. 4. Add tests for the new language implementation in `spec/frontend/ide/lib/languages/{langaugeName}.js`. - Example: See [`vue_spec.js`](spec/frontend/ide/lib/languages/vue_spec.js). -5. Create a [Merge Request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language. +5. Create a [merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) with your newly added language. Thank you! diff --git a/app/assets/javascripts/ide/messages.js b/app/assets/javascripts/ide/messages.js index 4298d4c627c..189226ef835 100644 --- a/app/assets/javascripts/ide/messages.js +++ b/app/assets/javascripts/ide/messages.js @@ -1,10 +1,14 @@ import { s__ } from '~/locale'; -export const MSG_CANNOT_PUSH_CODE = s__( +export const MSG_CANNOT_PUSH_CODE_SHOULD_FORK = s__( 'WebIDE|You need permission to edit files directly in this project. Fork this project to make your changes and submit a merge request.', ); -export const MSG_CANNOT_PUSH_CODE_SHORT = s__( +export const MSG_CANNOT_PUSH_CODE_GO_TO_FORK = s__( + 'WebIDE|You need permission to edit files directly in this project. Go to your fork to make changes and submit a merge request.', +); + +export const MSG_CANNOT_PUSH_CODE = s__( 'WebIDE|You need permission to edit files directly in this project.', ); @@ -15,3 +19,7 @@ export const MSG_CANNOT_PUSH_UNSIGNED = s__( export const MSG_CANNOT_PUSH_UNSIGNED_SHORT = s__( 'WebIDE|This project does not accept unsigned commits.', ); + +export const MSG_FORK = s__('WebIDE|Fork project'); + +export const MSG_GO_TO_FORK = s__('WebIDE|Go to fork'); diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 753f6b9cd47..74423cd7376 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -73,7 +73,7 @@ export const getMergeRequestData = ( actionText: __('Please try again'), actionPayload: { projectId, mergeRequestId, force }, }); - reject(new Error(`Merge Request not loaded ${projectId}`)); + reject(new Error(`Merge request not loaded ${projectId}`)); }); } else { resolve(state.projects[projectId].mergeRequests[mergeRequestId]); @@ -106,7 +106,7 @@ export const getMergeRequestChanges = ( actionText: __('Please try again'), actionPayload: { projectId, mergeRequestId, force }, }); - reject(new Error(`Merge Request Changes not loaded ${projectId}`)); + reject(new Error(`Merge request changes not loaded ${projectId}`)); }); } else { resolve(state.projects[projectId].mergeRequests[mergeRequestId].changes); @@ -140,7 +140,7 @@ export const getMergeRequestVersions = ( actionText: __('Please try again'), actionPayload: { projectId, mergeRequestId, force }, }); - reject(new Error(`Merge Request Versions not loaded ${projectId}`)); + reject(new Error(`Merge request versions not loaded ${projectId}`)); }); } else { resolve(state.projects[projectId].mergeRequests[mergeRequestId].versions); diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index a5bb32ec44a..e8b1a0ea494 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -11,12 +11,42 @@ import { } from '../constants'; import { MSG_CANNOT_PUSH_CODE, - MSG_CANNOT_PUSH_CODE_SHORT, + MSG_CANNOT_PUSH_CODE_SHOULD_FORK, + MSG_CANNOT_PUSH_CODE_GO_TO_FORK, MSG_CANNOT_PUSH_UNSIGNED, MSG_CANNOT_PUSH_UNSIGNED_SHORT, + MSG_FORK, + MSG_GO_TO_FORK, } from '../messages'; import { getChangesCountForFiles, filePathMatches } from './utils'; +const getCannotPushCodeViewModel = (state) => { + const { ide_path: idePath, fork_path: forkPath } = state.links.forkInfo || {}; + + if (idePath) { + return { + message: MSG_CANNOT_PUSH_CODE_GO_TO_FORK, + action: { + href: idePath, + text: MSG_GO_TO_FORK, + }, + }; + } else if (forkPath) { + return { + message: MSG_CANNOT_PUSH_CODE_SHOULD_FORK, + action: { + href: forkPath, + isForm: true, + text: MSG_FORK, + }, + }; + } + + return { + message: MSG_CANNOT_PUSH_CODE, + }; +}; + export const activeFile = (state) => state.openFiles.find((file) => file.active) || null; export const addedFiles = (state) => state.changedFiles.filter((f) => f.tempFile); @@ -178,7 +208,7 @@ export const canPushCodeStatus = (state, getters) => { PUSH_RULE_REJECT_UNSIGNED_COMMITS ]; - if (rejectUnsignedCommits) { + if (window.gon?.features?.rejectUnsignedCommitsByGitlab && rejectUnsignedCommits) { return { isAllowed: false, message: MSG_CANNOT_PUSH_UNSIGNED, @@ -188,8 +218,8 @@ export const canPushCodeStatus = (state, getters) => { if (!canPushCode) { return { isAllowed: false, - message: MSG_CANNOT_PUSH_CODE, - messageShort: MSG_CANNOT_PUSH_CODE_SHORT, + messageShort: MSG_CANNOT_PUSH_CODE, + ...getCannotPushCodeViewModel(state), }; } diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index 22ff29e8866..76ba8339703 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -10,7 +10,7 @@ export const SET_PROJECT = 'SET_PROJECT'; export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; -// Merge Request Mutation Types +// Merge request mutation types export const SET_MERGE_REQUEST = 'SET_MERGE_REQUEST'; export const SET_CURRENT_MERGE_REQUEST = 'SET_CURRENT_MERGE_REQUEST'; export const SET_MERGE_REQUEST_CHANGES = 'SET_MERGE_REQUEST_CHANGES'; diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index 8df51ef7f9b..cc6a057f587 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -1,13 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { GlIcon } from '@gitlab/ui'; import STATUS_MAP from '../constants'; export default { name: 'ImportStatus', components: { - CiIcon, - GlLoadingIcon, + GlIcon, }, props: { status: { @@ -20,28 +18,13 @@ export default { mappedStatus() { return STATUS_MAP[this.status]; }, - - ciIconStatus() { - const { icon } = this.mappedStatus; - - return { - icon: `status_${icon}`, - group: icon, - }; - }, }, }; </script> <template> - <div class="gl-display-flex gl-h-7 gl-align-items-center"> - <gl-loading-icon - v-if="mappedStatus.loadingIcon" - :inline="true" - :class="mappedStatus.textClass" - class="align-middle mr-2" - /> - <ci-icon v-else css-classes="align-middle mr-2" :status="ciIconStatus" /> - <span :class="mappedStatus.textClass">{{ mappedStatus.text }}</span> + <div> + <gl-icon :name="mappedStatus.icon" :class="mappedStatus.iconClass" :size="12" class="gl-mr-2" /> + <span>{{ mappedStatus.text }}</span> </div> </template> diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js index c2f398cb8a8..156e92e2d00 100644 --- a/app/assets/javascripts/import_entities/constants.js +++ b/app/assets/javascripts/import_entities/constants.js @@ -11,43 +11,43 @@ export const STATUSES = { STARTED: 'started', NONE: 'none', SCHEDULING: 'scheduling', + CANCELLED: 'cancelled', +}; + +const SCHEDULED_STATUS = { + icon: 'status-scheduled', + text: __('Pending'), + iconClass: 'gl-text-orange-400', }; const STATUS_MAP = { + [STATUSES.NONE]: { + icon: 'status-waiting', + text: __('Not started'), + iconClass: 'gl-text-gray-400', + }, + [STATUSES.SCHEDULING]: SCHEDULED_STATUS, + [STATUSES.SCHEDULED]: SCHEDULED_STATUS, + [STATUSES.CREATED]: SCHEDULED_STATUS, + [STATUSES.STARTED]: { + icon: 'status-running', + text: __('Importing...'), + iconClass: 'gl-text-blue-400', + }, [STATUSES.FINISHED]: { - icon: 'success', - text: __('Done'), - textClass: 'text-success', + icon: 'status-success', + text: __('Complete'), + iconClass: 'gl-text-green-400', }, [STATUSES.FAILED]: { - icon: 'failed', + icon: 'status-failed', text: __('Failed'), - textClass: 'text-danger', - }, - [STATUSES.CREATED]: { - icon: 'pending', - text: __('Scheduled'), - textClass: 'text-warning', - }, - [STATUSES.SCHEDULED]: { - icon: 'pending', - text: __('Scheduled'), - textClass: 'text-warning', - }, - [STATUSES.STARTED]: { - icon: 'running', - text: __('Running…'), - textClass: 'text-info', - }, - [STATUSES.NONE]: { - icon: 'created', - text: __('Not started'), - textClass: 'text-muted', + iconClass: 'gl-text-red-600', }, - [STATUSES.SCHEDULING]: { - loadingIcon: true, - text: __('Scheduling'), - textClass: 'text-warning', + [STATUSES.CANCELLED]: { + icon: 'status-stopped', + text: __('Cancelled'), + iconClass: 'gl-text-red-600', }, }; 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 ebb09947663..a803afeb901 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 @@ -111,7 +111,7 @@ export default { </gl-link> </td> <td - class="gl-display-flex gl-flex-sm-wrap gl-p-4 gl-pt-5 gl-vertical-align-top" + class="gl-display-flex gl-sm-flex-wrap gl-p-4 gl-pt-5 gl-vertical-align-top" data-testid="fullPath" data-qa-selector="project_path_content" > diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue index 9d5f37dc3b7..0746725153d 100644 --- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue +++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue @@ -26,7 +26,10 @@ export default { class="settings no-animate qa-incident-management-settings" > <div class="settings-header"> - <h4 ref="sectionHeader"> + <h4 + ref="sectionHeader" + class="settings-title js-settings-toggle js-settings-toggle-trigger-only" + > {{ $options.i18n.headerText }} </h4> <gl-button ref="toggleBtn" class="js-settings-toggle">{{ diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index a4baca20ac9..3655f94f06f 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -3,7 +3,6 @@ import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; import { capitalize, lowerCase, isEmpty } from 'lodash'; import { mapGetters } from 'vuex'; -import { __, sprintf } from '~/locale'; import eventHub from '../event_hub'; export default { @@ -77,14 +76,6 @@ export default { isNonEmptyPassword() { return this.isPassword && !isEmpty(this.value); }, - label() { - if (this.isNonEmptyPassword) { - return sprintf(__('Enter new %{field_title}'), { - field_title: this.humanizedTitle, - }); - } - return this.humanizedTitle; - }, humanizedTitle() { return this.title || capitalize(lowerCase(this.name)); }, @@ -136,7 +127,7 @@ export default { <template> <gl-form-group - :label="label" + :label="humanizedTitle" :label-for="fieldId" :invalid-feedback="__('This field is required.')" :state="valid" diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index d3d1fd8ddc3..aea4a8b1c0b 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,15 +1,8 @@ <script> -import { - GlFormGroup, - GlFormCheckbox, - GlFormInput, - GlSprintf, - GlLink, - GlButton, - GlCard, -} from '@gitlab/ui'; +import { GlFormGroup, GlFormCheckbox, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../event_hub'; +import JiraUpgradeCta from './jira_upgrade_cta.vue'; export default { name: 'JiraIssuesFields', @@ -19,8 +12,7 @@ export default { GlFormInput, GlSprintf, GlLink, - GlButton, - GlCard, + JiraUpgradeCta, JiraIssueCreationVulnerabilities: () => import('ee_component/integrations/edit/components/jira_issue_creation_vulnerabilities.vue'), }, @@ -84,11 +76,13 @@ export default { return !this.enableJiraIssues || Boolean(this.projectKey) || !this.validated; }, showJiraVulnerabilitiesOptions() { - return ( - this.enableJiraIssues && - this.showJiraVulnerabilitiesIntegration && - this.glFeatures.jiraForVulnerabilities - ); + return this.showJiraVulnerabilitiesIntegration && this.glFeatures.jiraForVulnerabilities; + }, + showUltimateUpgrade() { + return this.showJiraIssuesIntegration && !this.showJiraVulnerabilitiesIntegration; + }, + showPremiumUpgrade() { + return !this.showJiraIssuesIntegration; }, }, created() { @@ -129,33 +123,29 @@ export default { <template #help> {{ s__( - 'JiraService|Warning: All GitLab users that have access to this GitLab project will be able to view all issues from the Jira project specified below.', + 'JiraService|Warning: All GitLab users that have access to this GitLab project are able to view all issues from the Jira project specified below.', ) }} </template> </gl-form-checkbox> <jira-issue-creation-vulnerabilities - v-if="showJiraVulnerabilitiesOptions" + v-if="enableJiraIssues" :project-key="projectKey" :initial-is-enabled="initialEnableJiraVulnerabilities" :initial-issue-type-id="initialVulnerabilitiesIssuetype" + :show-full-feature="showJiraVulnerabilitiesOptions" data-testid="jira-for-vulnerabilities" @request-get-issue-types="getJiraIssueTypes" /> </template> - <gl-card v-else class="gl-mt-7"> - <strong>{{ __('This is a Premium feature') }}</strong> - <p>{{ __('Upgrade your plan to enable this feature of the Jira Integration.') }}</p> - <gl-button - v-if="upgradePlanPath" - category="primary" - variant="info" - :href="upgradePlanPath" - target="_blank" - > - {{ __('Upgrade your plan') }} - </gl-button> - </gl-card> + <jira-upgrade-cta + v-if="showUltimateUpgrade || showPremiumUpgrade" + class="gl-mt-2" + :class="{ 'gl-ml-6': showUltimateUpgrade }" + :upgrade-plan-path="upgradePlanPath" + :show-ultimate-message="showUltimateUpgrade" + :show-premium-message="showPremiumUpgrade" + /> </div> </gl-form-group> <template v-if="showJiraIssuesIntegration"> @@ -169,7 +159,7 @@ export default { id="service_project_key" v-model="projectKey" name="service[project_key]" - :placeholder="s__('JiraService|e.g. AB')" + :placeholder="s__('JiraService|For example, AB')" :required="enableJiraIssues" :state="validProjectKey" :disabled="!enableJiraIssues" diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index af4e9acf4ba..b0f19e5b585 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -1,7 +1,16 @@ <script> -import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui'; +import { + GlFormGroup, + GlFormCheckbox, + GlFormRadio, + GlFormInput, + GlLink, + GlSprintf, +} from '@gitlab/ui'; import { mapGetters } from 'vuex'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; +import eventHub from '../event_hub'; const commentDetailOptions = [ { @@ -18,12 +27,41 @@ const commentDetailOptions = [ }, ]; +const ISSUE_TRANSITION_AUTO = true; +const ISSUE_TRANSITION_CUSTOM = false; + +const issueTransitionOptions = [ + { + value: ISSUE_TRANSITION_AUTO, + label: s__('JiraService|Move to Done'), + help: s__( + 'JiraService|Automatically transitions Jira issues to the "Done" category. %{linkStart}Learn more%{linkEnd}', + ), + link: helpPagePath('user/project/integrations/jira.html', { + anchor: 'automatic-issue-transitions', + }), + }, + { + value: ISSUE_TRANSITION_CUSTOM, + label: s__('JiraService|Use custom transitions'), + help: s__( + 'JiraService|Set a custom final state by using transition IDs. %{linkStart}Learn about transition IDs%{linkEnd}', + ), + link: helpPagePath('user/project/integrations/jira.html', { + anchor: 'custom-issue-transitions', + }), + }, +]; + export default { name: 'JiraTriggerFields', components: { GlFormGroup, GlFormCheckbox, GlFormRadio, + GlFormInput, + GlLink, + GlSprintf, }, props: { initialTriggerCommit: { @@ -43,21 +81,58 @@ export default { required: false, default: 'standard', }, + initialJiraIssueTransitionAutomatic: { + type: Boolean, + required: false, + default: false, + }, + initialJiraIssueTransitionId: { + type: String, + required: false, + default: '', + }, }, data() { return { + validated: false, triggerCommit: this.initialTriggerCommit, triggerMergeRequest: this.initialTriggerMergeRequest, enableComments: this.initialEnableComments, commentDetail: this.initialCommentDetail, + jiraIssueTransitionAutomatic: + this.initialJiraIssueTransitionAutomatic || !this.initialJiraIssueTransitionId, + jiraIssueTransitionId: this.initialJiraIssueTransitionId, + issueTransitionEnabled: + this.initialJiraIssueTransitionAutomatic || Boolean(this.initialJiraIssueTransitionId), commentDetailOptions, + issueTransitionOptions, }; }, computed: { ...mapGetters(['isInheriting']), - showEnableComments() { + showTriggerSettings() { return this.triggerCommit || this.triggerMergeRequest; }, + validIssueTransitionId() { + return !this.validated || Boolean(this.jiraIssueTransitionId); + }, + }, + created() { + eventHub.$on('validateForm', this.validateForm); + }, + beforeDestroy() { + eventHub.$off('validateForm', this.validateForm); + }, + methods: { + validateForm() { + this.validated = true; + }, + showCustomIssueTransitions(currentOption) { + return ( + this.jiraIssueTransitionAutomatic === ISSUE_TRANSITION_CUSTOM && + currentOption === ISSUE_TRANSITION_CUSTOM + ); + }, }, }; </script> @@ -69,7 +144,7 @@ export default { label-for="service[trigger]" :description=" s__( - 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created.', + 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) is created.', ) " > @@ -89,7 +164,7 @@ export default { </gl-form-group> <gl-form-group - v-show="showEnableComments" + v-show="showTriggerSettings" :label="s__('Integrations|Comment settings:')" label-for="service[comment_on_event_enabled]" class="gl-pl-6" @@ -106,7 +181,7 @@ export default { </gl-form-group> <gl-form-group - v-show="showEnableComments && enableComments" + v-show="showTriggerSettings && enableComments" :label="s__('Integrations|Comment detail:')" label-for="service[comment_detail]" class="gl-pl-9" @@ -126,5 +201,67 @@ export default { </template> </gl-form-radio> </gl-form-group> + + <gl-form-group + v-if="showTriggerSettings" + :label="s__('JiraService|Transition Jira issues to their final state:')" + class="gl-pl-6" + data-testid="issue-transition-enabled" + > + <input type="hidden" name="service[jira_issue_transition_automatic]" value="false" /> + <input type="hidden" name="service[jira_issue_transition_id]" value="" /> + + <gl-form-checkbox + v-model="issueTransitionEnabled" + :disabled="isInheriting" + data-qa-selector="service_jira_issue_transition_enabled_checkbox" + > + {{ s__('JiraService|Enable Jira transitions') }} + </gl-form-checkbox> + </gl-form-group> + + <gl-form-group + v-if="showTriggerSettings && issueTransitionEnabled" + class="gl-pl-9" + data-testid="issue-transition-mode" + > + <gl-form-radio + v-for="issueTransitionOption in issueTransitionOptions" + :key="issueTransitionOption.value" + v-model="jiraIssueTransitionAutomatic" + name="service[jira_issue_transition_automatic]" + :value="issueTransitionOption.value" + :disabled="isInheriting" + :data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}_radio`" + > + {{ issueTransitionOption.label }} + + <template v-if="showCustomIssueTransitions(issueTransitionOption.value)"> + <gl-form-input + v-model="jiraIssueTransitionId" + name="service[jira_issue_transition_id]" + type="text" + class="gl-my-3" + data-qa-selector="service_jira_issue_transition_id_field" + :placeholder="s__('JiraService|For example, 12, 24')" + :disabled="isInheriting" + :required="true" + :state="validIssueTransitionId" + /> + + <span class="invalid-feedback"> + {{ __('This field is required.') }} + </span> + </template> + + <template #help> + <gl-sprintf :message="issueTransitionOption.help"> + <template #link="{ content }"> + <gl-link :href="issueTransitionOption.link" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-form-radio> + </gl-form-group> </div> </template> diff --git a/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue b/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue new file mode 100644 index 00000000000..9164e484440 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/jira_upgrade_cta.vue @@ -0,0 +1,51 @@ +<script> +import { GlButton, GlCard } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; + +export default { + components: { + GlButton, + GlCard, + }, + props: { + upgradePlanPath: { + type: String, + required: false, + default: '', + }, + showPremiumMessage: { + type: Boolean, + required: false, + default: false, + }, + showUltimateMessage: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + title() { + return this.showUltimateMessage + ? this.$options.i18n.titleUltimate + : this.$options.i18n.titlePremium; + }, + }, + i18n: { + titleUltimate: s__('JiraService|This is an Ultimate feature'), + titlePremium: s__('JiraService|This is a Premium feature'), + content: s__('JiraService|Upgrade your plan to enable this feature of the Jira Integration.'), + upgrade: __('Upgrade your plan'), + }, +}; +</script> + +<template> + <gl-card> + <strong>{{ title }}</strong> + <p>{{ $options.i18n.content }}</p> + <gl-button v-if="upgradePlanPath" category="primary" variant="info" :href="upgradePlanPath"> + {{ $options.i18n.upgrade }} + </gl-button> + </gl-card> +</template> diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue index 1bbecea05ad..42bc9e4c8a1 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue @@ -10,8 +10,8 @@ const typeWithPlaceholder = { }; const placeholderForType = { - [typeWithPlaceholder.SLACK]: __('Slack channels (e.g. general, development)'), - [typeWithPlaceholder.MATTERMOST]: __('Channel handle (e.g. town-square)'), + [typeWithPlaceholder.SLACK]: __('general, development'), + [typeWithPlaceholder.MATTERMOST]: __('my-channel'), }; export default { diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index ab9bdd9ca2e..792e7d8e85e 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -1,5 +1,6 @@ import Vue from 'vue'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; + import IntegrationForm from './components/integration_form.vue'; import { createStore } from './store'; @@ -28,6 +29,8 @@ function parseDatasetToProps(data) { testPath, resetPath, vulnerabilitiesIssuetype, + jiraIssueTransitionAutomatic, + jiraIssueTransitionId, ...booleanAttributes } = data; const { @@ -59,6 +62,8 @@ function parseDatasetToProps(data) { initialTriggerMergeRequest: mergeRequestEvents, initialEnableComments: enableComments, initialCommentDetail: commentDetail, + initialJiraIssueTransitionAutomatic: jiraIssueTransitionAutomatic, + initialJiraIssueTransitionId: jiraIssueTransitionId, }, jiraIssuesProps: { showJiraIssuesIntegration, @@ -73,7 +78,7 @@ function parseDatasetToProps(data) { }, learnMorePath, triggerEvents: JSON.parse(triggerEvents), - fields: JSON.parse(fields), + fields: convertObjectPropsToCamelCase(JSON.parse(fields), { deep: true }), inheritFromId: parseInt(inheritFromId, 10), integrationLevel, id: parseInt(id, 10), diff --git a/app/assets/javascripts/integrations/index/components/integrations_list.vue b/app/assets/javascripts/integrations/index/components/integrations_list.vue new file mode 100644 index 00000000000..7331437d484 --- /dev/null +++ b/app/assets/javascripts/integrations/index/components/integrations_list.vue @@ -0,0 +1,59 @@ +<script> +import { s__ } from '~/locale'; +import IntegrationsTable from './integrations_table.vue'; + +export default { + name: 'IntegrationsList', + components: { + IntegrationsTable, + }, + props: { + integrations: { + type: Array, + required: true, + }, + }, + computed: { + integrationsGrouped() { + return this.integrations.reduce( + (integrations, integration) => { + if (integration.active) { + integrations.active.push(integration); + } else { + integrations.inactive.push(integration); + } + + return integrations; + }, + { active: [], inactive: [] }, + ); + }, + }, + i18n: { + activeTableEmptyText: s__("Integrations|You haven't activated any integrations yet."), + inactiveTableEmptyText: s__("Integrations|You've activated every integration 🎉"), + activeIntegrationsHeading: s__('Integrations|Active integrations'), + inactiveIntegrationsHeading: s__('Integrations|Add an integration'), + }, +}; +</script> + +<template> + <div> + <h4>{{ $options.i18n.activeIntegrationsHeading }}</h4> + <integrations-table + class="gl-mb-7!" + :integrations="integrationsGrouped.active" + :empty-text="$options.i18n.activeTableEmptyText" + show-updated-at + data-testid="active-integrations-table" + /> + + <h4>{{ $options.i18n.inactiveIntegrationsHeading }}</h4> + <integrations-table + :integrations="integrationsGrouped.inactive" + :empty-text="$options.i18n.inactiveTableEmptyText" + data-testid="inactive-integrations-table" + /> + </div> +</template> diff --git a/app/assets/javascripts/integrations/index/components/integrations_table.vue b/app/assets/javascripts/integrations/index/components/integrations_table.vue new file mode 100644 index 00000000000..439c243f418 --- /dev/null +++ b/app/assets/javascripts/integrations/index/components/integrations_table.vue @@ -0,0 +1,95 @@ +<script> +import { GlIcon, GlLink, GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlIcon, + GlLink, + GlTable, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + integrations: { + type: Array, + required: true, + }, + showUpdatedAt: { + type: Boolean, + required: false, + default: false, + }, + emptyText: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + fields() { + return [ + { + key: 'active', + label: '', + thClass: 'gl-w-10', + }, + { + key: 'title', + label: __('Integration'), + thClass: 'gl-w-quarter', + }, + { + key: 'description', + label: __('Description'), + thClass: 'gl-display-none d-sm-table-cell', + tdClass: 'gl-display-none d-sm-table-cell', + }, + { + key: 'updated_at', + label: this.showUpdatedAt ? __('Last updated') : '', + thClass: 'gl-w-20p', + }, + ]; + }, + }, + methods: { + getStatusTooltipTitle(integration) { + return sprintf(s__('Integrations|%{integrationTitle}: active'), { + integrationTitle: integration.title, + }); + }, + }, +}; +</script> + +<template> + <gl-table :items="integrations" :fields="fields" :empty-text="emptyText" show-empty fixed> + <template #cell(active)="{ item }"> + <gl-icon + v-if="item.active" + v-gl-tooltip + name="check" + class="gl-text-green-500" + :title="getStatusTooltipTitle(item)" + /> + </template> + + <template #cell(title)="{ item }"> + <gl-link + :href="item.edit_path" + class="gl-font-weight-bold" + :data-qa-selector="`${item.name}_link`" + > + {{ item.title }} + </gl-link> + </template> + + <template #cell(updated_at)="{ item }"> + <time-ago-tooltip v-if="showUpdatedAt && item.updated_at" :time="item.updated_at" /> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/integrations/index/index.js b/app/assets/javascripts/integrations/index/index.js new file mode 100644 index 00000000000..09dca3a6d02 --- /dev/null +++ b/app/assets/javascripts/integrations/index/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import IntegrationList from './components/integrations_list.vue'; + +export default () => { + const el = document.querySelector('.js-integrations-list'); + + if (!el) { + return null; + } + + const { integrations } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(IntegrationList, { + props: { + integrations: JSON.parse(integrations), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/invite_member/components/invite_member_modal.vue b/app/assets/javascripts/invite_member/components/invite_member_modal.vue index 144c1a2c22a..ec77e49ae53 100644 --- a/app/assets/javascripts/invite_member/components/invite_member_modal.vue +++ b/app/assets/javascripts/invite_member/components/invite_member_modal.vue @@ -19,8 +19,10 @@ export default { GlLink, GlModal, }, - inject: { + props: { membersPath: { + type: String, + required: false, default: '', }, }, diff --git a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue index 56cf1ab2fc2..ee89e0bbf71 100644 --- a/app/assets/javascripts/invite_member/components/invite_member_trigger.vue +++ b/app/assets/javascripts/invite_member/components/invite_member_trigger.vue @@ -7,14 +7,20 @@ export default { components: { GlLink, }, - inject: { + props: { displayText: { + type: String, + required: false, default: '', }, event: { + type: String, + required: false, default: '', }, label: { + type: String, + required: false, default: '', }, }, diff --git a/app/assets/javascripts/invite_member/init_invite_member_modal.js b/app/assets/javascripts/invite_member/init_invite_member_modal.js index c292bda1931..a50d31c9e7a 100644 --- a/app/assets/javascripts/invite_member/init_invite_member_modal.js +++ b/app/assets/javascripts/invite_member/init_invite_member_modal.js @@ -1,13 +1,17 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +import { isInIssuePage, isInDesignPage } from '~/lib/utils/common_utils'; import InviteMemberModal from './components/invite_member_modal.vue'; Vue.use(GlToast); +const isAssigneesWidgetShown = + (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; + export default function initInviteMembersModal() { const el = document.querySelector('.js-invite-member-modal'); - if (!el) { + if (!el || isAssigneesWidgetShown) { return false; } @@ -15,7 +19,9 @@ export default function initInviteMembersModal() { return new Vue({ el, - provide: { membersPath }, - render: (createElement) => createElement(InviteMemberModal), + render: (createElement) => + createElement(InviteMemberModal, { + props: { membersPath }, + }), }); } diff --git a/app/assets/javascripts/invite_member/init_invite_member_trigger.js b/app/assets/javascripts/invite_member/init_invite_member_trigger.js index 5e763e4f47d..eb765ae83b0 100644 --- a/app/assets/javascripts/invite_member/init_invite_member_trigger.js +++ b/app/assets/javascripts/invite_member/init_invite_member_trigger.js @@ -10,7 +10,9 @@ export default function initInviteMembersTrigger() { return new Vue({ el, - provide: { ...el.dataset }, - render: (createElement) => createElement(InviteMemberTrigger), + render: (createElement) => + createElement(InviteMemberTrigger, { + props: { ...el.dataset }, + }), }); } 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 47f1405c980..d00a0f1633b 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -11,10 +11,12 @@ import { } from '@gitlab/ui'; import { partition, isString } from 'lodash'; import Api from '~/api'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; import GroupSelect from '~/invite_members/components/group_select.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import { s__, sprintf } from '~/locale'; +import { INVITE_MEMBERS_IN_COMMENT } from '../constants'; import eventHub from '../event_hub'; export default { @@ -122,8 +124,9 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal({ inviteeType }) { + openModal({ inviteeType, source }) { this.inviteeType = inviteeType; + this.source = source; this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, @@ -138,6 +141,12 @@ export default { } this.closeModal(); }, + trackInvite() { + if (this.source === INVITE_MEMBERS_IN_COMMENT) { + const tracking = new ExperimentTracking(INVITE_MEMBERS_IN_COMMENT); + tracking.event('comment_invite_success'); + } + }, cancelInvite() { this.selectedAccessLevel = this.defaultAccessLevel; this.selectedDate = undefined; @@ -177,6 +186,8 @@ export default { promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); } + this.trackInvite(); + Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError); }, inviteByEmailPostData(usersToInviteByEmail) { @@ -211,9 +222,9 @@ export default { }, labels: { members: { - modalTitle: s__('InviteMembersModal|Invite team members'), - searchField: s__('InviteMembersModal|GitLab member or Email address'), - placeHolder: s__('InviteMembersModal|Search for members to invite'), + modalTitle: s__('InviteMembersModal|Invite members'), + searchField: s__('InviteMembersModal|GitLab member or email address'), + placeHolder: s__('InviteMembersModal|Select members or type email addresses'), toGroup: { introText: s__( "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.", diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index 666693e934f..e297bb6c806 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -1,10 +1,11 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; export default { - components: { GlButton }, + components: { GlButton, GlLink }, props: { displayText: { type: String, @@ -26,10 +27,65 @@ export default { required: false, default: undefined, }, + triggerSource: { + type: String, + required: false, + default: 'unknown', + }, + trackExperiment: { + type: String, + required: false, + default: undefined, + }, + triggerElement: { + type: String, + required: false, + default: 'button', + }, + event: { + type: String, + required: false, + default: '', + }, + label: { + type: String, + required: false, + default: '', + }, + }, + computed: { + isButton() { + return this.triggerElement === 'button'; + }, + componentAttributes() { + const baseAttributes = { + class: this.classes, + 'data-qa-selector': 'invite_members_button', + }; + + if (this.event && this.label) { + return { + ...baseAttributes, + 'data-track-event': this.event, + 'data-track-label': this.label, + }; + } + + return baseAttributes; + }, + }, + mounted() { + this.trackExperimentOnShow(); }, methods: { openModal() { - eventHub.$emit('openModal', { inviteeType: 'members' }); + eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource }); + }, + trackExperimentOnShow() { + if (this.trackExperiment) { + const tracking = new ExperimentTracking(this.trackExperiment); + tracking.event('comment_invite_shown'); + } }, }, }; @@ -37,12 +93,15 @@ export default { <template> <gl-button - :class="classes" - :icon="icon" + v-if="isButton" + v-bind="componentAttributes" :variant="variant" - data-qa-selector="invite_members_button" + :icon="icon" @click="openModal" > {{ displayText }} </gl-button> + <gl-link v-else v-bind="componentAttributes" data-is-link="true" @click="openModal"> + {{ displayText }} + </gl-link> </template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 2044dad896f..a651b81c60e 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1 +1,3 @@ export const SEARCH_DELAY = 200; + +export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment'; diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue index 78987a5c629..7bdd55ddda3 100644 --- a/app/assets/javascripts/issuable/components/csv_export_modal.vue +++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue @@ -12,19 +12,23 @@ export default { }, inject: { issuableType: { - default: '', - }, - issuableCount: { - default: 0, + default: ISSUABLE_TYPE.issues, }, email: { default: '', }, + }, + props: { exportCsvPath: { + type: String, + required: false, default: '', }, - }, - props: { + issuableCount: { + type: Number, + required: false, + default: 0, + }, modalId: { type: String, required: true, diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue index bbf4160ce35..fb4d5aca2f5 100644 --- a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue +++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue @@ -13,6 +13,10 @@ import CsvExportModal from './csv_export_modal.vue'; import CsvImportModal from './csv_import_modal.vue'; export default { + i18n: { + exportAsCsvButtonText: __('Export as CSV'), + importIssuesText: __('Import issues'), + }, name: 'CsvImportExportButtons', components: { GlButtonGroup, @@ -49,6 +53,18 @@ export default { default: false, }, }, + props: { + exportCsvPath: { + type: String, + required: false, + default: '', + }, + issuableCount: { + type: Number, + required: false, + default: undefined, + }, + }, computed: { exportModalId() { return `${this.issuableType}-export-modal`; @@ -57,16 +73,15 @@ export default { return `${this.issuableType}-import-modal`; }, importButtonText() { - return this.showLabel ? this.$options.importIssuesText : null; + return this.showLabel ? this.$options.i18n.importIssuesText : null; }, importButtonTooltipText() { - return this.showLabel ? null : this.$options.importIssuesText; + return this.showLabel ? null : this.$options.i18n.importIssuesText; }, importButtonIcon() { return this.showLabel ? null : 'import'; }, }, - importIssuesText: __('Import issues'), }; </script> @@ -75,9 +90,10 @@ export default { <gl-button-group> <gl-button v-if="showExportButton" - v-gl-tooltip.hover="__('Export as CSV')" + v-gl-tooltip.hover="$options.i18n.exportAsCsvButtonText" v-gl-modal="exportModalId" icon="export" + :aria-label="$options.i18n.exportAsCsvButtonText" data-qa-selector="export_as_csv_button" data-testid="export-csv-button" /> @@ -101,7 +117,12 @@ export default { > </gl-dropdown> </gl-button-group> - <csv-export-modal v-if="showExportButton" :modal-id="exportModalId" /> + <csv-export-modal + v-if="showExportButton" + :modal-id="exportModalId" + :export-csv-path="exportCsvPath" + :issuable-count="issuableCount" + /> <csv-import-modal v-if="showImportButton" :modal-id="importModalId" /> </div> </template> diff --git a/app/assets/javascripts/issuable/components/issuable_by_email.vue b/app/assets/javascripts/issuable/components/issuable_by_email.vue index 6d063b59922..d0ce8c2c34b 100644 --- a/app/assets/javascripts/issuable/components/issuable_by_email.vue +++ b/app/assets/javascripts/issuable/components/issuable_by_email.vue @@ -14,6 +14,9 @@ import { sprintf, __ } from '~/locale'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; export default { + i18n: { + sendEmail: __('Send email'), + }, name: 'IssuableByEmail', components: { GlButton, @@ -116,7 +119,8 @@ export default { <gl-button v-gl-tooltip.hover :href="mailToLink" - :title="__('Send email')" + :title="$options.i18n.sendEmail" + :aria-label="$options.i18n.sendEmail" icon="mail" data-testid="mail-to-btn" /> diff --git a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js index 5a720b89d33..83163e3c478 100644 --- a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js +++ b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -import ImportExportButtons from './components/csv_import_export_buttons.vue'; +import CsvImportExportButtons from './components/csv_import_export_buttons.vue'; export default () => { const el = document.querySelector('.js-csv-import-export-buttons'); @@ -28,9 +28,7 @@ export default () => { showExportButton: parseBoolean(showExportButton), showImportButton: parseBoolean(showImportButton), issuableType, - issuableCount, email, - exportCsvPath, importCsvIssuesPath, containerClass, canEdit: parseBoolean(canEdit), @@ -39,7 +37,12 @@ export default () => { showLabel, }, render(h) { - return h(ImportExportButtons); + return h(CsvImportExportButtons, { + props: { + exportCsvPath, + issuableCount: parseInt(issuableCount, 10), + }, + }); }, }); }; diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index f507f072253..366a9a8a883 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -87,7 +87,7 @@ export default { // From issuable's initial bulk selection getOriginalCommonIds() { const labelIds = []; - this.getElement('.selected-issuable:checked').each((i, el) => { + this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); }); return intersection.apply(this, labelIds); @@ -100,7 +100,7 @@ export default { let issuableLabels = []; // Collect unique label IDs for all checked issues - this.getElement('.selected-issuable:checked').each((i, el) => { + this.getElement('.issuable-list input[type="checkbox"]:checked').each((i, el) => { issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); issuableLabels.forEach((labelId) => { // Store unique IDs diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index ef98db5151a..97d50dde9f7 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -34,7 +34,7 @@ export default class IssuableBulkUpdateSidebar { this.$otherFilters = $('.issues-other-filters'); this.$checkAllContainer = $('.check-all-holder'); this.$issueChecks = $('.issue-check'); - this.$issuesList = $('.selected-issuable'); + this.$issuesList = $('.issuable-list input[type="checkbox"]'); this.$issuableIdsInput = $('#update_issuable_ids'); } @@ -46,16 +46,11 @@ export default class IssuableBulkUpdateSidebar { this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); this.$checkAllContainer.on('click', () => this.updateFormState()); - if (this.vueIssuablesListFeature) { - issueableEventHub.$on('issuables:updateBulkEdit', () => { - // Danger! Strong coupling ahead! - // The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue - // is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties - // explicitly, but this component is used in too many places right now to refactor straight away. - - this.updateFormState(); - }); - } + // The event hub connects this bulk update logic with `issues_list_app.vue`. + // We can remove it once we've refactored the issues list page bulk edit sidebar to Vue. + // https://gitlab.com/gitlab-org/gitlab/-/issues/325874 + issueableEventHub.$on('issuables:enableBulkEdit', () => this.toggleBulkEdit(null, true)); + issueableEventHub.$on('issuables:updateBulkEdit', () => this.updateFormState()); } initDropdowns() { @@ -96,7 +91,7 @@ export default class IssuableBulkUpdateSidebar { } updateFormState() { - const noCheckedIssues = !$('.selected-issuable:checked').length; + const noCheckedIssues = !$('.issuable-list input[type="checkbox"]:checked').length; this.toggleSubmitButtonDisabled(noCheckedIssues); this.updateSelectedIssuableIds(); @@ -112,7 +107,7 @@ export default class IssuableBulkUpdateSidebar { } toggleBulkEdit(e, enable) { - e.preventDefault(); + e?.preventDefault(); issueableEventHub.$emit('issuables:toggleBulkEdit', enable); @@ -166,7 +161,7 @@ export default class IssuableBulkUpdateSidebar { } static getCheckedIssueIds() { - const $checkedIssues = $('.selected-issuable:checked'); + const $checkedIssues = $('.issuable-list input[type="checkbox"]:checked'); if ($checkedIssues.length > 0) { return $.map($checkedIssues, (value) => $(value).data('id')); diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js index 4856f9781ce..cdeee68b762 100644 --- a/app/assets/javascripts/issuable_index.js +++ b/app/assets/javascripts/issuable_index.js @@ -1,7 +1,7 @@ import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar'; export default class IssuableIndex { - constructor(pagePrefix) { + constructor(pagePrefix = 'issuable_') { issuableInitBulkUpdateSidebar.init(pagePrefix); } } diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 92c527c79ff..5d497369f5a 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -65,6 +65,9 @@ export default { labels() { return this.issuable.labels?.nodes || this.issuable.labels || []; }, + labelIdsString() { + return JSON.stringify(this.labels.map((label) => label.id)); + }, assignees() { return this.issuable.assignees || []; }, @@ -149,12 +152,13 @@ export default { </script> <template> - <li class="issue gl-px-5!"> + <li :id="`issuable_${issuable.id}`" class="issue gl-px-5!" :data-labels="labelIdsString"> <div class="issuable-info-container"> <div v-if="showCheckbox" class="issue-check"> <gl-form-checkbox class="gl-mr-0" :checked="checked" + :data-id="issuable.id" @input="$emit('checked-input', $event)" /> </div> diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue index 40b0fcbb8c6..6b95c3a578e 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -10,7 +10,14 @@ import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue'; import IssuableItem from './issuable_item.vue'; import IssuableTabs from './issuable_tabs.vue'; +const VueDraggable = () => import('vuedraggable'); + export default { + vueDraggableAttributes: { + animation: 200, + ghostClass: 'gl-visibility-hidden', + tag: 'ul', + }, components: { GlSkeletonLoading, IssuableTabs, @@ -18,6 +25,7 @@ export default { IssuableItem, IssuableBulkEditSidebar, GlPagination, + VueDraggable, }, props: { namespace: { @@ -127,6 +135,11 @@ export default { required: false, default: null, }, + isManualOrdering: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -159,6 +172,9 @@ export default { return acc; }, []); }, + issuablesWrapper() { + return this.isManualOrdering ? VueDraggable : 'ul'; + }, }, watch: { issuables(list) { @@ -202,11 +218,16 @@ export default { }, handleIssuableCheckedInput(issuable, value) { this.checkedIssuables[this.issuableId(issuable)].checked = value; + this.$emit('update-legacy-bulk-edit'); }, handleAllIssuablesCheckedInput(value) { Object.keys(this.checkedIssuables).forEach((issuableId) => { this.checkedIssuables[issuableId].checked = value; }); + this.$emit('update-legacy-bulk-edit'); + }, + handleVueDraggableUpdate({ newIndex, oldIndex }) { + this.$emit('reorder', { newIndex, oldIndex }); }, }, }; @@ -253,13 +274,18 @@ export default { <gl-skeleton-loading /> </li> </ul> - <ul + <component + :is="issuablesWrapper" v-if="!issuablesLoading && issuables.length" class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + v-bind="$options.vueDraggableAttributes" + @update="handleVueDraggableUpdate" > <issuable-item v-for="issuable in issuables" :key="issuableId(issuable)" + :class="{ 'gl-cursor-grab': isManualOrdering }" :issuable-symbol="issuableSymbol" :issuable="issuable" :enable-label-permalinks="enableLabelPermalinks" @@ -284,7 +310,7 @@ export default { <slot name="statistics" :issuable="issuable"></slot> </template> </issuable-item> - </ul> + </component> <slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot> <gl-pagination v-if="showPaginationControls" diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue index 57da030e22e..6bc621b52e6 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue @@ -26,6 +26,9 @@ export default { isTabActive(tabName) { return tabName === this.currentTab; }, + isTabCountNumeric(tab) { + return Number.isInteger(this.tabCounts[tab.name]); + }, }, }; </script> @@ -44,9 +47,13 @@ export default { > <template #title> <span :title="tab.titleTooltip">{{ tab.title }}</span> - <gl-badge v-if="tabCounts" variant="neutral" size="sm" class="gl-tab-counter-badge">{{ - tabCounts[tab.name] - }}</gl-badge> + <gl-badge + v-if="isTabCountNumeric(tab)" + variant="neutral" + size="sm" + class="gl-tab-counter-badge" + >{{ tabCounts[tab.name] }}</gl-badge + > </template> </gl-tab> </gl-tabs> diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue index fe102e942c9..05dc1650379 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_body.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue @@ -42,6 +42,10 @@ export default { type: Boolean, required: true, }, + enableZenMode: { + type: Boolean, + required: true, + }, enableTaskList: { type: Boolean, required: false, @@ -144,6 +148,7 @@ export default { :issuable="issuable" :enable-autocomplete="enableAutocomplete" :enable-autosave="enableAutosave" + :enable-zen-mode="enableZenMode" :show-field-title="showFieldTitle" :description-preview-path="descriptionPreviewPath" :description-help-path="descriptionHelpPath" diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue index 6d139541524..33dca3e9332 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue @@ -4,6 +4,7 @@ import $ from 'jquery'; import Autosave from '~/autosave'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import ZenMode from '~/zen_mode'; import eventHub from '../event_hub'; @@ -27,6 +28,10 @@ export default { type: Boolean, required: true, }, + enableZenMode: { + type: Boolean, + required: true, + }, showFieldTitle: { type: Boolean, required: true, @@ -62,6 +67,9 @@ export default { }, mounted() { if (this.enableAutosave) this.initAutosave(); + + // eslint-disable-next-line no-new + if (this.enableZenMode) new ZenMode(); }, beforeDestroy() { eventHub.$off('update.issuable', this.resetAutosave); diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue index b514a6b01d8..ca057094868 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue @@ -42,6 +42,11 @@ export default { required: false, default: true, }, + enableZenMode: { + type: Boolean, + required: false, + default: true, + }, enableTaskList: { type: Boolean, required: false, @@ -120,6 +125,7 @@ export default { :enable-edit="enableEdit" :enable-autocomplete="enableAutocomplete" :enable-autosave="enableAutosave" + :enable-zen-mode="enableZenMode" :enable-task-list="enableTaskList" :edit-form-visible="editFormVisible" :show-field-title="showFieldTitle" diff --git a/app/assets/javascripts/issuable_show/components/issuable_title.vue b/app/assets/javascripts/issuable_show/components/issuable_title.vue index b7ea4a010a3..b96ce0c43f7 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_title.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_title.vue @@ -6,8 +6,12 @@ import { GlTooltipDirective, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { + i18n: { + editTitleAndDescription: __('Edit title and description'), + }, components: { GlIcon, GlButton, @@ -58,7 +62,8 @@ export default { <gl-button v-if="enableEdit" v-gl-tooltip.bottom - :title="__('Edit title and description')" + :title="$options.i18n.editTitleAndDescription" + :aria-label="$options.i18n.editTitleAndDescription" icon="pencil" class="btn-edit js-issuable-edit qa-edit-button" @click="$emit('edit-issuable', $event)" diff --git a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql deleted file mode 100644 index 42e646391a8..00000000000 --- a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql +++ /dev/null @@ -1,16 +0,0 @@ -#import "~/graphql_shared/fragments/author.fragment.graphql" - -query getProjectIssue($iid: String!, $fullPath: ID!) { - project(fullPath: $fullPath) { - issue(iid: $iid) { - id - assignees { - nodes { - ...Author - id - state - } - } - } - } -} diff --git a/app/assets/javascripts/issuable_type_selector/components/info_popover.vue b/app/assets/javascripts/issuable_type_selector/components/info_popover.vue new file mode 100644 index 00000000000..3a20ccba814 --- /dev/null +++ b/app/assets/javascripts/issuable_type_selector/components/info_popover.vue @@ -0,0 +1,41 @@ +<script> +import { GlIcon, GlPopover } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + i18n: { + issueTypes: __('Issue types'), + issue: __('Issue'), + incident: __('Incident'), + issueHelpText: __('For general work'), + incidentHelpText: __('For investigating IT service disruptions or outages'), + }, + components: { + GlIcon, + GlPopover, + }, +}; +</script> + +<template> + <span id="popovercontainer"> + <gl-icon id="issuable-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" /> + <gl-popover + target="issuable-type-info" + container="popovercontainer" + :title="$options.i18n.issueTypes" + triggers="focus hover" + > + <ul class="gl-list-style-none gl-p-0 gl-m-0"> + <li class="gl-mb-3"> + <div class="gl-font-weight-bold">{{ $options.i18n.issue }}</div> + <span>{{ $options.i18n.issueHelpText }}</span> + </li> + <li> + <div class="gl-font-weight-bold">{{ $options.i18n.incident }}</div> + <span>{{ $options.i18n.incidentHelpText }}</span> + </li> + </ul> + </gl-popover> + </span> +</template> diff --git a/app/assets/javascripts/issuable_type_selector/index.js b/app/assets/javascripts/issuable_type_selector/index.js new file mode 100644 index 00000000000..433a62d1ae8 --- /dev/null +++ b/app/assets/javascripts/issuable_type_selector/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import InfoPopover from './components/info_popover.vue'; + +export default function initIssuableTypeSelector() { + const el = document.getElementById('js-type-popover'); + + return new Vue({ + el, + components: { + InfoPopover, + }, + render(h) { + return h(InfoPopover); + }, + }); +} diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 9b978483cc6..d153ff21a35 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -238,7 +238,7 @@ export default { : ''; }, statusIcon() { - return this.isClosed ? 'mobile-issue-close' : 'issue-open-m'; + return this.isClosed ? 'issue-close' : 'issue-open-m'; }, statusText() { return IssuableStatusText[this.issuableStatus]; diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue index 20c759cfbbd..7733e366c4f 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -47,7 +47,7 @@ export default { }, deleteIssuableButtonText() { return sprintf(__('Delete %{issuableType}'), { - issuableType: issuableTypes[this.issuableType], + issuableType: issuableTypes[this.issuableType].toLowerCase(), }); }, }, @@ -79,23 +79,23 @@ export default { :loading="formState.updateLoading" :disabled="formState.updateLoading || !isSubmitEnabled" category="primary" - variant="success" - class="float-left qa-save-button" + variant="confirm" + class="float-left qa-save-button gl-mr-3" type="submit" @click.prevent="updateIssuable" > {{ __('Save changes') }} </gl-button> - <gl-button class="float-right" @click="closeForm"> + <gl-button @click="closeForm"> {{ __('Cancel') }} </gl-button> <gl-button v-if="shouldShowDeleteButton" :loading="deleteLoading" :disabled="deleteLoading" - category="primary" + category="secondary" variant="danger" - class="float-right gl-mr-3 qa-delete-button" + class="float-right qa-delete-button" @click="deleteIssuable" > {{ deleteIssuableButtonText }} diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue index 2f2c4c6e341..2bddbe4faa0 100644 --- a/app/assets/javascripts/issue_show/components/header_actions.vue +++ b/app/assets/javascripts/issue_show/components/header_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui'; +import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash, { FLASH_TYPES } from '~/flash'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; @@ -17,7 +17,6 @@ export default { GlButton, GlDropdown, GlDropdownItem, - GlIcon, GlLink, GlModal, }, @@ -26,7 +25,6 @@ export default { }, actionPrimary: { text: __('Yes, close issue'), - attributes: [{ variant: 'warning' }], }, i18n: { promoteErrorMessage: __( @@ -88,9 +86,6 @@ export default { qaSelector() { return this.isClosed ? 'reopen_issue_button' : 'close_issue_button'; }, - buttonVariant() { - return this.isClosed ? 'default' : 'warning'; - }, dropdownText() { return sprintf(__('%{issueType} actions'), { issueType: capitalizeFirstCharacter(this.issueType), @@ -192,9 +187,9 @@ export default { </script> <template> - <div class="detail-page-header-actions"> + <div class="detail-page-header-actions gl-display-flex"> <gl-dropdown - class="gl-display-block gl-sm-display-none!" + class="gl-sm-display-none! w-100" block :text="dropdownText" :loading="isToggleStateButtonLoading" @@ -224,26 +219,22 @@ export default { <gl-button v-if="showToggleIssueStateButton" class="gl-display-none gl-sm-display-inline-flex!" - category="secondary" :data-qa-selector="qaSelector" :loading="isToggleStateButtonLoading" - :variant="buttonVariant" @click="toggleIssueState" > {{ buttonText }} </gl-button> <gl-dropdown - class="gl-display-none gl-sm-display-inline-flex!" - toggle-class="gl-border-0! gl-shadow-none!" + class="gl-display-none gl-sm-display-inline-flex! gl-ml-3" + icon="ellipsis_v" + category="tertiary" + :text="dropdownText" + :text-sr-only="true" no-caret right > - <template #button-content> - <gl-icon name="ellipsis_v" /> - <span class="gl-sr-only">{{ dropdownText }}</span> - </template> - <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> {{ newIssueTypeText }} </gl-dropdown-item> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index 806d95ca748..5e92211685a 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -1,9 +1,13 @@ <script> import { GlButton, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { __ } from '~/locale'; import eventHub from '../event_hub'; import animateMixin from '../mixins/animate'; export default { + i18n: { + editTitleAndDescription: __('Edit title and description'), + }, components: { GlButton, }, @@ -78,7 +82,8 @@ export default { v-gl-tooltip.bottom icon="pencil" class="btn-edit js-issuable-edit qa-edit-button" - title="Edit title and description" + :title="$options.i18n.editTitleAndDescription" + :aria-label="$options.i18n.editTitleAndDescription" @click="edit" /> </div> diff --git a/app/assets/javascripts/issues_list/components/issuables_list_app.vue b/app/assets/javascripts/issues_list/components/issuables_list_app.vue index 0b413ce0b06..51cad662ebf 100644 --- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue @@ -30,6 +30,9 @@ import issueableEventHub from '../eventhub'; import { emptyStateHelper } from '../service_desk_helper'; import Issuable from './issuable.vue'; +/** + * @deprecated Use app/assets/javascripts/issuable_list/components/issuable_list_root.vue instead + */ export default { LOADING_LIST_ITEMS_LENGTH, directives: { 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 c57fa5a82fa..57c5107fcbb 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,63 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { toNumber } from 'lodash'; import createFlash from '~/flash'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableList from '~/issuable_list/components/issuable_list_root.vue'; -import { IssuableStatus } from '~/issue_show/constants'; -import { PAGE_SIZE } from '~/issues_list/constants'; +import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants'; +import { + CREATED_DESC, + PAGE_SIZE, + RELATIVE_POSITION_ASC, + sortOptions, + sortParams, +} from '~/issues_list/constants'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import eventHub from '../eventhub'; import IssueCardTimeInfo from './issue_card_time_info.vue'; export default { + CREATED_DESC, + IssuableListTabs, PAGE_SIZE, + sortOptions, + sortParams, + i18n: { + calendarLabel: __('Subscribe to calendar'), + jiraIntegrationMessage: s__( + 'JiraService|%{jiraDocsLinkStart}Enable the Jira integration%{jiraDocsLinkEnd} to view your Jira issues in GitLab.', + ), + jiraIntegrationSecondaryMessage: s__('JiraService|This feature requires a Premium plan.'), + jiraIntegrationTitle: s__('JiraService|Using Jira for issue tracking?'), + newIssueLabel: __('New issue'), + noClosedIssuesTitle: __('There are no closed issues'), + noOpenIssuesDescription: __('To keep this project going, create a new issue'), + noOpenIssuesTitle: __('There are no open issues'), + noIssuesSignedInDescription: __( + 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.', + ), + noIssuesSignedInTitle: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project', + ), + noIssuesSignedOutButtonText: __('Register / Sign In'), + noIssuesSignedOutDescription: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', + ), + noIssuesSignedOutTitle: __('There are no issues to show'), + noSearchResultsDescription: __('To widen your search, change or remove filters above'), + noSearchResultsTitle: __('Sorry, your filter produced no results'), + reorderError: __('An error occurred while reordering issues.'), + rssLabel: __('Subscribe to RSS feed'), + }, components: { + CsvImportExportButtons, + GlButton, + GlEmptyState, GlIcon, + GlLink, + GlSprintf, IssuableList, IssueCardTimeInfo, BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'), @@ -22,49 +66,147 @@ export default { GlTooltip: GlTooltipDirective, }, inject: { + calendarPath: { + default: '', + }, + canBulkUpdate: { + default: false, + }, + emptyStateSvgPath: { + default: '', + }, endpoint: { default: '', }, + exportCsvPath: { + default: '', + }, fullPath: { default: '', }, + hasIssues: { + default: false, + }, + isSignedIn: { + default: false, + }, + issuesPath: { + default: '', + }, + jiraIntegrationPath: { + default: '', + }, + newIssuePath: { + default: '', + }, + rssPath: { + default: '', + }, + showNewIssueLink: { + default: false, + }, + signInPath: { + default: '', + }, }, data() { + const orderBy = getParameterByName('order_by'); + const sort = getParameterByName('sort'); + const sortKey = Object.keys(sortParams).find( + (key) => sortParams[key].order_by === orderBy && sortParams[key].sort === sort, + ); + + const search = getParameterByName('search') || ''; + const tokens = search.split(' ').map((searchWord) => ({ + type: 'filtered-search-term', + value: { + data: searchWord, + }, + })); + return { - currentPage: toNumber(getParameterByName('page')) || 1, + exportCsvPathWithQuery: this.getExportCsvPathWithQuery(), + filters: sortParams[sortKey] || {}, + filterTokens: tokens, isLoading: false, issues: [], + page: toNumber(getParameterByName('page')) || 1, + showBulkEditSidebar: false, + sortKey: sortKey || CREATED_DESC, + state: getParameterByName('state') || IssuableStates.Opened, totalIssues: 0, }; }, computed: { + isManualOrdering() { + return this.sortKey === RELATIVE_POSITION_ASC; + }, + isOpenTab() { + return this.state === IssuableStates.Opened; + }, + searchQuery() { + return ( + this.filterTokens + .map((searchTerm) => searchTerm.value.data) + .filter((searchWord) => Boolean(searchWord)) + .join(' ') || undefined + ); + }, + showPaginationControls() { + return this.issues.length > 0; + }, + tabCounts() { + return Object.values(IssuableStates).reduce( + (acc, state) => ({ + ...acc, + [state]: this.state === state ? this.totalIssues : undefined, + }), + {}, + ); + }, urlParams() { return { - page: this.currentPage, - state: IssuableStatus.Open, + page: this.page, + search: this.searchQuery, + state: this.state, + ...this.filters, }; }, }, mounted() { + eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => { + this.showBulkEditSidebar = showBulkEditSidebar; + }); this.fetchIssues(); }, + beforeDestroy() { + // eslint-disable-next-line @gitlab/no-global-event-off + eventHub.$off('issuables:toggleBulkEdit'); + }, methods: { - fetchIssues(pageToFetch) { + fetchIssues() { + if (!this.hasIssues) { + return undefined; + } + this.isLoading = true; return axios .get(this.endpoint, { params: { - page: pageToFetch || this.currentPage, + page: this.page, per_page: this.$options.PAGE_SIZE, - state: IssuableStatus.Open, + search: this.searchQuery, + state: this.state, with_labels_details: true, + ...this.filters, }, }) .then(({ data, headers }) => { - this.currentPage = Number(headers['x-page']); + this.page = Number(headers['x-page']); this.totalIssues = Number(headers['x-total']); this.issues = data.map((issue) => convertObjectPropsToCamelCase(issue, { deep: true })); + this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); }) .catch(() => { createFlash({ message: __('An error occurred while loading issues') }); @@ -73,8 +215,71 @@ export default { this.isLoading = false; }); }, + getExportCsvPathWithQuery() { + return `${this.exportCsvPath}${window.location.search}`; + }, + handleUpdateLegacyBulkEdit() { + // If "select all" checkbox was checked, wait for all checkboxes + // to be checked before updating IssuableBulkUpdateSidebar class + this.$nextTick(() => { + eventHub.$emit('issuables:updateBulkEdit'); + }); + }, + handleBulkUpdateClick() { + eventHub.$emit('issuables:enableBulkEdit'); + }, + handleClickTab(state) { + if (this.state !== state) { + this.page = 1; + } + this.state = state; + this.fetchIssues(); + }, + handleFilter(filter) { + this.filterTokens = filter; + this.fetchIssues(); + }, handlePageChange(page) { - this.fetchIssues(page); + this.page = page; + this.fetchIssues(); + }, + handleReorder({ newIndex, oldIndex }) { + const issueToMove = this.issues[oldIndex]; + const isDragDropDownwards = newIndex > oldIndex; + const isMovingToBeginning = newIndex === 0; + const isMovingToEnd = newIndex === this.issues.length - 1; + + let moveBeforeId; + let moveAfterId; + + if (isDragDropDownwards) { + const afterIndex = isMovingToEnd ? newIndex : newIndex + 1; + moveBeforeId = this.issues[newIndex].id; + moveAfterId = this.issues[afterIndex].id; + } else { + const beforeIndex = isMovingToBeginning ? newIndex : newIndex - 1; + moveBeforeId = this.issues[beforeIndex].id; + moveAfterId = this.issues[newIndex].id; + } + + return axios + .put(`${this.issuesPath}/${issueToMove.iid}/reorder`, { + move_before_id: isMovingToBeginning ? null : moveBeforeId, + move_after_id: isMovingToEnd ? null : moveAfterId, + }) + .then(() => { + // Move issue to new position in list + this.issues.splice(oldIndex, 1); + this.issues.splice(newIndex, 0, issueToMove); + }) + .catch(() => { + createFlash({ message: this.$options.i18n.reorderError }); + }); + }, + handleSort(value) { + this.sortKey = value; + this.filters = sortParams[value]; + this.fetchIssues(); }, }, }; @@ -82,26 +287,70 @@ export default { <template> <issuable-list + v-if="hasIssues" :namespace="fullPath" recent-searches-storage-key="issues" :search-input-placeholder="__('Search or filter results…')" :search-tokens="[]" - :sort-options="[]" + :initial-filter-value="filterTokens" + :sort-options="$options.sortOptions" + :initial-sort-by="sortKey" :issuables="issues" - :tabs="[]" - current-tab="" + :tabs="$options.IssuableListTabs" + :current-tab="state" + :tab-counts="tabCounts" :issuables-loading="isLoading" - :show-pagination-controls="true" + :is-manual-ordering="isManualOrdering" + :show-bulk-edit-sidebar="showBulkEditSidebar" + :show-pagination-controls="showPaginationControls" :total-items="totalIssues" - :current-page="currentPage" - :previous-page="currentPage - 1" - :next-page="currentPage + 1" + :current-page="page" + :previous-page="page - 1" + :next-page="page + 1" :url-params="urlParams" + @click-tab="handleClickTab" + @filter="handleFilter" @page-change="handlePageChange" + @reorder="handleReorder" + @sort="handleSort" + @update-legacy-bulk-edit="handleUpdateLegacyBulkEdit" > + <template #nav-actions> + <gl-button + v-gl-tooltip + :href="rssPath" + icon="rss" + :title="$options.i18n.rssLabel" + :aria-label="$options.i18n.rssLabel" + /> + <gl-button + v-gl-tooltip + :href="calendarPath" + icon="calendar" + :title="$options.i18n.calendarLabel" + :aria-label="$options.i18n.calendarLabel" + /> + <csv-import-export-buttons + class="gl-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="totalIssues" + /> + <gl-button + v-if="canBulkUpdate" + :disabled="showBulkEditSidebar" + @click="handleBulkUpdateClick" + > + {{ __('Edit issues') }} + </gl-button> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + </template> + <template #timeframe="{ issuable = {} }"> <issue-card-time-info :issue="issuable" /> </template> + <template #statistics="{ issuable = {} }"> <li v-if="issuable.mergeRequestsCount" @@ -139,5 +388,81 @@ export default { :is-list-item="true" /> </template> + + <template #empty-state> + <gl-empty-state + v-if="searchQuery" + :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> </issuable-list> + + <div v-else-if="isSignedIn"> + <gl-empty-state + :description="$options.i18n.noIssuesSignedInDescription" + :title="$options.i18n.noIssuesSignedInTitle" + :svg-path="emptyStateSvgPath" + > + <template #actions> + <gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm"> + {{ $options.i18n.newIssueLabel }} + </gl-button> + <csv-import-export-buttons + class="gl-mr-3" + :export-csv-path="exportCsvPathWithQuery" + :issuable-count="totalIssues" + /> + </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> + </div> + + <gl-empty-state + v-else + :description="$options.i18n.noIssuesSignedOutDescription" + :title="$options.i18n.noIssuesSignedOutTitle" + :svg-path="emptyStateSvgPath" + :primary-button-text="$options.i18n.noIssuesSignedOutButtonText" + :primary-button-link="signInPath" + /> </template> diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js index f008ba1bf4a..f6f23af80ba 100644 --- a/app/assets/javascripts/issues_list/constants.js +++ b/app/assets/javascripts/issues_list/constants.js @@ -54,3 +54,191 @@ export const availableSortOptionsJira = [ ]; export const JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY = 'jira-import-success-alert-hide-map'; + +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 UPDATED_ASC = 'UPDATED_ASC'; +export const UPDATED_DESC = 'UPDATED_DESC'; +export const WEIGHT_ASC = 'WEIGHT_ASC'; +export const WEIGHT_DESC = 'WEIGHT_DESC'; + +const SORT_ASC = 'asc'; +const SORT_DESC = 'desc'; + +const BLOCKING_ISSUES = 'blocking_issues'; + +export const sortParams = { + [PRIORITY_ASC]: { + order_by: PRIORITY, + sort: SORT_ASC, + }, + [PRIORITY_DESC]: { + order_by: PRIORITY, + sort: SORT_DESC, + }, + [CREATED_ASC]: { + order_by: CREATED_AT, + sort: SORT_ASC, + }, + [CREATED_DESC]: { + order_by: CREATED_AT, + sort: SORT_DESC, + }, + [UPDATED_ASC]: { + order_by: UPDATED_AT, + sort: SORT_ASC, + }, + [UPDATED_DESC]: { + order_by: UPDATED_AT, + sort: SORT_DESC, + }, + [MILESTONE_DUE_ASC]: { + order_by: MILESTONE_DUE, + sort: SORT_ASC, + }, + [MILESTONE_DUE_DESC]: { + order_by: MILESTONE_DUE, + sort: SORT_DESC, + }, + [DUE_DATE_ASC]: { + order_by: DUE_DATE, + sort: SORT_ASC, + }, + [DUE_DATE_DESC]: { + order_by: DUE_DATE, + sort: SORT_DESC, + }, + [POPULARITY_ASC]: { + order_by: POPULARITY, + sort: SORT_ASC, + }, + [POPULARITY_DESC]: { + order_by: POPULARITY, + sort: SORT_DESC, + }, + [LABEL_PRIORITY_ASC]: { + order_by: LABEL_PRIORITY, + sort: SORT_ASC, + }, + [LABEL_PRIORITY_DESC]: { + order_by: LABEL_PRIORITY, + sort: SORT_DESC, + }, + [RELATIVE_POSITION_ASC]: { + order_by: RELATIVE_POSITION, + per_page: 100, + sort: SORT_ASC, + }, + [WEIGHT_ASC]: { + order_by: WEIGHT, + sort: SORT_ASC, + }, + [WEIGHT_DESC]: { + order_by: WEIGHT, + sort: SORT_DESC, + }, + [BLOCKING_ISSUES_ASC]: { + order_by: BLOCKING_ISSUES, + sort: SORT_ASC, + }, + [BLOCKING_ISSUES_DESC]: { + order_by: BLOCKING_ISSUES, + sort: SORT_DESC, + }, +}; + +export const sortOptions = [ + { + id: 1, + title: __('Priority'), + sortDirection: { + ascending: PRIORITY_ASC, + descending: PRIORITY_DESC, + }, + }, + { + id: 2, + title: __('Created date'), + sortDirection: { + ascending: CREATED_ASC, + descending: CREATED_DESC, + }, + }, + { + id: 3, + title: __('Last updated'), + sortDirection: { + ascending: UPDATED_ASC, + descending: UPDATED_DESC, + }, + }, + { + id: 4, + title: __('Milestone due date'), + sortDirection: { + ascending: MILESTONE_DUE_ASC, + descending: MILESTONE_DUE_DESC, + }, + }, + { + id: 5, + title: __('Due date'), + sortDirection: { + ascending: DUE_DATE_ASC, + descending: DUE_DATE_DESC, + }, + }, + { + id: 6, + title: __('Popularity'), + sortDirection: { + ascending: POPULARITY_ASC, + descending: POPULARITY_DESC, + }, + }, + { + id: 7, + title: __('Label priority'), + sortDirection: { + ascending: LABEL_PRIORITY_ASC, + descending: LABEL_PRIORITY_DESC, + }, + }, + { + id: 8, + title: __('Manual'), + sortDirection: { + ascending: RELATIVE_POSITION_ASC, + descending: RELATIVE_POSITION_ASC, + }, + }, + { + id: 9, + title: __('Weight'), + sortDirection: { + ascending: WEIGHT_ASC, + descending: WEIGHT_DESC, + }, + }, + { + id: 10, + title: __('Blocking'), + sortDirection: { + ascending: BLOCKING_ISSUES_ASC, + descending: BLOCKING_ISSUES_DESC, + }, + }, +]; diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index a283cbdc86b..0b64df50691 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -73,11 +73,29 @@ export function initIssuesListApp() { } const { + calendarPath, + canBulkUpdate, + canEdit, + canImportIssues, + email, + emptyStateSvgPath, endpoint, + exportCsvPath, fullPath, hasBlockedIssuesFeature, hasIssuableHealthStatusFeature, + hasIssues, hasIssueWeightsFeature, + importCsvIssuesPath, + isSignedIn, + issuesPath, + jiraIntegrationPath, + maxAttachmentSize, + newIssuePath, + projectImportJiraPath, + rssPath, + showNewIssueLink, + signInPath, } = el.dataset; return new Vue({ @@ -86,11 +104,32 @@ export function initIssuesListApp() { // issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153 apolloProvider: {}, provide: { + calendarPath, + canBulkUpdate: parseBoolean(canBulkUpdate), + emptyStateSvgPath, endpoint, fullPath, hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature), hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature), + hasIssues: parseBoolean(hasIssues), hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature), + isSignedIn: parseBoolean(isSignedIn), + issuesPath, + jiraIntegrationPath, + newIssuePath, + rssPath, + showNewIssueLink: parseBoolean(showNewIssueLink), + signInPath, + // For CsvImportExportButtons component + canEdit: parseBoolean(canEdit), + email, + exportCsvPath, + importCsvIssuesPath, + maxAttachmentSize, + projectImportJiraPath, + showExportButton: parseBoolean(hasIssues), + showImportButton: parseBoolean(canImportIssues), + showLabel: !parseBoolean(hasIssues), }, render: (createComponent) => createComponent(IssuesListApp), }); diff --git a/app/assets/javascripts/jira_connect/api.js b/app/assets/javascripts/jira_connect/api.js index d78aba0a3f7..abf2c070e68 100644 --- a/app/assets/javascripts/jira_connect/api.js +++ b/app/assets/javascripts/jira_connect/api.js @@ -1,24 +1,5 @@ import axios from 'axios'; - -export const getJwt = () => { - return new Promise((resolve) => { - AP.context.getToken((token) => { - resolve(token); - }); - }); -}; - -export const getLocation = () => { - return new Promise((resolve) => { - if (typeof AP.getLocation !== 'function') { - resolve(); - } - - AP.getLocation((location) => { - resolve(location); - }); - }); -}; +import { getJwt } from '~/jira_connect/utils'; export const addSubscription = async (addPath, namespace) => { const jwt = await getJwt(); @@ -39,11 +20,12 @@ export const removeSubscription = async (removePath) => { }); }; -export const fetchGroups = async (groupsPath, { page, perPage }) => { +export const fetchGroups = async (groupsPath, { page, perPage, search }) => { return axios.get(groupsPath, { params: { page, per_page: perPage, + search, }, }); }; diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index fe5ad8b67d7..ff4dfb23687 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -1,27 +1,26 @@ <script> -import { GlAlert, GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlLink, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; import { mapState, mapMutations } from 'vuex'; -import { getLocation } from '~/jira_connect/api'; +import { retrieveAlert, getLocation } from '~/jira_connect/utils'; import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { SET_ALERT } from '../store/mutation_types'; -import { retrieveAlert } from '../utils'; import GroupsList from './groups_list.vue'; +import SubscriptionsList from './subscriptions_list.vue'; export default { name: 'JiraConnectApp', components: { GlAlert, GlButton, - GlModal, - GroupsList, GlLink, + GlModal, GlSprintf, + GroupsList, + SubscriptionsList, }, directives: { GlModalDirective, }, - mixins: [glFeatureFlagsMixin()], inject: { usersPath: { default: '', @@ -91,37 +90,36 @@ export default { <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> - <div - class="jira-connect-app-body gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" - > - <h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading"> - {{ s__('Integrations|Linked namespaces') }} - </h5> - <gl-button - v-if="usersPath" - category="primary" - variant="info" - class="gl-align-self-center" - :href="usersPathWithReturnTo" - target="_blank" - >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button - > - <template v-else> + <div class="jira-connect-app-body gl-my-7 gl-px-5 gl-pb-4"> + <div class="gl-display-flex gl-justify-content-end"> <gl-button - v-gl-modal-directive="'add-namespace-modal'" + v-if="usersPath" category="primary" variant="info" class="gl-align-self-center" - >{{ s__('Integrations|Add namespace') }}</gl-button - > - <gl-modal - modal-id="add-namespace-modal" - :title="s__('Integrations|Link namespaces')" - :action-cancel="$options.modal.cancelProps" + :href="usersPathWithReturnTo" + target="_blank" + >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button > - <groups-list /> - </gl-modal> - </template> + <template v-else> + <gl-button + v-gl-modal-directive="'add-namespace-modal'" + category="primary" + variant="info" + class="gl-align-self-center" + >{{ s__('Integrations|Add namespace') }}</gl-button + > + <gl-modal + modal-id="add-namespace-modal" + :title="s__('Integrations|Link namespaces')" + :action-cancel="$options.modal.cancelProps" + > + <groups-list /> + </gl-modal> + </template> + </div> + + <subscriptions-list /> </div> </div> </template> diff --git a/app/assets/javascripts/jira_connect/components/group_item_name.vue b/app/assets/javascripts/jira_connect/components/group_item_name.vue new file mode 100644 index 00000000000..e6c172dae9e --- /dev/null +++ b/app/assets/javascripts/jira_connect/components/group_item_name.vue @@ -0,0 +1,34 @@ +<script> +import { GlAvatar, GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlAvatar, + GlIcon, + }, + props: { + group: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <gl-icon name="folder-o" class="gl-mr-3" /> + <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3"> + <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" /> + </div> + + <div> + <span class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold"> + {{ group.full_name }} + </span> + <div v-if="group.description"> + <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue index 69f2903388c..275ff820419 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list.vue +++ b/app/assets/javascripts/jira_connect/components/groups_list.vue @@ -1,5 +1,5 @@ <script> -import { GlTabs, GlTab, GlLoadingIcon, GlPagination, GlAlert } from '@gitlab/ui'; +import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui'; import { fetchGroups } from '~/jira_connect/api'; import { defaultPerPage } from '~/jira_connect/constants'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; @@ -8,11 +8,10 @@ import GroupsListItem from './groups_list_item.vue'; export default { components: { - GlTabs, - GlTab, GlLoadingIcon, GlPagination, GlAlert, + GlSearchBoxByType, GroupsListItem, }, inject: { @@ -23,7 +22,8 @@ export default { data() { return { groups: [], - isLoading: false, + isLoadingInitial: true, + isLoadingMore: false, page: 1, perPage: defaultPerPage, totalItems: 0, @@ -31,15 +31,18 @@ export default { }; }, mounted() { - this.loadGroups(); + return this.loadGroups().finally(() => { + this.isLoadingInitial = false; + }); }, methods: { - loadGroups() { - this.isLoading = true; + loadGroups({ searchTerm } = {}) { + this.isLoadingMore = true; - fetchGroups(this.groupsPath, { + return fetchGroups(this.groupsPath, { page: this.page, perPage: this.perPage, + search: searchTerm, }) .then((response) => { const { page, total } = parseIntPagination(normalizeHeaders(response.headers)); @@ -51,50 +54,61 @@ export default { this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.'); }) .finally(() => { - this.isLoading = false; + this.isLoadingMore = false; }); }, + onGroupSearch(searchTerm) { + return this.loadGroups({ searchTerm }); + }, }, }; </script> <template> <div> - <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" @dismiss="errorMessage = null"> + <gl-alert v-if="errorMessage" class="gl-mb-5" variant="danger" @dismiss="errorMessage = null"> {{ errorMessage }} </gl-alert> - <gl-tabs> - <gl-tab :title="__('Groups and subgroups')" class="gl-pt-3"> - <gl-loading-icon v-if="isLoading" size="md" /> - <div v-else-if="groups.length === 0" class="gl-text-center"> - <h5>{{ s__('Integrations|No available namespaces.') }}</h5> - <p class="gl-mt-5"> - {{ - s__('Integrations|You must have owner or maintainer permissions to link namespaces.') - }} - </p> - </div> - <ul v-else class="gl-list-style-none gl-pl-0"> - <groups-list-item - v-for="group in groups" - :key="group.id" - :group="group" - @error="errorMessage = $event" - /> - </ul> + <gl-search-box-by-type + class="gl-mb-5" + debounce="500" + :placeholder="__('Search by name')" + :is-loading="isLoadingMore" + @input="onGroupSearch" + /> + + <gl-loading-icon v-if="isLoadingInitial" size="md" /> + <div v-else-if="groups.length === 0" class="gl-text-center"> + <h5>{{ s__('Integrations|No available namespaces.') }}</h5> + <p class="gl-mt-5"> + {{ s__('Integrations|You must have owner or maintainer permissions to link namespaces.') }} + </p> + </div> + <ul + v-else + class="gl-list-style-none gl-pl-0 gl-border-t-1 gl-border-t-solid gl-border-t-gray-100" + :class="{ 'gl-opacity-5': isLoadingMore }" + data-testid="groups-list" + > + <groups-list-item + v-for="group in groups" + :key="group.id" + :group="group" + :disabled="isLoadingMore" + @error="errorMessage = $event" + /> + </ul> - <div class="gl-display-flex gl-justify-content-center gl-mt-5"> - <gl-pagination - v-if="totalItems > perPage && groups.length > 0" - v-model="page" - class="gl-mb-0" - :per-page="perPage" - :total-items="totalItems" - @input="loadGroups" - /> - </div> - </gl-tab> - </gl-tabs> + <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-pagination + v-if="totalItems > perPage && groups.length > 0" + v-model="page" + class="gl-mb-0" + :per-page="perPage" + :total-items="totalItems" + @input="loadGroups" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/components/groups_list_item.vue index b8959a2a505..ad046920dd1 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list_item.vue +++ b/app/assets/javascripts/jira_connect/components/groups_list_item.vue @@ -1,15 +1,15 @@ <script> -import { GlAvatar, GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { addSubscription } from '~/jira_connect/api'; +import { persistAlert, reloadPage } from '~/jira_connect/utils'; import { s__ } from '~/locale'; -import { persistAlert } from '../utils'; +import GroupItemName from './group_item_name.vue'; export default { components: { - GlAvatar, GlButton, - GlIcon, + GroupItemName, }, inject: { subscriptionsPath: { @@ -21,6 +21,11 @@ export default { type: Object, required: true, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -42,7 +47,7 @@ export default { variant: 'success', }); - AP.navigator.reload(); + reloadPage(); }) .catch((error) => { this.$emit( @@ -50,8 +55,6 @@ export default { error?.response?.data?.error || s__('Integrations|Failed to link namespace. Please try again.'), ); - }) - .finally(() => { this.isLoading = false; }); }, @@ -60,34 +63,22 @@ export default { </script> <template> - <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"> + <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> <div class="gl-display-flex gl-align-items-center gl-py-3"> - <gl-icon name="folder-o" class="gl-mr-3" /> - <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3"> - <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" /> - </div> <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1"> - <div class="gl-display-flex gl-align-items-center gl-flex-wrap"> - <span - class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold" - data-testid="group-list-item-name" - > - {{ group.full_name }} - </span> - </div> - <div v-if="group.description" data-testid="group-list-item-description"> - <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p> - </div> + <group-item-name :group="group" /> </div> <gl-button category="secondary" - variant="success" + variant="confirm" :loading="isLoading" + :disabled="disabled" @click.prevent="onClick" - >{{ __('Link') }}</gl-button > + {{ __('Link') }} + </gl-button> </div> </div> </li> diff --git a/app/assets/javascripts/jira_connect/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/components/subscriptions_list.vue new file mode 100644 index 00000000000..a606e2edbbb --- /dev/null +++ b/app/assets/javascripts/jira_connect/components/subscriptions_list.vue @@ -0,0 +1,109 @@ +<script> +import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { mapMutations } from 'vuex'; +import { removeSubscription } from '~/jira_connect/api'; +import { reloadPage } from '~/jira_connect/utils'; +import { __, s__ } from '~/locale'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { SET_ALERT } from '../store/mutation_types'; +import GroupItemName from './group_item_name.vue'; + +export default { + components: { + GlButton, + GlEmptyState, + GlTable, + GroupItemName, + TimeagoTooltip, + }, + inject: { + subscriptions: { + default: [], + }, + }, + data() { + return { + loadingItem: null, + }; + }, + fields: [ + { + key: 'name', + label: s__('Integrations|Linked namespaces'), + }, + { + key: 'created_at', + label: __('Added'), + tdClass: 'gl-vertical-align-middle! gl-w-20p', + }, + { + key: 'actions', + label: '', + tdClass: 'gl-text-right gl-vertical-align-middle! gl-pl-0!', + }, + ], + i18n: { + emptyTitle: s__('Integrations|No linked namespaces'), + emptyDescription: s__( + 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.', + ), + unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'), + }, + methods: { + ...mapMutations({ + setAlert: SET_ALERT, + }), + isEmpty, + isLoadingItem(item) { + return this.loadingItem === item; + }, + unlinkBtnClass(item) { + return this.isLoadingItem(item) ? '' : 'gl-ml-6'; + }, + onClick(item) { + this.loadingItem = item; + + removeSubscription(item.unlink_path) + .then(() => { + reloadPage(); + }) + .catch((error) => { + this.setAlert({ + message: error?.response?.data?.error || this.$options.i18n.unlinkError, + variant: 'danger', + }); + this.loadingItem = null; + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-empty-state + v-if="isEmpty(subscriptions)" + :title="$options.i18n.emptyTitle" + :description="$options.i18n.emptyDescription" + /> + <gl-table v-else :items="subscriptions" :fields="$options.fields"> + <template #cell(name)="{ item }"> + <group-item-name :group="item.group" /> + </template> + <template #cell(created_at)="{ item }"> + <timeago-tooltip :time="item.created_at" /> + </template> + <template #cell(actions)="{ item }"> + <gl-button + :class="unlinkBtnClass(item)" + category="secondary" + :loading="isLoadingItem(item)" + :disabled="!isEmpty(loadingItem)" + @click.prevent="onClick(item)" + >{{ __('Unlink') }}</gl-button + > + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js index ecdb41607a4..dc8bb3b0c77 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/index.js @@ -1,25 +1,14 @@ import setConfigs from '@gitlab/ui/dist/config'; import Vue from 'vue'; -import { addSubscription, removeSubscription, getLocation } from '~/jira_connect/api'; +import { getLocation, sizeToParent } from '~/jira_connect/utils'; import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin'; import Translate from '~/vue_shared/translate'; import JiraConnectApp from './components/app.vue'; import createStore from './store'; -import { SET_ALERT } from './store/mutation_types'; const store = createStore(); -const reqComplete = () => { - AP.navigator.reload(); -}; - -const reqFailed = (res, fallbackErrorMessage) => { - const { error = fallbackErrorMessage } = res || {}; - - store.commit(SET_ALERT, { message: error, variant: 'danger' }); -}; - const updateSignInLinks = async () => { const location = await getLocation(); Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => { @@ -28,43 +17,7 @@ const updateSignInLinks = async () => { }); }; -const initRemoveSubscriptionButtonHandlers = () => { - Array.from(document.querySelectorAll('.js-jira-connect-remove-subscription')).forEach((el) => { - el.addEventListener('click', function onRemoveSubscriptionClick(e) { - e.preventDefault(); - - const removePath = e.target.getAttribute('href'); - removeSubscription(removePath) - .then(reqComplete) - .catch((err) => - reqFailed(err.response.data, 'Failed to remove namespace. Please try again.'), - ); - }); - }); -}; - -const initAddSubscriptionFormHandler = () => { - const formEl = document.querySelector('#add-subscription-form'); - if (!formEl) { - return; - } - - formEl.addEventListener('submit', function onAddSubscriptionForm(e) { - e.preventDefault(); - - const addPath = e.target.getAttribute('action'); - const namespace = (e.target.querySelector('#namespace-input') || {}).value; - - addSubscription(addPath, namespace) - .then(reqComplete) - .catch((err) => reqFailed(err.response.data, 'Failed to add namespace. Please try again.')); - }); -}; - export async function initJiraConnect() { - initAddSubscriptionFormHandler(); - initRemoveSubscriptionButtonHandlers(); - await updateSignInLinks(); const el = document.querySelector('.js-jira-connect-app'); @@ -76,14 +29,15 @@ export async function initJiraConnect() { Vue.use(Translate); Vue.use(GlFeatureFlagsPlugin); - const { groupsPath, subscriptionsPath, usersPath } = el.dataset; - AP.sizeToParent(); + const { groupsPath, subscriptions, subscriptionsPath, usersPath } = el.dataset; + sizeToParent(); return new Vue({ el, store, provide: { groupsPath, + subscriptions: JSON.parse(subscriptions), subscriptionsPath, usersPath, }, diff --git a/app/assets/javascripts/jira_connect/utils.js b/app/assets/javascripts/jira_connect/utils.js index 2a6c53ba42c..ecd1a31339a 100644 --- a/app/assets/javascripts/jira_connect/utils.js +++ b/app/assets/javascripts/jira_connect/utils.js @@ -1,6 +1,8 @@ import AccessorUtilities from '~/lib/utils/accessor'; import { ALERT_LOCALSTORAGE_KEY } from './constants'; +const isFunction = (fn) => typeof fn === 'function'; + /** * Persist alert data to localStorage. */ @@ -31,3 +33,41 @@ export const retrieveAlert = () => { return JSON.parse(initialAlertJSON); }; + +export const getJwt = () => { + return new Promise((resolve) => { + if (isFunction(AP?.context?.getToken)) { + AP.context.getToken((token) => { + resolve(token); + }); + } else { + resolve(); + } + }); +}; + +export const getLocation = () => { + return new Promise((resolve) => { + if (isFunction(AP?.getLocation)) { + AP.getLocation((location) => { + resolve(location); + }); + } else { + resolve(); + } + }); +}; + +export const reloadPage = () => { + if (isFunction(AP?.navigator?.reload)) { + AP.navigator.reload(); + } else { + window.location.reload(); + } +}; + +export const sizeToParent = () => { + if (isFunction(AP?.sizeToParent)) { + AP.sizeToParent(); + } +}; diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index eae6b5d5419..7f25ca8a94d 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -1,5 +1,4 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlLink } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -23,9 +22,9 @@ export default { </script> <template> <div> - <span class="font-weight-bold">{{ __('Commit') }}</span> + <span class="gl-font-weight-bold">{{ __('Commit') }}</span> - <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit"> + <gl-link :href="commit.commit_path" class="gl-text-blue-600!" data-testid="commit-sha"> {{ commit.short_id }} </gl-link> @@ -37,8 +36,8 @@ export default { /> <span v-if="mergeRequest"> - in - <gl-link :href="mergeRequest.path" class="js-link-commit link-commit" + {{ __('in') }} + <gl-link :href="mergeRequest.path" class="gl-text-blue-600!" data-testid="link-commit" >!{{ mergeRequest.iid }}</gl-link > </span> diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index 488d838db52..00a570fe2f8 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -48,7 +48,7 @@ export default { }" > <gl-link - v-gl-tooltip + v-gl-tooltip:tooltip-container.left :href="job.status.details_path" :title="tooltipText" class="js-job-link gl-display-flex gl-align-items-center" diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index ce4a85b35b7..ea50a11bed6 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -1,9 +1,15 @@ <script> import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; import { numberToHumanSize } from '~/lib/utils/number_utils'; -import { __, sprintf } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; export default { + i18n: { + eraseLogButtonLabel: s__('Job|Erase job log'), + scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), + scrollToTopButtonLabel: s__('Job|Scroll to top'), + showRawButtonLabel: s__('Job|Show complete raw'), + }, components: { GlLink, GlButton, @@ -82,7 +88,8 @@ export default { <gl-button v-if="rawPath" v-gl-tooltip.body - :title="s__('Job|Show complete raw')" + :title="$options.i18n.showRawButtonLabel" + :aria-label="$options.i18n.showRawButtonLabel" :href="rawPath" data-testid="job-raw-link-controller" icon="doc-text" @@ -91,7 +98,8 @@ export default { <gl-button v-if="erasePath" v-gl-tooltip.body - :title="s__('Job|Erase job log')" + :title="$options.i18n.eraseLogButtonLabel" + :aria-label="$options.i18n.eraseLogButtonLabel" :href="erasePath" :data-confirm="__('Are you sure you want to erase this build?')" class="gl-ml-3" @@ -102,23 +110,25 @@ export default { <!-- eo links --> <!-- scroll buttons --> - <div v-gl-tooltip :title="s__('Job|Scroll to top')" class="gl-ml-3"> + <div v-gl-tooltip :title="$options.i18n.scrollToTopButtonLabel" class="gl-ml-3"> <gl-button :disabled="isScrollTopDisabled" class="btn-scroll" data-testid="job-controller-scroll-top" icon="scroll_up" + :aria-label="$options.i18n.scrollToTopButtonLabel" @click="handleScrollToTop" /> </div> - <div v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="gl-ml-3"> + <div v-gl-tooltip :title="$options.i18n.scrollToBottomButtonLabel" class="gl-ml-3"> <gl-button :disabled="isScrollBottomDisabled" class="js-scroll-bottom btn-scroll" data-testid="job-controller-scroll-bottom" icon="scroll_down" :class="{ animate: isScrollingDown }" + :aria-label="$options.i18n.scrollToBottomButtonLabel" @click="handleScrollToBottom" /> </div> diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue index a1f4f7abb77..d45012d2023 100644 --- a/app/assets/javascripts/jobs/components/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue @@ -43,6 +43,7 @@ export default { variables: [], key: '', secretValue: '', + triggerBtnDisabled: false, }; }, computed: { @@ -98,6 +99,11 @@ export default { 1, ); }, + trigger() { + this.triggerBtnDisabled = true; + + this.triggerManualJob(this.variables); + }, }, }; </script> @@ -111,7 +117,12 @@ export default { <div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Value') }}</div> </div> - <div v-for="variable in variables" :key="variable.id" class="gl-responsive-table-row"> + <div + v-for="variable in variables" + :key="variable.id" + class="gl-responsive-table-row" + data-testid="ci-variable-row" + > <div class="table-section section-50"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div> <div class="table-mobile-content gl-mr-3"> @@ -120,6 +131,7 @@ export default { v-model="variable.key" :placeholder="$options.i18n.keyPlaceholder" class="ci-variable-body-item form-control" + data-testid="ci-variable-key" /> </div> </div> @@ -132,6 +144,7 @@ export default { v-model="variable.secret_value" :placeholder="$options.i18n.valuePlaceholder" class="ci-variable-body-item form-control" + data-testid="ci-variable-value" /> </div> </div> @@ -143,6 +156,7 @@ export default { category="tertiary" icon="clear" :aria-label="__('Delete variable')" + data-testid="delete-variable-btn" @click="deleteVariable(variable.id)" /> </div> @@ -175,14 +189,16 @@ export default { </div> </div> <div class="d-flex gl-mt-3 justify-content-center"> - <p class="text-muted" v-html="helpText"></p> + <p class="text-muted" data-testid="form-help-text" v-html="helpText"></p> </div> <div class="d-flex justify-content-center"> <gl-button variant="info" category="primary" :aria-label="__('Trigger manual job')" - @click="triggerManualJob(variables)" + :disabled="triggerBtnDisabled" + data-testid="trigger-manual-job-btn" + @click="trigger" > {{ action.button_title }} </gl-button> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index fcf03dff34e..1b50006239c 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -49,7 +49,8 @@ export default { return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; }, hasArtifact() { - return !isEmpty(this.job.artifact); + // the artifact object will always have a locked property + return Object.keys(this.job.artifact).length > 1; }, hasTriggers() { return !isEmpty(this.job.trigger); diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index b20d58b6ffe..98badb96ed7 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -51,7 +51,9 @@ export default { }); }, runnerId() { - return `${this.job.runner.description} (#${this.job.runner.id})`; + const { id, short_sha: token, description } = this.job?.runner; + + return `#${id} (${token}) ${description}`; }, shouldRenderBlock() { return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags); diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 18de849af88..36b0ad43b14 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -44,13 +44,14 @@ export default { </script> <template> <div class="dropdown"> - <div class="js-pipeline-info"> + <div class="js-pipeline-info" data-testid="pipeline-info"> <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span> <gl-link :href="pipeline.path" class="js-pipeline-path link-commit" + data-testid="pipeline-path" data-qa-selector="pipeline_path" >#{{ pipeline.id }}</gl-link > @@ -58,13 +59,17 @@ export default { {{ s__('Job|for') }} <template v-if="isTriggeredByMergeRequest"> - <gl-link :href="pipeline.merge_request.path" class="link-commit ref-name js-mr-link" + <gl-link + :href="pipeline.merge_request.path" + class="link-commit ref-name" + data-testid="mr-link" >!{{ pipeline.merge_request.iid }}</gl-link > {{ s__('Job|with') }} <gl-link :href="pipeline.merge_request.source_branch_path" - class="link-commit ref-name js-source-branch-link" + class="link-commit ref-name" + data-testid="source-branch-link" >{{ pipeline.merge_request.source_branch }}</gl-link > @@ -72,7 +77,8 @@ export default { {{ s__('Job|into') }} <gl-link :href="pipeline.merge_request.target_branch_path" - class="link-commit ref-name js-target-branch-link" + class="link-commit ref-name" + data-testid="target-branch-link" >{{ pipeline.merge_request.target_branch }}</gl-link > </template> diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql new file mode 100644 index 00000000000..d9e51b0345a --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql @@ -0,0 +1,52 @@ +query getJobs($fullPath: ID!, $statuses: [CiJobStatus!]) { + project(fullPath: $fullPath) { + jobs(first: 20, statuses: $statuses) { + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + nodes { + detailedStatus { + icon + label + text + tooltip + action { + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + pipeline { + id + path + user { + webPath + avatarUrl + } + } + stage { + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + } + } + } +} diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js new file mode 100644 index 00000000000..b6b3bb6d379 --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default (containerId = 'js-jobs-table') => { + const containerEl = document.getElementById(containerId); + + if (!containerEl) { + return false; + } + + const { fullPath, jobCounts, jobStatuses } = containerEl.dataset; + + return new Vue({ + el: containerEl, + apolloProvider, + provide: { + fullPath, + jobStatuses: JSON.parse(jobStatuses), + jobCounts: JSON.parse(jobCounts), + }, + render(createElement) { + return createElement(JobsTableApp); + }, + }); +}; diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue new file mode 100644 index 00000000000..32b26d45dfe --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -0,0 +1,67 @@ +<script> +import { GlTable } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const defaultTableClasses = { + tdClass: 'gl-p-5!', + thClass: 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!', +}; + +export default { + fields: [ + { + key: 'status', + label: __('Status'), + ...defaultTableClasses, + }, + { + key: 'job', + label: __('Job'), + ...defaultTableClasses, + }, + { + key: 'pipeline', + label: __('Pipeline'), + ...defaultTableClasses, + }, + { + key: 'stage', + label: __('Stage'), + ...defaultTableClasses, + }, + { + key: 'name', + label: __('Name'), + ...defaultTableClasses, + }, + { + key: 'duration', + label: __('Duration'), + ...defaultTableClasses, + }, + { + key: 'coverage', + label: __('Coverage'), + ...defaultTableClasses, + }, + { + key: 'actions', + label: '', + ...defaultTableClasses, + }, + ], + components: { + GlTable, + }, + props: { + jobs: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <gl-table :items="jobs" :fields="$options.fields" /> +</template> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue new file mode 100644 index 00000000000..55954e31654 --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -0,0 +1,85 @@ +<script> +import { GlAlert, GlSkeletonLoader } from '@gitlab/ui'; +import { __ } from '~/locale'; +import GetJobs from './graphql/queries/get_jobs.query.graphql'; +import JobsTable from './jobs_table.vue'; +import JobsTableTabs from './jobs_table_tabs.vue'; + +export default { + i18n: { + errorMsg: __('There was an error fetching the jobs for your project.'), + }, + components: { + GlAlert, + GlSkeletonLoader, + JobsTable, + JobsTableTabs, + }, + inject: { + fullPath: { + default: '', + }, + }, + apollo: { + jobs: { + query: GetJobs, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update({ project }) { + return project?.jobs; + }, + error() { + this.hasError = true; + }, + }, + }, + data() { + return { + jobs: null, + hasError: false, + isAlertDismissed: false, + }; + }, + computed: { + shouldShowAlert() { + return this.hasError && !this.isAlertDismissed; + }, + }, + methods: { + fetchJobsByStatus(scope) { + this.$apollo.queries.jobs.refetch({ statuses: scope }); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert + v-if="shouldShowAlert" + class="gl-mt-2" + variant="danger" + dismissible + @dismiss="isAlertDismissed = true" + > + {{ $options.i18n.errorMsg }} + </gl-alert> + + <jobs-table-tabs @fetchJobsByStatus="fetchJobsByStatus" /> + + <div v-if="$apollo.loading" class="gl-mt-5"> + <gl-skeleton-loader + preserve-aspect-ratio="none" + equal-width-lines + :lines="5" + :width="600" + :height="66" + /> + </div> + + <jobs-table v-else :jobs="jobs.nodes" /> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue new file mode 100644 index 00000000000..95d265fce60 --- /dev/null +++ b/app/assets/javascripts/jobs/components/table/jobs_table_tabs.vue @@ -0,0 +1,66 @@ +<script> +import { GlBadge, GlTab, GlTabs } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlBadge, + GlTab, + GlTabs, + }, + inject: { + jobCounts: { + default: {}, + }, + jobStatuses: { + default: {}, + }, + }, + computed: { + tabs() { + return [ + { + text: __('All'), + count: this.jobCounts.all, + scope: null, + testId: 'jobs-all-tab', + }, + { + text: __('Pending'), + count: this.jobCounts.pending, + scope: this.jobStatuses.pending, + testId: 'jobs-pending-tab', + }, + { + text: __('Running'), + count: this.jobCounts.running, + scope: this.jobStatuses.running, + testId: 'jobs-running-tab', + }, + { + text: __('Finished'), + count: this.jobCounts.finished, + scope: [this.jobStatuses.success, this.jobStatuses.failed, this.jobStatuses.canceled], + testId: 'jobs-finished-tab', + }, + ]; + }, + }, +}; +</script> + +<template> + <gl-tabs> + <gl-tab + v-for="tab in tabs" + :key="tab.text" + :title-link-attributes="{ 'data-testid': tab.testId }" + @click="$emit('fetchJobsByStatus', tab.scope)" + > + <template #title> + <span>{{ tab.text }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> + </template> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 56e69ab9418..fb88e48c9a6 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, dot-notation, no-empty */ +/* eslint-disable func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-empty */ /* global Issuable */ /* global ListLabel */ @@ -7,7 +7,6 @@ import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { isScopedLabel } from '~/lib/utils/common_utils'; import boardsStore from './boards/stores/boards_store'; -import ModalStore from './boards/stores/modal_store'; import CreateLabelDropdown from './create_label'; import { deprecatedCreateFlash as flash } from './flash'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; @@ -313,7 +312,11 @@ export default class LabelsSelect { return; } - if ($('html').hasClass('issue-boards-page')) { + if ( + $('html') + .attr('class') + .match(/issue-boards-page|epic-boards-page/) + ) { return; } if ($dropdown.hasClass('js-multiselect')) { @@ -357,21 +360,7 @@ export default class LabelsSelect { return; } - let boardsModel; - if ($dropdown.closest('.add-issues-modal').length) { - boardsModel = ModalStore.store.filter; - } - - if (boardsModel) { - if (label.isAny) { - boardsModel['label_name'] = []; - } else if ($el.hasClass('is-active')) { - boardsModel['label_name'].push(label.title); - } - - e.preventDefault(); - return; - } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if (!$dropdown.hasClass('js-multiselect')) { selectedLabel = label.title; return Issuable.filterResults($dropdown.closest('form')); @@ -522,11 +511,15 @@ export default class LabelsSelect { } bindEvents() { - return $('body').on('change', '.selected-issuable', this.onSelectCheckboxIssue); + return $('body').on( + 'change', + '.issuable-list input[type="checkbox"]', + this.onSelectCheckboxIssue, + ); } // eslint-disable-next-line class-methods-use-this onSelectCheckboxIssue() { - if ($('.selected-issuable:checked').length) { + if ($('.issuable-list input[type="checkbox"]:checked').length) { return; } return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text(__('Label')); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index e090f9f6e8c..c720476f3bf 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -4,6 +4,7 @@ import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; import { createHttpLink } from 'apollo-link-http'; import { createUploadLink } from 'apollo-upload-client'; +import { apolloCaptchaLink } from '~/captcha/apollo_captcha_link'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; @@ -78,6 +79,7 @@ export default (resolvers = {}, config = {}) => { requestCounterLink, performanceBarLink, new StartupJSLink(), + apolloCaptchaLink, uploadsLink, ]), cache: new InMemoryCache({ diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js index ff176f11867..da2c10076b1 100644 --- a/app/assets/javascripts/lib/utils/color_utils.js +++ b/app/assets/javascripts/lib/utils/color_utils.js @@ -43,3 +43,15 @@ export const validateHexColor = (color = '') => { return /^#([0-9A-F]{3}){1,2}$/i.test(color); }; + +export function darkModeEnabled() { + const ideDarkThemes = ['dark', 'solarized-dark', 'monokai']; + + // eslint-disable-next-line @gitlab/require-i18n-strings + const isWebIde = document.body.dataset.page.startsWith('ide:'); + + if (isWebIde) { + return ideDarkThemes.includes(window.gon?.user_color_scheme); + } + return document.body.classList.contains('gl-dark'); +} diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 73eadfe3cbe..fb257228597 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -681,6 +681,19 @@ export const roundOffFloat = (number, precision = 0) => { }; /** + * Method to round values to the nearest half (0.5) + * + * Eg; roundToNearestHalf(3.141592) = 3, roundToNearestHalf(3.41592) = 3.5 + * + * Refer to spec/javascripts/lib/utils/common_utils_spec.js for + * more supported examples. + * + * @param {Float} number + * @returns {Float|Number} + */ +export const roundToNearestHalf = (num) => Math.round(num * 2).toFixed() / 2; + +/** * Method to round down values with decimal places * with provided precision. * diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 145b419f8f0..a509828815a 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -4,8 +4,6 @@ import { isString, mapValues, isNumber, reduce } from 'lodash'; import * as timeago from 'timeago.js'; import { languageCode, s__, __, n__ } from '../../locale'; -const MILLISECONDS_IN_HOUR = 60 * 60 * 1000; -const MILLISECONDS_IN_DAY = 24 * MILLISECONDS_IN_HOUR; const DAYS_IN_WEEK = 7; window.timeago = timeago; @@ -256,6 +254,37 @@ export const timeIntervalInWords = (intervalInSeconds) => { : secondsText; }; +/** + * Similar to `timeIntervalInWords`, but rounds the return value + * to 1/10th of the largest time unit. For example: + * + * 30 => 30 seconds + * 90 => 1.5 minutes + * 7200 => 2 hours + * 86400 => 1 day + * ... etc. + * + * The largest supported unit is "days". + * + * @param {Number} intervalInSeconds The time interval in seconds + * @returns {String} A humanized description of the time interval + */ +export const humanizeTimeInterval = (intervalInSeconds) => { + if (intervalInSeconds < 60 /* = 1 minute */) { + const seconds = Math.round(intervalInSeconds * 10) / 10; + return n__('%d second', '%d seconds', seconds); + } else if (intervalInSeconds < 3600 /* = 1 hour */) { + const minutes = Math.round(intervalInSeconds / 6) / 10; + return n__('%d minute', '%d minutes', minutes); + } else if (intervalInSeconds < 86400 /* = 1 day */) { + const hours = Math.round(intervalInSeconds / 360) / 10; + return n__('%d hour', '%d hours', hours); + } + + const days = Math.round(intervalInSeconds / 8640) / 10; + return n__('%d day', '%d days', days); +}; + export const dateInWords = (date, abbreviated = false, hideYear = false) => { if (!date) return date; @@ -947,49 +976,6 @@ export const format24HourTimeStringFromInt = (time) => { }; /** - * A utility function which checks if two date ranges overlap. - * - * @param {Object} givenPeriodLeft - the first period to compare. - * @param {Object} givenPeriodRight - the second period to compare. - * @returns {Object} { daysOverlap: number of days the overlap is present, hoursOverlap: number of hours the overlap is present, overlapStartDate: the start date of the overlap in time format, overlapEndDate: the end date of the overlap in time format } - * @throws {Error} Uncaught Error: Invalid period - * - * @example - * getOverlapDateInPeriods( - * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 13) }, - * { start: new Date(2021, 0, 11), end: new Date(2021, 0, 14) } - * ) => { daysOverlap: 2, hoursOverlap: 48, overlapStartDate: 1610323200000, overlapEndDate: 1610496000000 } - * - */ -export const getOverlapDateInPeriods = (givenPeriodLeft = {}, givenPeriodRight = {}) => { - const leftStartTime = new Date(givenPeriodLeft.start).getTime(); - const leftEndTime = new Date(givenPeriodLeft.end).getTime(); - const rightStartTime = new Date(givenPeriodRight.start).getTime(); - const rightEndTime = new Date(givenPeriodRight.end).getTime(); - - if (!(leftStartTime <= leftEndTime && rightStartTime <= rightEndTime)) { - throw new Error(__('Invalid period')); - } - - const isOverlapping = leftStartTime < rightEndTime && rightStartTime < leftEndTime; - - if (!isOverlapping) { - return { daysOverlap: 0 }; - } - - const overlapStartDate = Math.max(leftStartTime, rightStartTime); - const overlapEndDate = rightEndTime > leftEndTime ? leftEndTime : rightEndTime; - const differenceInMs = overlapEndDate - overlapStartDate; - - return { - hoursOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_HOUR), - daysOverlap: Math.ceil(differenceInMs / MILLISECONDS_IN_DAY), - overlapStartDate, - overlapEndDate, - }; -}; - -/** * A utility function that checks that the date is today * * @param {Date} date diff --git a/app/assets/javascripts/lib/utils/forms.js b/app/assets/javascripts/lib/utils/forms.js index 52e1323412d..b58aef15dda 100644 --- a/app/assets/javascripts/lib/utils/forms.js +++ b/app/assets/javascripts/lib/utils/forms.js @@ -1,3 +1,5 @@ +import { convertToCamelCase } from '~/lib/utils/text_utility'; + export const serializeFormEntries = (entries) => entries.reduce((acc, { name, value }) => Object.assign(acc, { [name]: value }), {}); @@ -51,3 +53,95 @@ export const serializeFormObject = (form) => return acc; }, []), ); + +/** + * Parse inputs of HTML forms generated by Rails. + * + * This can be helpful when mounting Vue components within Rails forms. + * + * If called with an HTML element like: + * + * ```html + * <input type="text" placeholder="Email" value="foo@bar.com" name="user[contact_info][email]" id="user_contact_info_email" data-js-name="contactInfoEmail"> + * <input type="text" placeholder="Phone" value="(123) 456-7890" name="user[contact_info][phone]" id="user_contact_info_phone" data-js-name="contactInfoPhone"> + * <input type="checkbox" name="user[interests][]" id="user_interests_vue" value="Vue" checked data-js-name="interests"> + * <input type="checkbox" name="user[interests][]" id="user_interests_graphql" value="GraphQL" data-js-name="interests"> + * ``` + * + * It will return an object like: + * + * ```javascript + * { + * contactInfoEmail: { + * name: 'user[contact_info][email]', + * id: 'user_contact_info_email', + * value: 'foo@bar.com', + * placeholder: 'Email', + * }, + * contactInfoPhone: { + * name: 'user[contact_info][phone]', + * id: 'user_contact_info_phone', + * value: '(123) 456-7890', + * placeholder: 'Phone', + * }, + * interests: [ + * { + * name: 'user[interests][]', + * id: 'user_interests_vue', + * value: 'Vue', + * checked: true, + * }, + * { + * name: 'user[interests][]', + * id: 'user_interests_graphql', + * value: 'GraphQL', + * checked: false, + * }, + * ], + * } + * ``` + * + * @param {HTMLInputElement} mountEl + * @returns {Object} object with form fields data. + */ +export const parseRailsFormFields = (mountEl) => { + if (!mountEl) { + throw new TypeError('`mountEl` argument is required'); + } + + const inputs = mountEl.querySelectorAll('[name]'); + + return [...inputs].reduce((accumulator, input) => { + const fieldName = input.dataset.jsName; + + if (!fieldName) { + return accumulator; + } + + const fieldNameCamelCase = convertToCamelCase(fieldName); + const { id, placeholder, name, value, type, checked } = input; + const attributes = { + name, + id, + value, + ...(placeholder && { placeholder }), + }; + + // Store radio buttons and checkboxes as an array so they can be + // looped through and rendered in Vue + if (['radio', 'checkbox'].includes(type)) { + return { + ...accumulator, + [fieldNameCamelCase]: [ + ...(accumulator[fieldNameCamelCase] || []), + { ...attributes, checked }, + ], + }; + } + + return { + ...accumulator, + [fieldNameCamelCase]: attributes, + }; + }, {}); +}; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 345dfaf895b..1593a363dd1 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -232,7 +232,7 @@ export function insertMarkdownText({ .join('\n'); } } else if (tag.indexOf(textPlaceholder) > -1) { - textToInsert = tag.replace(textPlaceholder, selected); + textToInsert = tag.replace(textPlaceholder, selected.replace(/\\n/g, '\n')); } else { textToInsert = String(startChar) + tag + selected + (wrap ? tag : ''); } @@ -322,7 +322,7 @@ export function updateTextForToolbarBtn($toolbarBtn) { blockTag: $toolbarBtn.data('mdBlock'), wrap: !$toolbarBtn.data('mdPrepend'), select: $toolbarBtn.data('mdSelect'), - tagContent: $toolbarBtn.data('mdTagContent'), + tagContent: $toolbarBtn.attr('data-md-tag-content'), }); } diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js index 07a4d2deb0b..a88f1bd82fc 100644 --- a/app/assets/javascripts/lib/utils/webpack.js +++ b/app/assets/javascripts/lib/utils/webpack.js @@ -11,10 +11,4 @@ export function resetServiceWorkersPublicPath() { const relativeRootPath = (gon && gon.relative_url_root) || ''; const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/'); __webpack_public_path__ = webpackAssetPath; // eslint-disable-line babel/camelcase - - // monaco-editor-webpack-plugin currently (incorrectly) references the - // public path as a property of `window`. Once this is fixed upstream we - // can remove this line - // see: https://github.com/Microsoft/monaco-editor-webpack-plugin/pull/63 - window.__webpack_public_path__ = webpackAssetPath; // eslint-disable-line } diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 417baddc031..3f22bd36a4a 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -16,21 +16,16 @@ import { initRails } from '~/lib/utils/rails_ujs'; import * as popovers from '~/popovers'; import * as tooltips from '~/tooltips'; import initAlertHandler from './alert_handler'; -import { deprecatedCreateFlash as Flash, removeFlashClickListener } from './flash'; +import { removeFlashClickListener } from './flash'; import initTodoToggle from './header'; import initLayoutNav from './layout_nav'; -import { - handleLocationHash, - addSelectOnFocusBehaviour, - getCspNonceValue, -} from './lib/utils/common_utils'; +import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; import { localTimeAgo } from './lib/utils/datetime_utility'; import { getLocationHash, visitUrl } from './lib/utils/url_utility'; // everything else import initFeatureHighlight from './feature_highlight'; import LazyLoader from './lazy_loader'; -import { __ } from './locale'; import initLogoAnimation from './logo'; import initFrequentItemDropdowns from './frequent_items'; import initBreadcrumbs from './breadcrumb'; @@ -49,29 +44,8 @@ applyGitLabUIConfig(); window.jQuery = jQuery; window.$ = jQuery; -// Add nonce to jQuery script handler -jQuery.ajaxSetup({ - converters: { - // eslint-disable-next-line @gitlab/require-i18n-strings, func-names - 'text script': function (text) { - jQuery.globalEval(text, { nonce: getCspNonceValue() }); - return text; - }, - }, -}); - -function disableJQueryAnimations() { - $.fx.off = true; -} - -// Disable jQuery animations -if (gon?.disable_animations) { - disableJQueryAnimations(); -} - // inject test utilities if necessary if (process.env.NODE_ENV !== 'production' && gon?.test_env) { - disableJQueryAnimations(); import(/* webpackMode: "eager" */ './test_utils/'); } @@ -135,20 +109,6 @@ function deferredInitialisation() { addSelectOnFocusBehaviour('.js-select-on-focus'); - $('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() { - tooltips.dispose(this); - - $(this).closest('li').addClass('gl-display-none!'); - }); - - $('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() { - $(this).hide(); - }); - - $('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() { - $(this).closest('tr').addClass('gl-display-none!'); - }); - const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; @@ -239,17 +199,6 @@ document.addEventListener('DOMContentLoaded', () => { } }); - // eslint-disable-next-line no-jquery/no-ajax-events - $(document).ajaxError((e, xhrObj) => { - const ref = xhrObj.status; - - if (ref === 401) { - Flash(__('You need to be logged in.')); - } else if (ref === 404 || ref === 500) { - Flash(__('Something went wrong on our end.')); - } - }); - $('.navbar-toggler').on('click', () => { $('.header-content').toggleClass('menu-expanded'); }); diff --git a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue index 83f266779f2..00973100e15 100644 --- a/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue @@ -12,6 +12,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: ['namespace'], props: { memberId: { type: Number, @@ -19,7 +20,11 @@ export default { }, }, computed: { - ...mapState(['memberPath']), + ...mapState({ + memberPath(state) { + return state[this.namespace].memberPath; + }, + }), approvePath() { return this.memberPath.replace(/:id$/, `${this.memberId}/approve_access_request`); }, diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue index 0bcc85157f1..91062c222f4 100644 --- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue @@ -42,6 +42,7 @@ export default { :member-id="member.id" :message="message" :title="s__('Member|Revoke invite')" + is-invite /> </div> </action-button-group> diff --git a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue index 3b87c29c1bc..fef7940eaa2 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue @@ -12,6 +12,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: ['namespace'], props: { groupLink: { type: Object, @@ -19,7 +20,11 @@ export default { }, }, methods: { - ...mapActions(['showRemoveGroupLinkModal']), + ...mapActions({ + showRemoveGroupLinkModal(dispatch, payload) { + return dispatch(`${this.namespace}/showRemoveGroupLinkModal`, payload); + }, + }), }, }; </script> diff --git a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index cb71be39ebc..a477aedd233 100644 --- a/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue @@ -8,11 +8,17 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: ['namespace'], props: { memberId: { type: Number, required: true, }, + memberType: { + type: String, + required: false, + default: null, + }, message: { type: String, required: true, @@ -31,12 +37,29 @@ export default { required: false, default: false, }, + isInvite: { + type: Boolean, + required: false, + default: false, + }, + oncallSchedules: { + type: Object, + required: false, + default: () => {}, + }, }, computed: { - ...mapState(['memberPath']), + ...mapState({ + memberPath(state) { + return state[this.namespace].memberPath; + }, + }), computedMemberPath() { return this.memberPath.replace(':id', this.memberId); }, + stringifiedSchedules() { + return JSON.stringify(this.oncallSchedules); + }, }, }; </script> @@ -50,8 +73,11 @@ export default { :aria-label="title" :icon="icon" :data-member-path="computedMemberPath" + :data-member-type="memberType" :data-is-access-request="isAccessRequest" + :data-is-invite="isInvite" :data-message="message" + :data-oncall-schedules="stringifiedSchedules" data-qa-selector="delete_member_button" /> </template> diff --git a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue index 261a6279920..2173974c6f4 100644 --- a/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue @@ -12,6 +12,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: ['namespace'], props: { memberId: { type: Number, @@ -19,7 +20,11 @@ export default { }, }, computed: { - ...mapState(['memberPath']), + ...mapState({ + memberPath(state) { + return state[this.namespace].memberPath; + }, + }), resendPath() { return this.memberPath.replace(/:id$/, `${this.memberId}/resend_invite`); }, diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index f779d1755a5..1e9f79927ea 100644 --- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -33,7 +33,7 @@ export default { if (user) { return sprintf( - s__('Members|Are you sure you want to remove %{usersName} from "%{source}"'), + s__('Members|Are you sure you want to remove %{usersName} from "%{source}"?'), { usersName: user.name, source: source.fullName, @@ -42,12 +42,16 @@ export default { } return sprintf( - s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'), + s__('Members|Are you sure you want to remove this orphaned member from "%{source}"?'), { source: source.fullName, }, ); }, + oncallScheduleUserData() { + const { user: { name, oncallSchedules: schedules } = {} } = this.member; + return { name, schedules }; + }, }, }; </script> @@ -59,6 +63,8 @@ export default { <remove-member-button v-else :member-id="member.id" + :member-type="member.type" + :oncall-schedules="oncallScheduleUserData" :message="message" :title="s__('Member|Remove member')" /> diff --git a/app/assets/javascripts/members/components/app.vue b/app/assets/javascripts/members/components/app.vue index 27fceb7374e..585fabdf3ff 100644 --- a/app/assets/javascripts/members/components/app.vue +++ b/app/assets/javascripts/members/components/app.vue @@ -9,8 +9,16 @@ import MembersTable from './table/members_table.vue'; export default { name: 'MembersApp', components: { MembersTable, FilterSortContainer, GlAlert }, + inject: ['namespace'], computed: { - ...mapState(['showError', 'errorMessage']), + ...mapState({ + showError(state) { + return state[this.namespace].showError; + }, + errorMessage(state) { + return state[this.namespace].errorMessage; + }, + }), }, watch: { showError(value) { @@ -23,7 +31,9 @@ export default { }, methods: { ...mapMutations({ - hideError: HIDE_ERROR, + hideError(commit) { + return commit(`${this.namespace}/${HIDE_ERROR}`); + }, }), }, }; diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue index 658fb43cecb..9687eacb036 100644 --- a/app/assets/javascripts/members/components/avatars/user_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue @@ -5,7 +5,6 @@ import { GlBadge, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; -import { mapState } from 'vuex'; import { generateBadges } from 'ee_else_ce/members/utils'; import { glEmojiTag } from '~/emoji'; import { __ } from '~/locale'; @@ -24,6 +23,7 @@ export default { directives: { SafeHtml, }, + inject: ['canManageMembers'], props: { member: { type: Object, @@ -35,7 +35,6 @@ export default { }, }, computed: { - ...mapState(['canManageMembers']), user() { return this.member.user; }, diff --git a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue index 812a8626949..419b7b83c0f 100644 --- a/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue +++ b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue @@ -6,8 +6,16 @@ import SortDropdown from './sort_dropdown.vue'; export default { name: 'FilterSortContainer', components: { MembersFilteredSearchBar, SortDropdown }, + inject: ['namespace'], computed: { - ...mapState(['filteredSearchBar', 'tableSortableFields']), + ...mapState({ + filteredSearchBar(state) { + return state[this.namespace].filteredSearchBar; + }, + tableSortableFields(state) { + return state[this.namespace].tableSortableFields; + }, + }), showContainer() { return this.filteredSearchBar.show || this.showSortDropdown; }, 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 039ee9a0207..cc97d235a9c 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 @@ -37,13 +37,18 @@ export default { ], }, ], + inject: ['namespace', 'sourceId', 'canManageMembers'], data() { return { initialFilterValue: [], }; }, computed: { - ...mapState(['sourceId', 'filteredSearchBar', 'canManageMembers']), + ...mapState({ + filteredSearchBar(state) { + return state[this.namespace].filteredSearchBar; + }, + }), tokens() { return this.$options.availableTokens.filter((token) => { if ( diff --git a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue index 9fa8772faf4..ce28283ccdf 100644 --- a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue +++ b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue @@ -8,8 +8,16 @@ import { parseSortParam, buildSortHref } from '~/members/utils'; export default { name: 'SortDropdown', components: { GlSorting, GlSortingItem }, + inject: ['namespace'], computed: { - ...mapState(['tableSortableFields', 'filteredSearchBar']), + ...mapState({ + tableSortableFields(state) { + return state[this.namespace].tableSortableFields; + }, + filteredSearchBar(state) { + return state[this.namespace].filteredSearchBar; + }, + }), sort() { return parseSortParam(this.tableSortableFields); }, diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue index a0f978d85cc..44178981136 100644 --- a/app/assets/javascripts/members/components/modals/leave_modal.vue +++ b/app/assets/javascripts/members/components/modals/leave_modal.vue @@ -3,6 +3,7 @@ import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { mapState } from 'vuex'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; +import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; import { LEAVE_MODAL_ID } from '../../constants'; export default { @@ -19,10 +20,11 @@ export default { csrf, modalId: LEAVE_MODAL_ID, modalContent: s__('Members|Are you sure you want to leave "%{source}"?'), - components: { GlModal, GlForm, GlSprintf }, + components: { GlModal, GlForm, GlSprintf, OncallSchedulesList }, directives: { GlTooltip: GlTooltipDirective, }, + inject: ['namespace'], props: { member: { type: Object, @@ -30,13 +32,23 @@ export default { }, }, computed: { - ...mapState(['memberPath']), + ...mapState({ + memberPath(state) { + return state[this.namespace].memberPath; + }, + }), leavePath() { return this.memberPath.replace(/:id$/, 'leave'); }, modalTitle() { return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName }); }, + schedules() { + return this.member.user?.oncallSchedules; + }, + isPartOfOnCallSchedules() { + return this.schedules?.length; + }, }, methods: { handlePrimary() { @@ -53,7 +65,6 @@ export default { :title="modalTitle" :action-primary="$options.actionPrimary" :action-cancel="$options.actionCancel" - size="sm" @primary="handlePrimary" > <gl-form ref="form" :action="leavePath" method="post"> @@ -63,6 +74,12 @@ export default { </gl-sprintf> </p> + <oncall-schedules-list + v-if="isPartOfOnCallSchedules" + :schedules="schedules" + :is-current-user="true" + /> + <input type="hidden" name="_method" value="delete" /> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> </gl-form> diff --git a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue index 1ba6bf9aba6..b179ced46e1 100644 --- a/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue @@ -22,8 +22,19 @@ export default { }, modalId: REMOVE_GROUP_LINK_MODAL_ID, components: { GlModal, GlSprintf, GlForm }, + inject: ['namespace'], computed: { - ...mapState(['memberPath', 'groupLinkToRemove', 'removeGroupLinkModalVisible']), + ...mapState({ + memberPath(state) { + return state[this.namespace].memberPath; + }, + groupLinkToRemove(state) { + return state[this.namespace].groupLinkToRemove; + }, + removeGroupLinkModalVisible(state) { + return state[this.namespace].removeGroupLinkModalVisible; + }, + }), groupLinkPath() { return this.memberPath.replace(/:id$/, this.groupLinkToRemove?.id); }, @@ -35,7 +46,11 @@ export default { }, }, methods: { - ...mapActions(['hideRemoveGroupLinkModal']), + ...mapActions({ + hideRemoveGroupLinkModal(dispatch) { + return dispatch(`${this.namespace}/hideRemoveGroupLinkModal`); + }, + }), handlePrimary() { this.$refs.form.$el.submit(); }, diff --git a/app/assets/javascripts/members/components/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue index 0a8af81c1d1..9f6e8979102 100644 --- a/app/assets/javascripts/members/components/table/expiration_datepicker.vue +++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue @@ -7,6 +7,7 @@ import { s__ } from '~/locale'; export default { name: 'ExpirationDatepicker', components: { GlDatepicker }, + inject: ['namespace'], props: { member: { type: Object, @@ -46,7 +47,11 @@ export default { } }, methods: { - ...mapActions(['updateMemberExpiration']), + ...mapActions({ + updateMemberExpiration(dispatch, payload) { + return dispatch(`${this.namespace}/updateMemberExpiration`, payload); + }, + }), handleInput(date) { this.busy = true; this.updateMemberExpiration({ diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 9a3edff19ff..236aeaef418 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -31,8 +31,19 @@ export default { LdapOverrideConfirmationModal: () => import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), }, + inject: ['namespace', 'currentUserId'], computed: { - ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId']), + ...mapState({ + members(state) { + return state[this.namespace].members; + }, + tableFields(state) { + return state[this.namespace].tableFields; + }, + tableAttrs(state) { + return state[this.namespace].tableAttrs; + }, + }), filteredFields() { return FIELDS.filter( (field) => this.tableFields.includes(field.key) && this.showField(field), diff --git a/app/assets/javascripts/members/components/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue index 1f537740f94..3436bcab2fc 100644 --- a/app/assets/javascripts/members/components/table/members_table_cell.vue +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -1,5 +1,4 @@ <script> -import { mapState } from 'vuex'; import { MEMBER_TYPES } from '../../constants'; import { isGroup, @@ -12,6 +11,7 @@ import { export default { name: 'MembersTableCell', + inject: ['currentUserId'], props: { member: { type: Object, @@ -19,7 +19,6 @@ export default { }, }, computed: { - ...mapState(['currentUserId']), isGroup() { return isGroup(this.member); }, diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index 8ad45ab6920..f84ded427cd 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -11,6 +11,7 @@ export default { GlDropdownItem, LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'), }, + inject: ['namespace'], props: { member: { type: Object, @@ -44,7 +45,11 @@ export default { } }, methods: { - ...mapActions(['updateMemberRole']), + ...mapActions({ + updateMemberRole(dispatch, payload) { + return dispatch(`${this.namespace}/updateMemberRole`, payload); + }, + }), handleSelect(value, name) { if (value === this.member.accessLevel.integerValue) { return; diff --git a/app/assets/javascripts/members/index.js b/app/assets/javascripts/members/index.js index fe174d9beb6..6376b3fa75a 100644 --- a/app/assets/javascripts/members/index.js +++ b/app/assets/javascripts/members/index.js @@ -8,6 +8,7 @@ import membersStore from './store'; export const initMembersApp = ( el, { + namespace, tableFields = [], tableAttrs = {}, tableSortableFields = [], @@ -22,22 +23,31 @@ export const initMembersApp = ( Vue.use(Vuex); Vue.use(GlToast); - const store = new Vuex.Store( - membersStore({ - ...parseDataAttributes(el), - currentUserId: gon.current_user_id || null, - tableFields, - tableAttrs, - tableSortableFields, - requestFormatter, - filteredSearchBar, - }), - ); + const { sourceId, canManageMembers, ...vuexStoreAttributes } = parseDataAttributes(el); + + const store = new Vuex.Store({ + modules: { + [namespace]: membersStore({ + ...vuexStoreAttributes, + tableFields, + tableAttrs, + tableSortableFields, + requestFormatter, + filteredSearchBar, + }), + }, + }); return new Vue({ el, components: { App }, store, + provide: { + namespace, + currentUserId: gon.current_user_id || null, + sourceId, + canManageMembers, + }, render: (createElement) => createElement('app'), }); }; diff --git a/app/assets/javascripts/members/store/index.js b/app/assets/javascripts/members/store/index.js index 45f4eefffc9..6c371887a3f 100644 --- a/app/assets/javascripts/members/store/index.js +++ b/app/assets/javascripts/members/store/index.js @@ -3,6 +3,7 @@ import mutations from 'ee_else_ce/members/store/mutations'; import createState from 'ee_else_ce/members/store/state'; export default (initialState) => ({ + namespaced: true, state: createState(initialState), actions, mutations, diff --git a/app/assets/javascripts/members/store/state.js b/app/assets/javascripts/members/store/state.js index 23a7983adcc..4006b4b501d 100644 --- a/app/assets/javascripts/members/store/state.js +++ b/app/assets/javascripts/members/store/state.js @@ -1,8 +1,5 @@ export default ({ members, - sourceId, - currentUserId, - canManageMembers, tableFields, tableAttrs, tableSortableFields, @@ -11,9 +8,6 @@ export default ({ filteredSearchBar, }) => ({ members, - sourceId, - currentUserId, - canManageMembers, tableFields, tableAttrs, tableSortableFields, diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue index 2c7c8038af5..7649c363daa 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue @@ -1,8 +1,10 @@ <script> import { debounce } from 'lodash'; +import { mapActions } from 'vuex'; import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; +import { INTERACTIVE_RESOLVE_MODE } from '../constants'; export default { props: { @@ -10,14 +12,6 @@ export default { type: Object, required: true, }, - onCancelDiscardConfirmation: { - type: Function, - required: true, - }, - onAcceptDiscardConfirmation: { - type: Function, - required: true, - }, }, data() { return { @@ -50,6 +44,7 @@ export default { } }, methods: { + ...mapActions(['setFileResolveMode', 'setPromptConfirmationState', 'updateFile']), loadEditor() { const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite'); const DataPromise = axios.get(this.file.content_path); @@ -82,23 +77,24 @@ export default { saveDiffResolution() { this.saved = true; - // This probably be better placed in the data provider - /* eslint-disable vue/no-mutating-props */ - this.file.content = this.editor.getValue(); - this.file.resolveEditChanged = this.file.content !== this.originalContent; - this.file.promptDiscardConfirmation = false; - /* eslint-enable vue/no-mutating-props */ + this.updateFile({ + ...this.file, + content: this.editor.getValue(), + resolveEditChanged: this.file.content !== this.originalContent, + promptDiscardConfirmation: false, + }); }, resetEditorContent() { if (this.fileLoaded) { this.editor.setValue(this.originalContent); } }, - cancelDiscardConfirmation(file) { - this.onCancelDiscardConfirmation(file); - }, acceptDiscardConfirmation(file) { - this.onAcceptDiscardConfirmation(file); + this.setPromptConfirmationState({ file, promptDiscardConfirmation: false }); + this.setFileResolveMode({ file, mode: INTERACTIVE_RESOLVE_MODE }); + }, + cancelDiscardConfirmation(file) { + this.setPromptConfirmationState({ file, promptDiscardConfirmation: false }); }, }, }; 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 519fd53af1e..9721481e6be 100644 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue @@ -1,34 +1,41 @@ <script> import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import actionsMixin from '../mixins/line_conflict_actions'; +import { mapActions } from 'vuex'; +import syntaxHighlight from '~/syntax_highlight'; +import { SYNTAX_HIGHLIGHT_CLASS } from '../constants'; import utilsMixin from '../mixins/line_conflict_utils'; export default { directives: { SafeHtml, }, - mixins: [utilsMixin, actionsMixin], + mixins: [utilsMixin], + SYNTAX_HIGHLIGHT_CLASS, props: { file: { type: Object, required: true, }, }, + mounted() { + syntaxHighlight(document.querySelectorAll(`.${SYNTAX_HIGHLIGHT_CLASS}`)); + }, + methods: { + ...mapActions(['handleSelected']), + }, }; </script> <template> - <table class="diff-wrap-lines code code-commit js-syntax-highlight"> - <tr - v-for="line in file.inlineLines" - :key="(line.isHeader ? line.id : line.new_line) + line.richText" - class="line_holder diff-inline" - > + <table :class="['diff-wrap-lines code code-commit', $options.SYNTAX_HIGHLIGHT_CLASS]"> + <!-- Unfortunately there isn't a good key for these sections --> + <!-- eslint-disable vue/require-v-for-key --> + <tr v-for="line in file.inlineLines" class="line_holder diff-inline"> <template v-if="line.isHeader"> <td :class="lineCssClass(line)" class="diff-line-num header"></td> <td :class="lineCssClass(line)" class="diff-line-num header"></td> <td :class="lineCssClass(line)" class="line_content header"> <strong>{{ line.richText }}</strong> - <button class="btn" @click="handleSelected(file, line.id, line.section)"> + <button class="btn" @click="handleSelected({ file, line })"> {{ line.buttonTitle }} </button> </td> 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 e66f641f70d..7b1d947ccff 100644 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue @@ -1,32 +1,41 @@ <script> import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; -import actionsMixin from '../mixins/line_conflict_actions'; +import { mapActions } from 'vuex'; +import syntaxHighlight from '~/syntax_highlight'; +import { SYNTAX_HIGHLIGHT_CLASS } from '../constants'; import utilsMixin from '../mixins/line_conflict_utils'; export default { directives: { SafeHtml, }, - mixins: [utilsMixin, actionsMixin], + mixins: [utilsMixin], + SYNTAX_HIGHLIGHT_CLASS, props: { file: { type: Object, required: true, }, }, + mounted() { + syntaxHighlight(document.querySelectorAll(`.${SYNTAX_HIGHLIGHT_CLASS}`)); + }, + methods: { + ...mapActions(['handleSelected']), + }, }; </script> <template> <!-- Unfortunately there isn't a good key for these sections --> <!-- eslint-disable vue/require-v-for-key --> - <table class="diff-wrap-lines code js-syntax-highlight"> + <table :class="['diff-wrap-lines code', $options.SYNTAX_HIGHLIGHT_CLASS]"> <tr v-for="section in file.parallelLines" class="line_holder parallel"> <template v-for="line in section"> <template v-if="line.isHeader"> <td class="diff-line-num header" :class="lineCssClass(line)"></td> <td class="line_content header" :class="lineCssClass(line)"> <strong>{{ line.richText }}</strong> - <button class="btn" @click="handleSelected(file, line.id, line.section)"> + <button class="btn" @click="handleSelected({ file, line })"> {{ line.buttonTitle }} </button> </td> diff --git a/app/assets/javascripts/merge_conflicts/constants.js b/app/assets/javascripts/merge_conflicts/constants.js index 6f3ee339e36..dddcc891e81 100644 --- a/app/assets/javascripts/merge_conflicts/constants.js +++ b/app/assets/javascripts/merge_conflicts/constants.js @@ -13,6 +13,7 @@ export const VIEW_TYPES = { export const EDIT_RESOLVE_MODE = 'edit'; export const INTERACTIVE_RESOLVE_MODE = 'interactive'; export const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE; +export const SYNTAX_HIGHLIGHT_CLASS = 'js-syntax-highlight'; export const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes'); export const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes'); diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue index 16a7cfb2ba8..0509cf0afa1 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue @@ -1,14 +1,15 @@ <script> import { GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState, mapActions } from 'vuex'; import { __ } from '~/locale'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import DiffFileEditor from './components/diff_file_editor.vue'; import InlineConflictLines from './components/inline_conflict_lines.vue'; import ParallelConflictLines from './components/parallel_conflict_lines.vue'; +import { INTERACTIVE_RESOLVE_MODE } from './constants'; /** - * NOTE: Most of this component is directly using $root, rather than props or a better data store. - * This is BAD and one shouldn't copy that behavior. Similarly a lot of the classes below should + * A lot of the classes below should * be replaced with GitLab UI components. * * We are just doing it temporarily in order to migrate the template from HAML => Vue in an iterative manner @@ -25,60 +26,88 @@ export default { InlineConflictLines, ParallelConflictLines, }, - inject: ['mergeRequestPath', 'sourceBranchPath'], + inject: ['mergeRequestPath', 'sourceBranchPath', 'resolveConflictsPath'], i18n: { commitStatSummary: __('Showing %{conflict} between %{sourceBranch} and %{targetBranch}'), resolveInfo: __( 'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}', ), }, + computed: { + ...mapGetters([ + 'getConflictsCountText', + 'isReadyToCommit', + 'getCommitButtonText', + 'fileTextTypePresent', + ]), + ...mapState(['isLoading', 'hasError', 'isParallel', 'conflictsData']), + commitMessage: { + get() { + return this.conflictsData.commitMessage; + }, + set(value) { + this.updateCommitMessage(value); + }, + }, + }, + methods: { + ...mapActions([ + 'setViewType', + 'submitResolvedConflicts', + 'setFileResolveMode', + 'setPromptConfirmationState', + 'updateCommitMessage', + ]), + onClickResolveModeButton(file, mode) { + if (mode === INTERACTIVE_RESOLVE_MODE && file.resolveEditChanged) { + this.setPromptConfirmationState({ file, promptDiscardConfirmation: true }); + } else { + this.setFileResolveMode({ file, mode }); + } + }, + }, }; </script> <template> <div id="conflicts"> - <div v-if="$root.isLoading" class="loading"> + <div v-if="isLoading" class="loading"> <div class="spinner spinner-md"></div> </div> - <div v-if="$root.hasError" class="nothing-here-block"> - {{ $root.conflictsData.errorMessage }} + <div v-if="hasError" class="nothing-here-block"> + {{ conflictsData.errorMessage }} </div> - <template v-if="!$root.isLoading && !$root.hasError"> + <template v-if="!isLoading && !hasError"> <div class="content-block oneline-block files-changed"> - <div v-if="$root.showDiffViewTypeSwitcher" class="inline-parallel-buttons"> + <div v-if="fileTextTypePresent" class="inline-parallel-buttons"> <div class="btn-group"> <button - :class="{ active: !$root.isParallel }" + :class="{ active: !isParallel }" class="btn gl-button" - @click="$root.handleViewTypeChange('inline')" + @click="setViewType('inline')" > {{ __('Inline') }} </button> <button - :class="{ active: $root.isParallel }" + :class="{ active: isParallel }" class="btn gl-button" - @click="$root.handleViewTypeChange('parallel')" + data-testid="side-by-side" + @click="setViewType('parallel')" > {{ __('Side-by-side') }} </button> </div> </div> <div class="js-toggle-container"> - <div class="commit-stat-summary"> + <div class="commit-stat-summary" data-testid="conflicts-count"> <gl-sprintf :message="$options.i18n.commitStatSummary"> <template #conflict> - <strong class="cred"> - {{ $root.conflictsCountText }} - </strong> + <strong class="cred">{{ getConflictsCountText }}</strong> </template> <template #sourceBranch> - <strong class="ref-name"> - {{ $root.conflictsData.sourceBranch }} - </strong> + <strong class="ref-name">{{ conflictsData.sourceBranch }}</strong> </template> <template #targetBranch> - <strong class="ref-name"> - {{ $root.conflictsData.targetBranch }} - </strong> + <strong class="ref-name">{{ conflictsData.targetBranch }}</strong> </template> </gl-sprintf> </div> @@ -87,12 +116,13 @@ export default { <div class="files-wrapper"> <div class="files"> <div - v-for="file in $root.conflictsData.files" + v-for="file in conflictsData.files" :key="file.blobPath" class="diff-file file-holder conflict" + data-testid="files" > <div class="js-file-title file-title file-title-flex-parent cursor-default"> - <div class="file-header-content"> + <div class="file-header-content" data-testid="file-name"> <file-icon :file-name="file.filePath" :size="18" css-classes="gl-mr-2" /> <strong class="file-title-name">{{ file.filePath }}</strong> </div> @@ -102,7 +132,8 @@ export default { :class="{ active: file.resolveMode === 'interactive' }" class="btn gl-button" type="button" - @click="$root.onClickResolveModeButton(file, 'interactive')" + data-testid="interactive-button" + @click="onClickResolveModeButton(file, 'interactive')" > {{ __('Interactive mode') }} </button> @@ -110,7 +141,8 @@ export default { :class="{ active: file.resolveMode === 'edit' }" class="btn gl-button" type="button" - @click="$root.onClickResolveModeButton(file, 'edit')" + data-testid="inline-button" + @click="onClickResolveModeButton(file, 'edit')" > {{ __('Edit inline') }} </button> @@ -118,35 +150,23 @@ export default { <a :href="file.blobPath" class="btn gl-button view-file"> <gl-sprintf :message="__('View file @ %{commitSha}')"> <template #commitSha> - {{ $root.conflictsData.shortCommitSha }} + {{ conflictsData.shortCommitSha }} </template> </gl-sprintf> </a> </div> </div> <div class="diff-content diff-wrap-lines"> - <div - v-show=" - !$root.isParallel && file.resolveMode === 'interactive' && file.type === 'text' - " - class="file-content" - > - <inline-conflict-lines :file="file" /> - </div> - <div - v-show=" - $root.isParallel && file.resolveMode === 'interactive' && file.type === 'text' - " - class="file-content" - > - <parallel-conflict-lines :file="file" /> - </div> - <div v-show="file.resolveMode === 'edit' || file.type === 'text-editor'"> - <diff-file-editor - :file="file" - :on-accept-discard-confirmation="$root.acceptDiscardConfirmation" - :on-cancel-discard-confirmation="$root.cancelDiscardConfirmation" - /> + <template v-if="file.resolveMode === 'interactive' && file.type === 'text'"> + <div v-if="!isParallel" class="file-content"> + <inline-conflict-lines :file="file" /> + </div> + <div v-if="isParallel" class="file-content"> + <parallel-conflict-lines :file="file" /> + </div> + </template> + <div v-if="file.resolveMode === 'edit' || file.type === 'text-editor'"> + <diff-file-editor :file="file" /> </div> </div> </div> @@ -169,7 +189,7 @@ export default { </template> <template #branch_name> <a class="ref-name" :href="sourceBranchPath"> - {{ $root.conflictsData.sourceBranch }} + {{ conflictsData.sourceBranch }} </a> </template> </gl-sprintf> @@ -183,7 +203,8 @@ export default { <div class="max-width-marker"></div> <textarea id="commit-message" - v-model="$root.conflictsData.commitMessage" + v-model="commitMessage" + data-testid="commit-message" class="form-control js-commit-message" rows="5" ></textarea> @@ -195,12 +216,12 @@ export default { <div class="row"> <div class="col-6"> <button - :disabled="!$root.readyToCommit" + :disabled="!isReadyToCommit" class="btn gl-button btn-success js-submit-button" type="button" - @click="$root.commit()" + @click="submitResolvedConflicts(resolveConflictsPath)" > - <span>{{ $root.commitButtonText }}</span> + <span>{{ getCommitButtonText }}</span> </button> </div> <div class="col-6 text-right"> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js b/app/assets/javascripts/merge_conflicts/merge_conflict_service.js deleted file mode 100644 index 64d69159222..00000000000 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_service.js +++ /dev/null @@ -1,16 +0,0 @@ -import axios from '../lib/utils/axios_utils'; - -export default class MergeConflictsService { - constructor(options) { - this.conflictsPath = options.conflictsPath; - this.resolveConflictsPath = options.resolveConflictsPath; - } - - fetchConflictsData() { - return axios.get(this.conflictsPath); - } - - submitResolveConflicts(data) { - return axios.post(this.resolveConflictsPath, data); - } -} diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js deleted file mode 100644 index fb3444262ea..00000000000 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js +++ /dev/null @@ -1,432 +0,0 @@ -/* eslint-disable no-param-reassign, babel/camelcase, no-nested-ternary, no-continue */ - -import $ from 'jquery'; -import Cookies from 'js-cookie'; -import Vue from 'vue'; -import { s__ } from '~/locale'; - -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - const diffViewType = Cookies.get('diff_view'); - const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes'); - const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes'); - const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours'); - const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs'); - const INTERACTIVE_RESOLVE_MODE = 'interactive'; - const EDIT_RESOLVE_MODE = 'edit'; - const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE; - const VIEW_TYPES = { - INLINE: 'inline', - PARALLEL: 'parallel', - }; - const CONFLICT_TYPES = { - TEXT: 'text', - TEXT_EDITOR: 'text-editor', - }; - - global.mergeConflicts.mergeConflictsStore = { - state: { - isLoading: true, - hasError: false, - isSubmitting: false, - isParallel: diffViewType === VIEW_TYPES.PARALLEL, - diffViewType, - conflictsData: {}, - }, - - setConflictsData(data) { - this.decorateFiles(data.files); - - this.state.conflictsData = { - files: data.files, - commitMessage: data.commit_message, - sourceBranch: data.source_branch, - targetBranch: data.target_branch, - shortCommitSha: data.commit_sha.slice(0, 7), - }; - }, - - decorateFiles(files) { - files.forEach((file) => { - file.content = ''; - file.resolutionData = {}; - file.promptDiscardConfirmation = false; - file.resolveMode = DEFAULT_RESOLVE_MODE; - file.filePath = this.getFilePath(file); - file.blobPath = file.blob_path; - - if (file.type === CONFLICT_TYPES.TEXT) { - file.showEditor = false; - file.loadEditor = false; - - this.setInlineLine(file); - this.setParallelLine(file); - } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) { - file.showEditor = true; - file.loadEditor = true; - } - }); - }, - - setInlineLine(file) { - file.inlineLines = []; - - file.sections.forEach((section) => { - let currentLineType = 'new'; - const { conflict, lines, id } = section; - - if (conflict) { - file.inlineLines.push(this.getHeadHeaderLine(id)); - } - - lines.forEach((line) => { - const { type } = line; - - if ((type === 'new' || type === 'old') && currentLineType !== type) { - currentLineType = type; - file.inlineLines.push({ lineType: 'emptyLine', richText: '' }); - } - - this.decorateLineForInlineView(line, id, conflict); - file.inlineLines.push(line); - }); - - if (conflict) { - file.inlineLines.push(this.getOriginHeaderLine(id)); - } - }); - }, - - setParallelLine(file) { - file.parallelLines = []; - const linesObj = { left: [], right: [] }; - - file.sections.forEach((section) => { - const { conflict, lines, id } = section; - - if (conflict) { - linesObj.left.push(this.getOriginHeaderLine(id)); - linesObj.right.push(this.getHeadHeaderLine(id)); - } - - lines.forEach((line) => { - const { type } = line; - - if (conflict) { - if (type === 'old') { - linesObj.left.push(this.getLineForParallelView(line, id, 'conflict')); - } else if (type === 'new') { - linesObj.right.push(this.getLineForParallelView(line, id, 'conflict', true)); - } - } else { - const lineType = type || 'context'; - - linesObj.left.push(this.getLineForParallelView(line, id, lineType)); - linesObj.right.push(this.getLineForParallelView(line, id, lineType, true)); - } - }); - - this.checkLineLengths(linesObj); - }); - - for (let i = 0, len = linesObj.left.length; i < len; i += 1) { - file.parallelLines.push([linesObj.right[i], linesObj.left[i]]); - } - }, - - setLoadingState(state) { - this.state.isLoading = state; - }, - - setErrorState(state) { - this.state.hasError = state; - }, - - setFailedRequest(message) { - this.state.hasError = true; - this.state.conflictsData.errorMessage = message; - }, - - getConflictsCount() { - if (!this.state.conflictsData.files.length) { - return 0; - } - - const { files } = this.state.conflictsData; - let count = 0; - - files.forEach((file) => { - if (file.type === CONFLICT_TYPES.TEXT) { - file.sections.forEach((section) => { - if (section.conflict) { - count += 1; - } - }); - } else { - count += 1; - } - }); - - return count; - }, - - getConflictsCountText() { - const count = this.getConflictsCount(); - const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict'); - - return `${count} ${text}`; - }, - - setViewType(viewType) { - this.state.diffView = viewType; - this.state.isParallel = viewType === VIEW_TYPES.PARALLEL; - - Cookies.set('diff_view', viewType); - }, - - getHeadHeaderLine(id) { - return { - id, - richText: HEAD_HEADER_TEXT, - buttonTitle: HEAD_BUTTON_TITLE, - type: 'new', - section: 'head', - isHeader: true, - isHead: true, - isSelected: false, - isUnselected: false, - }; - }, - - decorateLineForInlineView(line, id, conflict) { - const { type } = line; - line.id = id; - line.hasConflict = conflict; - line.isHead = type === 'new'; - line.isOrigin = type === 'old'; - line.hasMatch = type === 'match'; - line.richText = line.rich_text; - line.isSelected = false; - line.isUnselected = false; - }, - - getLineForParallelView(line, id, lineType, isHead) { - const { old_line, new_line, rich_text } = line; - const hasConflict = lineType === 'conflict'; - - return { - id, - lineType, - hasConflict, - isHead: hasConflict && isHead, - isOrigin: hasConflict && !isHead, - hasMatch: lineType === 'match', - lineNumber: isHead ? new_line : old_line, - section: isHead ? 'head' : 'origin', - richText: rich_text, - isSelected: false, - isUnselected: false, - }; - }, - - getOriginHeaderLine(id) { - return { - id, - richText: ORIGIN_HEADER_TEXT, - buttonTitle: ORIGIN_BUTTON_TITLE, - type: 'old', - section: 'origin', - isHeader: true, - isOrigin: true, - isSelected: false, - isUnselected: false, - }; - }, - - getFilePath(file) { - const { old_path, new_path } = file; - return old_path === new_path ? new_path : `${old_path} → ${new_path}`; - }, - - checkLineLengths(linesObj) { - const { left, right } = linesObj; - - if (left.length !== right.length) { - if (left.length > right.length) { - const diff = left.length - right.length; - for (let i = 0; i < diff; i += 1) { - right.push({ lineType: 'emptyLine', richText: '' }); - } - } else { - const diff = right.length - left.length; - for (let i = 0; i < diff; i += 1) { - left.push({ lineType: 'emptyLine', richText: '' }); - } - } - } - }, - - setPromptConfirmationState(file, state) { - file.promptDiscardConfirmation = state; - }, - - setFileResolveMode(file, mode) { - if (mode === INTERACTIVE_RESOLVE_MODE) { - file.showEditor = false; - } else if (mode === EDIT_RESOLVE_MODE) { - // Restore Interactive mode when switching to Edit mode - file.showEditor = true; - file.loadEditor = true; - file.resolutionData = {}; - - this.restoreFileLinesState(file); - } - - file.resolveMode = mode; - }, - - restoreFileLinesState(file) { - file.inlineLines.forEach((line) => { - if (line.hasConflict || line.isHeader) { - line.isSelected = false; - line.isUnselected = false; - } - }); - - file.parallelLines.forEach((lines) => { - const left = lines[0]; - const right = lines[1]; - const isLeftMatch = left.hasConflict || left.isHeader; - const isRightMatch = right.hasConflict || right.isHeader; - - if (isLeftMatch || isRightMatch) { - left.isSelected = false; - left.isUnselected = false; - right.isSelected = false; - right.isUnselected = false; - } - }); - }, - - isReadyToCommit() { - const { files } = this.state.conflictsData; - const hasCommitMessage = $.trim(this.state.conflictsData.commitMessage).length; - let unresolved = 0; - - for (let i = 0, l = files.length; i < l; i += 1) { - const file = files[i]; - - if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) { - let numberConflicts = 0; - const resolvedConflicts = Object.keys(file.resolutionData).length; - - // We only check for conflicts type 'text' - // since conflicts `text_editor` can´t be resolved in interactive mode - if (file.type === CONFLICT_TYPES.TEXT) { - for (let j = 0, k = file.sections.length; j < k; j += 1) { - if (file.sections[j].conflict) { - numberConflicts += 1; - } - } - - if (resolvedConflicts !== numberConflicts) { - unresolved += 1; - } - } - } else if (file.resolveMode === EDIT_RESOLVE_MODE) { - // Unlikely to happen since switching to Edit mode saves content automatically. - // Checking anyway in case the save strategy changes in the future - if (!file.content) { - unresolved += 1; - continue; - } - } - } - - return !this.state.isSubmitting && hasCommitMessage && !unresolved; - }, - - getCommitButtonText() { - const initial = s__('MergeConflict|Commit to source branch'); - const inProgress = s__('MergeConflict|Committing...'); - - return this.state ? (this.state.isSubmitting ? inProgress : initial) : initial; - }, - - getCommitData() { - let commitData = {}; - - commitData = { - commit_message: this.state.conflictsData.commitMessage, - files: [], - }; - - this.state.conflictsData.files.forEach((file) => { - const addFile = { - old_path: file.old_path, - new_path: file.new_path, - }; - - if (file.type === CONFLICT_TYPES.TEXT) { - // Submit only one data for type of editing - if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) { - addFile.sections = file.resolutionData; - } else if (file.resolveMode === EDIT_RESOLVE_MODE) { - addFile.content = file.content; - } - } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) { - addFile.content = file.content; - } - - commitData.files.push(addFile); - }); - - return commitData; - }, - - handleSelected(file, sectionId, selection) { - Vue.set(file.resolutionData, sectionId, selection); - - file.inlineLines.forEach((line) => { - if (line.id === sectionId && (line.hasConflict || line.isHeader)) { - this.markLine(line, selection); - } - }); - - file.parallelLines.forEach((lines) => { - const left = lines[0]; - const right = lines[1]; - const hasSameId = right.id === sectionId || left.id === sectionId; - const isLeftMatch = left.hasConflict || left.isHeader; - const isRightMatch = right.hasConflict || right.isHeader; - - if (hasSameId && (isLeftMatch || isRightMatch)) { - this.markLine(left, selection); - this.markLine(right, selection); - } - }); - }, - - markLine(line, selection) { - if (selection === 'head' && line.isHead) { - line.isSelected = true; - line.isUnselected = false; - } else if (selection === 'origin' && line.isOrigin) { - line.isSelected = true; - line.isUnselected = false; - } else { - line.isSelected = false; - line.isUnselected = true; - } - }, - - setSubmitState(state) { - this.state.isSubmitting = state; - }, - - fileTextTypePresent() { - return this.state.conflictsData.files.some((f) => f.type === CONFLICT_TYPES.TEXT); - }, - }; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index 4b73dd317cd..cf02c6fbd6b 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,100 +1,32 @@ -import $ from 'jquery'; import Vue from 'vue'; -import { __ } from '~/locale'; -import { deprecatedCreateFlash as createFlash } from '../flash'; import initIssuableSidebar from '../init_issuable_sidebar'; -import './merge_conflict_store'; -import syntaxHighlight from '../syntax_highlight'; import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue'; -import MergeConflictsService from './merge_conflict_service'; +import { createStore } from './store'; export default function initMergeConflicts() { - const INTERACTIVE_RESOLVE_MODE = 'interactive'; const conflictsEl = document.querySelector('#conflicts'); - const { mergeConflictsStore } = gl.mergeConflicts; - const mergeConflictsService = new MergeConflictsService({ - conflictsPath: conflictsEl.dataset.conflictsPath, - resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath, - }); - const { sourceBranchPath, mergeRequestPath } = conflictsEl.dataset; + const { + sourceBranchPath, + mergeRequestPath, + conflictsPath, + resolveConflictsPath, + } = conflictsEl.dataset; initIssuableSidebar(); + const store = createStore(); + return new Vue({ el: conflictsEl, + store, provide: { sourceBranchPath, mergeRequestPath, - }, - data: mergeConflictsStore.state, - computed: { - conflictsCountText() { - return mergeConflictsStore.getConflictsCountText(); - }, - readyToCommit() { - return mergeConflictsStore.isReadyToCommit(); - }, - commitButtonText() { - return mergeConflictsStore.getCommitButtonText(); - }, - showDiffViewTypeSwitcher() { - return mergeConflictsStore.fileTextTypePresent(); - }, + resolveConflictsPath, }, created() { - mergeConflictsService - .fetchConflictsData() - .then(({ data }) => { - if (data.type === 'error') { - mergeConflictsStore.setFailedRequest(data.message); - } else { - mergeConflictsStore.setConflictsData(data); - } - - mergeConflictsStore.setLoadingState(false); - - this.$nextTick(() => { - syntaxHighlight($('.js-syntax-highlight')); - }); - }) - .catch(() => { - mergeConflictsStore.setLoadingState(false); - mergeConflictsStore.setFailedRequest(); - }); - }, - methods: { - handleViewTypeChange(viewType) { - mergeConflictsStore.setViewType(viewType); - }, - onClickResolveModeButton(file, mode) { - if (mode === INTERACTIVE_RESOLVE_MODE && file.resolveEditChanged) { - mergeConflictsStore.setPromptConfirmationState(file, true); - return; - } - - mergeConflictsStore.setFileResolveMode(file, mode); - }, - acceptDiscardConfirmation(file) { - mergeConflictsStore.setPromptConfirmationState(file, false); - mergeConflictsStore.setFileResolveMode(file, INTERACTIVE_RESOLVE_MODE); - }, - cancelDiscardConfirmation(file) { - mergeConflictsStore.setPromptConfirmationState(file, false); - }, - commit() { - mergeConflictsStore.setSubmitState(true); - - mergeConflictsService - .submitResolveConflicts(mergeConflictsStore.getCommitData()) - .then(({ data }) => { - window.location.href = data.redirect_to; - }) - .catch(() => { - mergeConflictsStore.setSubmitState(false); - createFlash(__('Failed to save merge conflicts resolutions. Please try again!')); - }); - }, + store.dispatch('fetchConflictsData', conflictsPath); }, render(createElement) { return createElement(MergeConflictsResolverApp); diff --git a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js b/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js deleted file mode 100644 index 364ae2b2688..00000000000 --- a/app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js +++ /dev/null @@ -1,7 +0,0 @@ -export default { - methods: { - handleSelected(file, sectionId, selection) { - gl.mergeConflicts.mergeConflictsStore.handleSelected(file, sectionId, selection); - }, - }, -}; diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js index 8036e90c58c..df515c4ac1a 100644 --- a/app/assets/javascripts/merge_conflicts/store/actions.js +++ b/app/assets/javascripts/merge_conflicts/store/actions.js @@ -118,3 +118,8 @@ export const handleSelected = ({ commit, state, getters }, { file, line: { id, s commit(types.UPDATE_FILE, { file: updated, index }); }; + +export const updateFile = ({ commit, getters }, file) => { + const index = getters.getFileIndex(file); + commit(types.UPDATE_FILE, { file, index }); +}; diff --git a/app/assets/javascripts/merge_conflicts/store/getters.js b/app/assets/javascripts/merge_conflicts/store/getters.js index 03e425fb478..54f3d6ec4bc 100644 --- a/app/assets/javascripts/merge_conflicts/store/getters.js +++ b/app/assets/javascripts/merge_conflicts/store/getters.js @@ -67,7 +67,7 @@ export const isReadyToCommit = (state) => { } } - return !state.isSubmitting && hasCommitMessage && !unresolved; + return Boolean(!state.isSubmitting && hasCommitMessage && !unresolved); }; export const getCommitButtonText = (state) => { diff --git a/app/assets/javascripts/merge_request/components/status_box.vue b/app/assets/javascripts/merge_request/components/status_box.vue index 5d2660d65e6..526aafc1def 100644 --- a/app/assets/javascripts/merge_request/components/status_box.vue +++ b/app/assets/javascripts/merge_request/components/status_box.vue @@ -13,7 +13,7 @@ const CLASSES = { const STATUS = { opened: [__('Open'), 'issue-open-m'], locked: [__('Open'), 'issue-open-m'], - closed: [__('Closed'), 'close'], + closed: [__('Closed'), 'issue-close'], merged: [__('Merged'), 'git-merge'], }; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 81b9db6b4d5..67b24793a65 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -264,7 +264,7 @@ export default class MergeRequestTabs { } } - // Replaces the current Merge Request-specific action in the URL with a new one + // Replaces the current merge request-specific action in the URL with a new one // // If the action is "notes", the URL is reset to the standard // `MergeRequests#show` route. diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index f4b60fc0961..b992eaff779 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -11,7 +11,6 @@ import boardsStore, { boardStoreIssueSet, boardStoreIssueDelete, } from './boards/stores/boards_store'; -import ModalStore from './boards/stores/modal_store'; import axios from './lib/utils/axios_utils'; import { timeFor, parsePikadayDate, dateInWords } from './lib/utils/datetime_utility'; @@ -211,7 +210,7 @@ export default class MilestoneSelect { const { e } = clickEvent; let selected = clickEvent.selectedObj; - let data, modalStoreFilter; + let data; if (!selected) return; if (options.handleClick) { @@ -234,14 +233,7 @@ export default class MilestoneSelect { return; } - if ($dropdown.closest('.add-issues-modal').length) { - modalStoreFilter = ModalStore.store.filter; - } - - if (modalStoreFilter) { - modalStoreFilter[$dropdown.data('fieldName')] = selected.name; - e.preventDefault(); - } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { + if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { return Issuable.filterResults($dropdown.closest('form')); } else if ($dropdown.hasClass('js-filter-submit')) { return $dropdown.closest('form').submit(); diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js deleted file mode 100644 index 05f2f15fa9a..00000000000 --- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js +++ /dev/null @@ -1,113 +0,0 @@ -import $ from 'jquery'; -import { deprecatedCreateFlash as flash } from './flash'; -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; - -/** - * In each pipelines table we have a mini pipeline graph for each pipeline. - * - * When we click in a pipeline stage, we need to make an API call to get the - * builds list to render in a dropdown. - * - * The container should be the table element. - * - * The stage icon clicked needs to have the following HTML structure: - * <div class="dropdown"> - * <button class="dropdown js-builds-dropdown-button" data-toggle="dropdown"></button> - * <div class="js-builds-dropdown-container dropdown-menu"></div> - * </div> - */ - -export default class MiniPipelineGraph { - constructor(opts = {}) { - this.container = opts.container || ''; - this.dropdownListSelector = '.js-builds-dropdown-container'; - this.getBuildsList = this.getBuildsList.bind(this); - } - - /** - * Adds the event listener when the dropdown is opened. - * All dropdown events are fired at the .dropdown-menu's parent element. - */ - bindEvents() { - $(document) - .off('shown.bs.dropdown', this.container) - .on('shown.bs.dropdown', this.container, this.getBuildsList); - } - - /** - * When the user right clicks or cmd/ctrl + click in the job name - * the dropdown should not be closed and the link should open in another tab, - * so we stop propagation of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - */ - stopDropdownClickPropagation() { - $(document).on( - 'click', - `${this.container} .js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item`, - (e) => { - e.stopPropagation(); - }, - ); - } - - /** - * For the clicked stage, renders the given data in the dropdown list. - * - * @param {HTMLElement} stageContainer - * @param {Object} data - */ - renderBuildsList(stageContainer, data) { - const dropdownContainer = stageContainer.parentElement.querySelector( - `${this.dropdownListSelector} .js-builds-dropdown-list ul`, - ); - - dropdownContainer.innerHTML = data; - } - - /** - * For the clicked stage, gets the list of builds. - * - * All dropdown events have a relatedTarget property, - * whose value is the toggling anchor element. - * - * @param {Object} e bootstrap dropdown event - * @return {Promise} - */ - getBuildsList(e) { - const button = e.relatedTarget; - const endpoint = button.dataset.stageEndpoint; - - this.renderBuildsList(button, ''); - this.toggleLoading(button); - - axios - .get(endpoint) - .then(({ data }) => { - this.toggleLoading(button); - this.renderBuildsList(button, data.html); - this.stopDropdownClickPropagation(); - }) - .catch(() => { - this.toggleLoading(button); - if ($(button).parent().hasClass('open')) { - $(button).dropdown('toggle'); - } - flash(__('An error occurred while fetching the builds.'), 'alert'); - }); - } - - /** - * Toggles the visibility of the loading icon. - * - * @param {HTMLElement} stageContainer - * @return {type} - */ - toggleLoading(stageContainer) { - stageContainer.parentElement - .querySelector(`${this.dropdownListSelector} .js-builds-dropdown-loading`) - .classList.toggle('hidden'); - } -} diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index 3c423bea368..05b5b760f0a 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -14,6 +14,7 @@ import { debounce } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import invalidUrl from '~/lib/utils/invalid_url'; import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import { timeRanges } from '~/vue_shared/constants'; @@ -24,6 +25,9 @@ import DashboardsDropdown from './dashboards_dropdown.vue'; import RefreshButton from './refresh_button.vue'; export default { + i18n: { + metricsSettings: s__('Metrics|Metrics Settings'), + }, components: { GlIcon, GlButton, @@ -282,7 +286,8 @@ export default { data-testid="metrics-settings-button" icon="settings" :href="operationsSettingsPath" - :title="s__('Metrics|Metrics Settings')" + :title="$options.i18n.metricsSettings" + :aria-label="$options.i18n.metricsSettings" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue index 847339e814a..e5f0206bb8b 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue @@ -10,6 +10,7 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { mapActions, mapState } from 'vuex'; +import { s__ } from '~/locale'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; import { timeRanges } from '~/vue_shared/constants'; import DashboardPanel from './dashboard_panel.vue'; @@ -24,6 +25,9 @@ metrics: `; export default { + i18n: { + refreshButtonLabel: s__('Metrics|Refresh Prometheus data'), + }, components: { GlCard, GlForm, @@ -191,7 +195,8 @@ export default { v-gl-tooltip data-testid="previewRefreshButton" icon="retry" - :title="s__('Metrics|Refresh Prometheus data')" + :title="$options.i18n.refreshButtonLabel" + :aria-label="$options.i18n.refreshButtonLabel" @click="onRefresh" /> <dashboard-panel :graph-data="panelPreviewGraphData" /> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue index 627af202028..1765a2f3d5d 100644 --- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue @@ -23,7 +23,7 @@ export default { }, }, radioVals: { - /* Use the default branch (e.g. master) */ + /* Use the default branch (e.g. main) */ DEFAULT: 'DEFAULT', /* Create a new branch */ NEW: 'NEW', diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue index 3f9f57d4ac1..fb5ab12916e 100644 --- a/app/assets/javascripts/monitoring/components/links_section.vue +++ b/app/assets/javascripts/monitoring/components/links_section.vue @@ -15,7 +15,7 @@ export default { <template> <div ref="linksSection" - class="gl-sm-display-flex gl-flex-sm-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section" + class="gl-sm-display-flex gl-sm-flex-wrap gl-mt-5 gl-p-3 gl-bg-gray-10 border gl-rounded-base links-section" > <div v-for="(link, key) in links" diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue index 3daf5b38933..0b80043a92c 100644 --- a/app/assets/javascripts/monitoring/components/refresh_button.vue +++ b/app/assets/javascripts/monitoring/components/refresh_button.vue @@ -9,7 +9,7 @@ import { } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import { mapActions } from 'vuex'; -import { n__, __ } from '~/locale'; +import { n__, __, s__ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -45,6 +45,9 @@ const makeInterval = (length = 0, unit = 's') => { }; export default { + i18n: { + refreshDashboard: s__('Metrics|Refresh dashboard'), + }, components: { GlButtonGroup, GlButton, @@ -148,7 +151,8 @@ export default { v-gl-tooltip class="gl-flex-grow-1" variant="default" - :title="s__('Metrics|Refresh dashboard')" + :title="$options.i18n.refreshDashboard" + :aria-label="$options.i18n.refreshDashboard" icon="retry" @click="refresh" /> diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index 9a93e90c2bb..d85fd10be45 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -25,6 +25,9 @@ export default () => { return { noteableData, + endpoints: { + metadata: notesDataset.endpointMetadata, + }, currentUserData: JSON.parse(notesDataset.currentUserData), notesData: JSON.parse(notesDataset.notesData), helpPagePath: notesDataset.helpPagePath, @@ -54,6 +57,9 @@ export default () => { }, created() { this.setActiveTab(window.mrTabs.getCurrentAction()); + this.setEndpoints(this.endpoints); + + this.fetchMrMetadata(); }, mounted() { this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge'); @@ -65,7 +71,7 @@ export default () => { window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab); }, methods: { - ...mapActions(['setActiveTab']), + ...mapActions(['setActiveTab', 'setEndpoints', 'fetchMrMetadata']), updateDiscussionTabCounter() { this.notesCountBadge.text(this.discussionTabCounter); }, diff --git a/app/assets/javascripts/mr_notes/stores/actions.js b/app/assets/javascripts/mr_notes/stores/actions.js index 426c6a00d5e..bc66d1dd68f 100644 --- a/app/assets/javascripts/mr_notes/stores/actions.js +++ b/app/assets/javascripts/mr_notes/stores/actions.js @@ -1,7 +1,32 @@ +import axios from '~/lib/utils/axios_utils'; + import types from './mutation_types'; -export default { - setActiveTab({ commit }, tab) { - commit(types.SET_ACTIVE_TAB, tab); - }, -}; +export function setActiveTab({ commit }, tab) { + commit(types.SET_ACTIVE_TAB, tab); +} + +export function setEndpoints({ commit }, endpoints) { + commit(types.SET_ENDPOINTS, endpoints); +} + +export function setMrMetadata({ commit }, metadata) { + commit(types.SET_MR_METADATA, metadata); +} + +export function fetchMrMetadata({ dispatch, state }) { + if (state.endpoints?.metadata) { + axios + .get(state.endpoints.metadata) + .then((response) => { + dispatch('setMrMetadata', response.data); + }) + .catch(() => { + // https://gitlab.com/gitlab-org/gitlab/-/issues/324740 + // We can't even do a simple console warning here because + // the pipeline will fail. However, the issue above will + // eventually handle errors appropriately. + // console.warn('Failed to load MR Metadata for the Overview tab.'); + }); + } +} diff --git a/app/assets/javascripts/mr_notes/stores/modules/index.js b/app/assets/javascripts/mr_notes/stores/modules/index.js index c28e666943b..52e12ba664c 100644 --- a/app/assets/javascripts/mr_notes/stores/modules/index.js +++ b/app/assets/javascripts/mr_notes/stores/modules/index.js @@ -1,10 +1,12 @@ -import actions from '../actions'; +import * as actions from '../actions'; import getters from '../getters'; import mutations from '../mutations'; export default () => ({ state: { + endpoints: {}, activeTab: null, + mrMetadata: {}, }, actions, getters, diff --git a/app/assets/javascripts/mr_notes/stores/mutation_types.js b/app/assets/javascripts/mr_notes/stores/mutation_types.js index 105104361cf..88cf6e48988 100644 --- a/app/assets/javascripts/mr_notes/stores/mutation_types.js +++ b/app/assets/javascripts/mr_notes/stores/mutation_types.js @@ -1,3 +1,5 @@ export default { SET_ACTIVE_TAB: 'SET_ACTIVE_TAB', + SET_ENDPOINTS: 'SET_ENDPOINTS', + SET_MR_METADATA: 'SET_MR_METADATA', }; diff --git a/app/assets/javascripts/mr_notes/stores/mutations.js b/app/assets/javascripts/mr_notes/stores/mutations.js index 8175aa9488f..6af6adb4e18 100644 --- a/app/assets/javascripts/mr_notes/stores/mutations.js +++ b/app/assets/javascripts/mr_notes/stores/mutations.js @@ -4,4 +4,10 @@ export default { [types.SET_ACTIVE_TAB](state, tab) { Object.assign(state, { activeTab: tab }); }, + [types.SET_ENDPOINTS](state, endpoints) { + Object.assign(state, { endpoints }); + }, + [types.SET_MR_METADATA](state, metadata) { + Object.assign(state, { mrMetadata: metadata }); + }, }; diff --git a/app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue b/app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue new file mode 100644 index 00000000000..8de6e910bb6 --- /dev/null +++ b/app/assets/javascripts/namespaces/cascading_settings/components/lock_popovers.vue @@ -0,0 +1,77 @@ +<script> +import { GlPopover, GlSprintf, GlLink } from '@gitlab/ui'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +export default { + name: 'LockPopovers', + components: { + GlPopover, + GlSprintf, + GlLink, + }, + data() { + return { + targets: [], + }; + }, + mounted() { + this.targets = [...document.querySelectorAll('.js-cascading-settings-lock-popover-target')].map( + (el) => { + const { + dataset: { popoverData }, + } = el; + + const { + lockedByAncestor, + lockedByApplicationSetting, + ancestorNamespace, + } = convertObjectPropsToCamelCase(JSON.parse(popoverData || '{}'), { deep: true }); + + return { + el, + lockedByAncestor, + lockedByApplicationSetting, + ancestorNamespace, + }; + }, + ); + }, +}; +</script> + +<template> + <div> + <template + v-for="( + { el, lockedByApplicationSetting, lockedByAncestor, ancestorNamespace }, index + ) in targets" + > + <gl-popover + v-if="lockedByApplicationSetting || lockedByAncestor" + :key="index" + :target="el" + placement="top" + > + <template #title>{{ s__('CascadingSettings|Setting enforced') }}</template> + <p data-testid="cascading-settings-lock-popover"> + <template v-if="lockedByApplicationSetting">{{ + s__('CascadingSettings|This setting has been enforced by an instance admin.') + }}</template> + + <gl-sprintf + v-else-if="lockedByAncestor && ancestorNamespace" + :message=" + s__('CascadingSettings|This setting has been enforced by an owner of %{link}.') + " + > + <template #link> + <gl-link :href="ancestorNamespace.path" class="gl-font-sm">{{ + ancestorNamespace.fullName + }}</gl-link> + </template> + </gl-sprintf> + </p> + </gl-popover> + </template> + </div> +</template> diff --git a/app/assets/javascripts/namespaces/cascading_settings/index.js b/app/assets/javascripts/namespaces/cascading_settings/index.js new file mode 100644 index 00000000000..3e44d1e9e2d --- /dev/null +++ b/app/assets/javascripts/namespaces/cascading_settings/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import LockPopovers from './components/lock_popovers.vue'; + +export const initCascadingSettingsLockPopovers = () => { + const el = document.querySelector('.js-cascading-settings-lock-popovers'); + + if (!el) return false; + + return new Vue({ + el, + render(createElement) { + return createElement(LockPopovers); + }, + }); +}; diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index e4cde0d4ff3..c09db6851e5 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -37,6 +37,11 @@ const katexRegexString = `( .replace(/\s/g, '') .trim(); +function deHTMLify(t) { + // get some specific characters back, that are allowed for KaTex rendering + const text = t.replace(/'/g, "'").replace(/</g, '<').replace(/>/g, '>'); + return text; +} function renderKatex(t) { let text = t; let numInline = 0; // number of successfull converted math formulas @@ -57,9 +62,7 @@ function renderKatex(t) { while (matches !== null) { try { - const renderedKatex = katex.renderToString( - matches[0].replace(/\$/g, '').replace(/'/g, "'"), - ); // get the tick ' back again from HTMLified string + const renderedKatex = katex.renderToString(deHTMLify(matches[0].replace(/\$/g, ''))); text = `${text.replace(matches[0], ` ${renderedKatex}`)}`; } catch { numInline -= 1; @@ -68,7 +71,7 @@ function renderKatex(t) { } } else { try { - text = katex.renderToString(matches[2].replace(/'/g, "'")); + text = katex.renderToString(deHTMLify(matches[2])); } catch (error) { numInline -= 1; } diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 8ed40f36103..b5c59f34e87 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -22,7 +22,7 @@ import syntaxHighlight from '~/syntax_highlight'; import Autosave from './autosave'; import loadAwardsHandler from './awards_handler'; import CommentTypeToggle from './comment_type_toggle'; -import { deprecatedCreateFlash as Flash } from './flash'; +import createFlash from './flash'; import { defaultAutocompleteConfig } from './gfm_auto_complete'; import GLForm from './gl_form'; import axios from './lib/utils/axios_utils'; @@ -106,7 +106,7 @@ export default class Notes { this.collapseLongCommitList(); this.setViewType(view); - // We are in the Merge Requests page so we need another edit form for Changes tab + // We are in the merge requests page so we need another edit form for Changes tab if (getPagePath(1) === 'merge_requests') { $('.note-edit-form').clone().addClass('mr-note-edit-form').insertAfter('.note-edit-form'); } @@ -399,7 +399,11 @@ export default class Notes { if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) { $notesList.find('.system-note.being-posted').remove(); } - this.addFlash(noteEntity.errors.commands_only, 'notice', this.parentTimeline.get(0)); + this.addFlash({ + message: noteEntity.errors.commands_only, + type: 'notice', + parent: this.parentTimeline.get(0), + }); this.refresh(); } return; @@ -620,20 +624,21 @@ export default class Notes { } else if ($form.hasClass('js-discussion-note-form')) { formParentTimeline = $form.closest('.discussion-notes').find('.notes'); } - return this.addFlash( - __( + return this.addFlash({ + message: __( 'Your comment could not be submitted! Please check your network connection and try again.', ), - 'alert', - formParentTimeline.get(0), - ); + type: 'alert', + parent: formParentTimeline.get(0), + }); } updateNoteError() { - // eslint-disable-next-line no-new - new Flash( - __('Your comment could not be updated! Please check your network connection and try again.'), - ); + createFlash({ + message: __( + 'Your comment could not be updated! Please check your network connection and try again.', + ), + }); } /** @@ -1289,7 +1294,7 @@ export default class Notes { } addFlash(...flashParams) { - this.flashContainer = new Flash(...flashParams); + this.flashContainer = createFlash(...flashParams); } clearFlash() { diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 08d7c745791..79d8ce78329 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -84,6 +84,7 @@ export default { 'getNoteableDataByProp', 'getNotesData', 'openState', + 'hasDrafts', ]), ...mapState(['isToggleStateButtonLoading']), isNoteTypeComment() { @@ -171,6 +172,9 @@ export default { endpoint() { return this.getNoteableData.create_note_path; }, + draftEndpoint() { + return this.getNotesData.draftsPath; + }, issuableTypeTitle() { return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE ? this.$options.i18n.mergeRequest @@ -214,12 +218,15 @@ export default { this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK]; } }, - handleSave(withIssueAction) { + handleSaveDraft() { + this.handleSave({ isDraft: true }); + }, + handleSave({ withIssueAction = false, isDraft = false } = {}) { this.errors = []; if (this.note.length) { const noteData = { - endpoint: this.endpoint, + endpoint: isDraft ? this.draftEndpoint : this.endpoint, data: { note: { noteable_type: this.noteableType, @@ -229,6 +236,7 @@ export default { }, merge_request_diff_head_sha: this.getNoteableData.diff_head_sha, }, + isDraft, }; if (this.noteType === constants.DISCUSSION) { @@ -392,62 +400,82 @@ export default { </markdown-field> </comment-field-layout> <div class="note-form-actions"> - <gl-form-checkbox - v-if="confidentialNotesEnabled && canSetConfidential" - v-model="noteIsConfidential" - class="gl-mb-6" - data-testid="confidential-note-checkbox" - > - {{ $options.i18n.confidential }} - <gl-icon - v-gl-tooltip:tooltipcontainer.bottom - name="question" - :size="16" - :title="$options.i18n.confidentialVisibility" - class="gl-text-gray-500" - /> - </gl-form-checkbox> - <gl-dropdown - split - :text="commentButtonTitle" - class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown" - category="primary" - variant="success" - :disabled="disableSubmitButton" - data-testid="comment-button" - data-qa-selector="comment_button" - :data-track-label="trackingLabel" - data-track-event="click_button" - @click="handleSave()" - > - <gl-dropdown-item - is-check-item - :is-checked="isNoteTypeComment" - :selected="isNoteTypeComment" - @click="setNoteTypeToComment" + <template v-if="hasDrafts"> + <gl-button + :disabled="disableSubmitButton" + data-testid="add-to-review-button" + type="submit" + category="primary" + variant="success" + @click.prevent="handleSaveDraft()" + >{{ __('Add to review') }}</gl-button + > + <gl-button + :disabled="disableSubmitButton" + data-testid="add-comment-now-button" + category="secondary" + @click.prevent="handleSave()" + >{{ __('Add comment now') }}</gl-button + > + </template> + <template v-else> + <gl-form-checkbox + v-if="confidentialNotesEnabled && canSetConfidential" + v-model="noteIsConfidential" + class="gl-mb-6" + data-testid="confidential-note-checkbox" > - <strong>{{ $options.i18n.submitButton.comment }}</strong> - <p class="gl-m-0">{{ commentDescription }}</p> - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-item - is-check-item - :is-checked="isNoteTypeDiscussion" - :selected="isNoteTypeDiscussion" - data-qa-selector="discussion_menu_item" - @click="setNoteTypeToDiscussion" + {{ $options.i18n.confidential }} + <gl-icon + v-gl-tooltip:tooltipcontainer.bottom + name="question" + :size="16" + :title="$options.i18n.confidentialVisibility" + class="gl-text-gray-500" + /> + </gl-form-checkbox> + <gl-dropdown + split + :text="commentButtonTitle" + class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown" + category="primary" + variant="confirm" + :disabled="disableSubmitButton" + data-testid="comment-button" + data-qa-selector="comment_button" + :data-track-label="trackingLabel" + data-track-event="click_button" + @click="handleSave()" > - <strong>{{ $options.i18n.submitButton.startThread }}</strong> - <p class="gl-m-0">{{ startDiscussionDescription }}</p> - </gl-dropdown-item> - </gl-dropdown> + <gl-dropdown-item + is-check-item + :is-checked="isNoteTypeComment" + :selected="isNoteTypeComment" + @click="setNoteTypeToComment" + > + <strong>{{ $options.i18n.submitButton.comment }}</strong> + <p class="gl-m-0">{{ commentDescription }}</p> + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-item + is-check-item + :is-checked="isNoteTypeDiscussion" + :selected="isNoteTypeDiscussion" + data-qa-selector="discussion_menu_item" + @click="setNoteTypeToDiscussion" + > + <strong>{{ $options.i18n.submitButton.startThread }}</strong> + <p class="gl-m-0">{{ startDiscussionDescription }}</p> + </gl-dropdown-item> + </gl-dropdown> + </template> <gl-button v-if="canToggleIssueState" :loading="isToggleStateButtonLoading" :class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']" :disabled="isSubmitting" data-testid="close-reopen-button" - @click="handleSave(true)" + @click="handleSave({ withIssueAction: true })" >{{ issueActionButtonTitle }}</gl-button > </div> diff --git a/app/assets/javascripts/notes/components/discussion_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue index fa3c900c337..7e8bb75902b 100644 --- a/app/assets/javascripts/notes/components/discussion_navigator.vue +++ b/app/assets/javascripts/notes/components/discussion_navigator.vue @@ -1,6 +1,11 @@ <script> /* global Mousetrap */ import 'mousetrap'; +import { + keysFor, + MR_NEXT_UNRESOLVED_DISCUSSION, + MR_PREVIOUS_UNRESOLVED_DISCUSSION, +} from '~/behaviors/shortcuts/keybindings'; import eventHub from '~/notes/event_hub'; import discussionNavigation from '~/notes/mixins/discussion_navigation'; @@ -10,12 +15,12 @@ export default { eventHub.$on('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion); }, mounted() { - Mousetrap.bind('n', this.jumpToNextDiscussion); - Mousetrap.bind('p', this.jumpToPreviousDiscussion); + Mousetrap.bind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION), this.jumpToNextDiscussion); + Mousetrap.bind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION), this.jumpToPreviousDiscussion); }, beforeDestroy() { - Mousetrap.unbind('n'); - Mousetrap.unbind('p'); + Mousetrap.unbind(keysFor(MR_NEXT_UNRESOLVED_DISCUSSION)); + Mousetrap.unbind(keysFor(MR_PREVIOUS_UNRESOLVED_DISCUSSION)); eventHub.$off('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion); }, diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 0f74d78c8e0..dfe2763d8bd 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -121,6 +121,7 @@ export default { :is="componentName(firstNote)" :note="componentData(firstNote)" :line="line || diffLine" + :discussion-file="discussion.diff_file" :commit="commit" :help-page-path="helpPagePath" :show-reply-button="userCanReply" @@ -167,6 +168,7 @@ export default { v-for="(note, index) in discussion.notes" :key="note.id" :note="componentData(note)" + :discussion-file="discussion.diff_file" :help-page-path="helpPagePath" :line="diffLine" :discussion-root="index === 0" diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue index cace382ccd6..5f429cbf462 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -1,7 +1,11 @@ <script> import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; export default { + i18n: { + buttonLabel: s__('MergeRequests|Resolve this thread in a new issue'), + }, name: 'ResolveWithIssueButton', components: { GlButton, @@ -23,7 +27,8 @@ export default { <gl-button v-gl-tooltip :href="url" - :title="s__('MergeRequests|Resolve this thread in a new issue')" + :title="$options.i18n.buttonLabel" + :aria-label="$options.i18n.buttonLabel" class="new-issue-for-discussion discussion-create-issue-btn" icon="issue-new" /> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index ed6701b34e8..24399e669a6 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -13,6 +13,12 @@ import { splitCamelCase } from '../../lib/utils/text_utility'; import ReplyButton from './note_actions/reply_button.vue'; export default { + i18n: { + addReactionLabel: __('Add reaction'), + editCommentLabel: __('Edit comment'), + deleteCommentLabel: __('Delete comment'), + moreActionsLabel: __('More actions'), + }, name: 'NoteActions', components: { GlIcon, @@ -119,9 +125,11 @@ export default { type: Boolean, required: true, }, + // This can be undefined when `canAwardEmoji` is false awardPath: { type: String, - required: true, + required: false, + default: '', }, }, computed: { @@ -301,9 +309,9 @@ export default { category="tertiary" variant="default" size="small" - title="Add reaction" + :title="$options.i18n.addReactionLabel" + :aria-label="$options.i18n.addReactionLabel" data-position="right" - :aria-label="__('Add reaction')" > <span class="reaction-control-icon reaction-control-icon-neutral"> <gl-icon name="slight-smile" /> @@ -325,32 +333,35 @@ export default { <gl-button v-if="canEdit" v-gl-tooltip - title="Edit comment" + :title="$options.i18n.editCommentLabel" + :aria-label="$options.i18n.editCommentLabel" icon="pencil" size="small" category="tertiary" - class="note-action-button js-note-edit btn btn-transparent" + class="note-action-button js-note-edit" data-qa-selector="note_edit_button" @click="onEdit" /> <gl-button v-if="showDeleteAction" v-gl-tooltip - title="Delete comment" + :title="$options.i18n.deleteCommentLabel" + :aria-label="$options.i18n.deleteCommentLabel" size="small" icon="remove" category="tertiary" - class="note-action-button js-note-delete btn btn-transparent" + class="note-action-button js-note-delete" @click="onDelete" /> <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions"> <gl-button v-gl-tooltip - title="More actions" + :title="$options.i18n.moreActionsLabel" + :aria-label="$options.i18n.moreActionsLabel" icon="ellipsis_v" size="small" category="tertiary" - class="note-action-button more-actions-toggle btn btn-transparent" + class="note-action-button more-actions-toggle" data-toggle="dropdown" @click="closeTooltip" /> diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue index b20facc4032..c49f3e2de99 100644 --- a/app/assets/javascripts/notes/components/note_attachment.vue +++ b/app/assets/javascripts/notes/components/note_attachment.vue @@ -24,7 +24,7 @@ export default { target="_blank" rel="noopener noreferrer" > - <img :src="attachment.url" class="note-image-attach" /> + <img :src="attachment.url" class="note-image-attach col-lg-4" /> </a> <div class="attachment"> <a diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index d74ade15de1..a70bac94b71 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -60,6 +60,11 @@ export default { required: false, default: null, }, + lines: { + type: Array, + required: false, + default: () => [], + }, note: { type: Object, required: false, @@ -333,6 +338,7 @@ export default { :help-page-path="helpPagePath" :show-suggest-popover="showSuggestPopover" :textarea-value="updatedNoteBody" + :lines="lines" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" > <template #textarea> @@ -384,7 +390,7 @@ export default { <gl-button :disabled="isDisabled" category="primary" - variant="success" + variant="confirm" class="gl-mr-3" data-qa-selector="start_review_button" @click="handleAddToReview" @@ -418,7 +424,7 @@ export default { <gl-button :disabled="isDisabled" category="primary" - variant="success" + variant="confirm" data-qa-selector="reply_comment_button" class="gl-mr-3 js-vue-issue-save js-comment-button" @click="handleUpdate()" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 185f4a70367..0feb77be653 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -48,6 +48,11 @@ export default { required: false, default: null, }, + discussionFile: { + type: Object, + required: false, + default: null, + }, helpPagePath: { type: String, required: false, @@ -86,7 +91,7 @@ export default { isRequesting: false, isResolving: false, commentLineStart: {}, - resolveAsThread: this.glFeatures.removeResolveNote, + resolveAsThread: true, }; }, computed: { @@ -139,14 +144,9 @@ export default { return this.note.isDraft; }, canResolve() { - if (this.glFeatures.removeResolveNote && !this.discussionRoot) return false; + if (!this.discussionRoot) return false; - if (this.glFeatures.removeResolveNote) return this.note.current_user.can_resolve_discussion; - - return ( - this.note.current_user.can_resolve || - (this.note.isDraft && this.note.discussion_id !== null) - ); + return this.note.current_user.can_resolve_discussion; }, lineRange() { return this.note.position?.line_range; @@ -172,12 +172,18 @@ export default { return commentLineOptions(lines, this.commentLineStart, this.line.line_code); }, diffFile() { + let fileResolvedFromAvailableSource; + if (this.commentLineStart.line_code) { const lineCode = this.commentLineStart.line_code.split('_')[0]; - return this.getDiffFileByHash(lineCode); + fileResolvedFromAvailableSource = this.getDiffFileByHash(lineCode); + } + + if (!fileResolvedFromAvailableSource && this.discussionFile) { + fileResolvedFromAvailableSource = this.discussionFile; } - return null; + return fileResolvedFromAvailableSource || null; }, }, created() { diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 58cfd150659..433f75a752d 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -3,8 +3,10 @@ import { mapGetters, mapActions } from 'vuex'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import { __ } from '~/locale'; import initUserPopovers from '~/user_popovers'; +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 { deprecatedCreateFlash as Flash } from '../../flash'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue'; @@ -32,6 +34,8 @@ export default { discussionFilterNote, OrderedLayout, SidebarSubscription, + draftNote, + TimelineEntryItem, }, mixins: [glFeatureFlagsMixin()], props: { @@ -276,6 +280,9 @@ export default { <ul id="notes-list" class="notes main-notes-list timeline"> <template v-for="discussion in allDiscussions"> <skeleton-loading-container v-if="discussion.isSkeletonNote" :key="discussion.id" /> + <timeline-entry-item v-else-if="discussion.isDraft" :key="discussion.id"> + <draft-note :draft="discussion" /> + </timeline-entry-item> <template v-else-if="discussion.isPlaceholderNote"> <placeholder-system-note v-if="discussion.placeholderType === $options.systemNote" diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue index ed1f456c174..92c39fbb9f0 100644 --- a/app/assets/javascripts/notes/components/sort_discussion.vue +++ b/app/assets/javascripts/notes/components/sort_discussion.vue @@ -49,18 +49,17 @@ export default { </script> <template> - <div class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile"> + <div + data-testid="sort-discussion-filter" + class="gl-mr-3 gl-display-inline-block gl-vertical-align-bottom full-width-mobile" + > <local-storage-sync :value="sortDirection" :storage-key="storageKey" :persist="persistSortOrder" @input="setDiscussionSortDirection({ direction: $event })" /> - <gl-dropdown - :text="dropdownText" - data-testid="sort-discussion-filter" - class="js-dropdown-text full-width-mobile" - > + <gl-dropdown :text="dropdownText" class="js-dropdown-text full-width-mobile"> <gl-dropdown-item v-for="{ text, key, cls } in $options.SORT_OPTIONS" :key="key" diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index baada4c5ce8..27ed8e203b0 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -1,24 +1,11 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { - mixins: [glFeatureFlagsMixin()], computed: { discussionResolved() { if (this.discussion) { - const { notes, resolved } = this.discussion; - - if (this.glFeatures.removeResolveNote) { - return Boolean(resolved); - } - - if (notes) { - // Decide resolved state using store. Only valid for discussions. - return notes.filter((note) => !note.system).every((note) => note.resolved); - } - - return resolved; + return Boolean(this.discussion.resolved); } return this.note.resolved; @@ -47,7 +34,7 @@ export default { let endpoint = discussion && this.discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`; - if (this.glFeatures.removeResolveNote && this.discussionResolvePath) { + if (this.discussionResolvePath) { endpoint = this.discussionResolvePath; } diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 43d99937b8d..39f66063cfb 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -2,7 +2,23 @@ import { flattenDeep, clone } from 'lodash'; import * as constants from '../constants'; import { collapseSystemNotes } from './collapse_utils'; -export const discussions = (state) => { +const getDraftComments = (state) => { + if (!state.batchComments) { + return []; + } + + return state.batchComments.drafts + .filter((draft) => !draft.file_path && !draft.discussion_id) + .map((x) => ({ + ...x, + // Treat a top-level draft note as individual_note so it's not included in + // expand/collapse threads + individual_note: true, + })) + .sort((a, b) => a.id - b.id); +}; + +export const discussions = (state, getters, rootState) => { let discussionsInState = clone(state.discussions); // NOTE: not testing bc will be removed when backend is finished. @@ -22,11 +38,15 @@ export const discussions = (state) => { .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); } + discussionsInState = collapseSystemNotes(discussionsInState); + + discussionsInState = discussionsInState.concat(getDraftComments(rootState)); + if (state.discussionSortOrder === constants.DESC) { discussionsInState = discussionsInState.reverse(); } - return collapseSystemNotes(discussionsInState); + return discussionsInState; }; export const convertedDisscussionIds = (state) => state.convertedDisscussionIds; @@ -257,3 +277,6 @@ export const commentsDisabled = (state) => state.commentsDisabled; export const suggestionsCount = (state, getters) => Object.values(getters.notesById).filter((n) => n.suggestions.length).length; + +export const hasDrafts = (state, getters, rootState, rootGetters) => + Boolean(rootGetters['batchComments/hasDrafts']); diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue index 0c0bbb744b3..a24612e4680 100644 --- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue +++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue @@ -31,7 +31,9 @@ export default { <template> <section class="settings no-animate"> <div class="settings-header"> - <h4 class="js-section-header"> + <h4 + class="js-section-header settings-title js-settings-toggle js-settings-toggle-trigger-only" + > {{ s__('MetricsSettings|Metrics dashboard') }} </h4> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> diff --git a/app/assets/javascripts/packages/list/components/package_search.vue b/app/assets/javascripts/packages/list/components/package_search.vue index cd61d323d83..2e183b1b978 100644 --- a/app/assets/javascripts/packages/list/components/package_search.vue +++ b/app/assets/javascripts/packages/list/components/package_search.vue @@ -2,7 +2,8 @@ import { mapState, mapActions } from 'vuex'; import { __, s__ } from '~/locale'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; -import getTableHeaders from '../utils'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; +import { sortableFields } from '../utils'; import PackageTypeToken from './tokens/package_type_token.vue'; export default { @@ -16,7 +17,7 @@ export default { operators: [{ value: '=', description: __('is'), default: 'true' }], }, ], - components: { RegistrySearch }, + components: { RegistrySearch, UrlSync }, computed: { ...mapState({ isGroupPage: (state) => state.config.isGroupPage, @@ -24,7 +25,7 @@ export default { filter: (state) => state.filter, }), sortableFields() { - return getTableHeaders(this.isGroupPage); + return sortableFields(this.isGroupPage); }, }, methods: { @@ -38,13 +39,18 @@ export default { </script> <template> - <registry-search - :filter="filter" - :sorting="sorting" - :tokens="$options.tokens" - :sortable-fields="sortableFields" - @sorting:changed="updateSorting" - @filter:changed="setFilter" - @filter:submit="$emit('update')" - /> + <url-sync> + <template #default="{ updateQuery }"> + <registry-search + :filter="filter" + :sorting="sorting" + :tokens="$options.tokens" + :sortable-fields="sortableFields" + @sorting:changed="updateSorting" + @filter:changed="setFilter" + @filter:submit="$emit('update')" + @query:changed="updateQuery" + /> + </template> + </url-sync> </template> diff --git a/app/assets/javascripts/packages/list/components/package_title.vue b/app/assets/javascripts/packages/list/components/package_title.vue index 6176e15ffd4..426ad150ea9 100644 --- a/app/assets/javascripts/packages/list/components/package_title.vue +++ b/app/assets/javascripts/packages/list/components/package_title.vue @@ -11,25 +11,25 @@ export default { MetadataItem, }, props: { - packagesCount: { + count: { type: Number, required: false, default: null, }, - packageHelpUrl: { + helpUrl: { type: String, required: true, }, }, computed: { showPackageCount() { - return Number.isInteger(this.packagesCount); + return Number.isInteger(this.count); }, packageAmountText() { - return n__(`%d Package`, `%d Packages`, this.packagesCount); + return n__(`%d Package`, `%d Packages`, this.count); }, infoMessages() { - return [{ text: LIST_INTRO_TEXT, link: this.packageHelpUrl }]; + return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }]; }, }, i18n: { diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue index a609dfebedf..4c5fb0ee7c9 100644 --- a/app/assets/javascripts/packages/list/components/packages_list_app.vue +++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue @@ -5,9 +5,9 @@ import createFlash from '~/flash'; import { historyReplaceState } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '../constants'; -import PackageSearch from './package_search.vue'; -import PackageTitle from './package_title.vue'; import PackageList from './packages_list.vue'; export default { @@ -16,8 +16,38 @@ export default { GlLink, GlSprintf, PackageList, - PackageTitle, - PackageSearch, + PackageTitle: () => + import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'), + PackageSearch: () => + import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'), + InfrastructureTitle: () => + import( + /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue' + ), + InfrastructureSearch: () => + import( + /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue' + ), + }, + inject: { + titleComponent: { + from: 'titleComponent', + default: 'PackageTitle', + }, + searchComponent: { + from: 'searchComponent', + default: 'PackageSearch', + }, + emptyPageTitle: { + from: 'emptyPageTitle', + default: s__('PackageRegistry|There are no packages yet'), + }, + noResultsText: { + from: 'noResultsText', + default: s__( + 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', + ), + }, }, computed: { ...mapState({ @@ -30,22 +60,32 @@ export default { }), emptySearch() { return ( - this.filter.filter((f) => f.type !== 'filtered-search-term' || f.value?.data).length === 0 + this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0 ); }, emptyStateTitle() { return this.emptySearch - ? s__('PackageRegistry|There are no packages yet') + ? this.emptyPageTitle : s__('PackageRegistry|Sorry, your filter produced no results'); }, }, mounted() { + const queryParams = getQueryParams(window.document.location.search); + const { sorting, filters } = extractFilterAndSorting(queryParams); + this.setSorting(sorting); + this.setFilter(filters); this.requestPackagesList(); this.checkDeleteAlert(); }, methods: { - ...mapActions(['requestPackagesList', 'requestDeletePackage', 'setSelectedType']), + ...mapActions([ + 'requestPackagesList', + 'requestDeletePackage', + 'setSelectedType', + 'setSorting', + 'setFilter', + ]), onPageChanged(page) { return this.requestPackagesList({ page }); }, @@ -65,24 +105,21 @@ export default { }, i18n: { widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'), - noResults: s__( - 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', - ), }, }; </script> <template> <div> - <package-title :package-help-url="packageHelpUrl" :packages-count="packagesCount" /> - <package-search @update="requestPackagesList" /> + <component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" /> + <component :is="searchComponent" @update="requestPackagesList" /> <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> <template #empty-state> <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration"> <template #description> <gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" /> - <gl-sprintf v-else :message="$options.i18n.noResults"> + <gl-sprintf v-else :message="noResultsText"> <template #noPackagesLink="{ content }"> <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index 25a55200df2..b4fe3c70dea 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -82,6 +82,10 @@ export const PACKAGE_TYPES = [ title: s__('PackageRegistry|PyPI'), type: PackageType.PYPI, }, + { + title: s__('PackageRegistry|RubyGems'), + type: PackageType.RUBYGEMS, + }, ]; export const LIST_TITLE_TEXT = s__('PackageRegistry|Package Registry'); diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js index 58b09c1ebd1..2911cf70a33 100644 --- a/app/assets/javascripts/packages/list/packages_list_app_bundle.js +++ b/app/assets/javascripts/packages/list/packages_list_app_bundle.js @@ -1,11 +1,8 @@ import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import Translate from '~/vue_shared/translate'; import PackagesListApp from './components/packages_list_app.vue'; import { createStore } from './stores'; -Vue.use(VueApollo); Vue.use(Translate); export default () => { @@ -13,14 +10,9 @@ export default () => { const store = createStore(); store.dispatch('setInitialState', el.dataset); - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); - return new Vue({ el, store, - apolloProvider, components: { PackagesListApp, }, diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages/list/utils.js index ee89d3cdefe..537b30d2ca4 100644 --- a/app/assets/javascripts/packages/list/utils.js +++ b/app/assets/javascripts/packages/list/utils.js @@ -1,7 +1,7 @@ import { LIST_KEY_PROJECT, SORT_FIELDS } from './constants'; -export default (isGroupPage) => - SORT_FIELDS.filter((f) => f.key !== LIST_KEY_PROJECT || isGroupPage); +export const sortableFields = (isGroupPage) => + SORT_FIELDS.filter((f) => f.orderBy !== LIST_KEY_PROJECT || isGroupPage); /** * A small util function that works out if the delete action has deleted the diff --git a/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue b/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue new file mode 100644 index 00000000000..105f7bbe132 --- /dev/null +++ b/app/assets/javascripts/packages/shared/components/package_icon_and_name.vue @@ -0,0 +1,17 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + name: 'PackageIconAndName', + components: { + GlIcon, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <gl-icon name="package" class="gl-ml-3 gl-mr-2" /> + <span><slot></slot></span> + </div> +</template> diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue index 172b356227a..4de4c191e51 100644 --- a/app/assets/javascripts/packages/shared/components/package_list_row.vue +++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; +import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { getPackageTypeLabel } from '../utils'; @@ -11,7 +11,6 @@ export default { name: 'PackageListRow', components: { GlButton, - GlIcon, GlLink, GlSprintf, GlTruncate, @@ -19,11 +18,23 @@ export default { PackagePath, PublishMethod, ListItem, + PackageIconAndName: () => + import(/* webpackChunkName: 'package_registry_components' */ './package_icon_and_name.vue'), + InfrastructureIconAndName: () => + import( + /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue' + ), }, directives: { GlTooltip: GlTooltipDirective, }, mixins: [timeagoMixin], + inject: { + iconComponent: { + from: 'iconComponent', + default: 'PackageIconAndName', + }, + }, props: { packageEntity: { type: Object, @@ -94,10 +105,9 @@ export default { </gl-sprintf> </div> - <div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type"> - <gl-icon name="package" class="gl-ml-3 gl-mr-2" /> - <span>{{ packageType }}</span> - </div> + <component :is="iconComponent" v-if="showPackageType"> + {{ packageType }} + </component> <package-path v-if="hasProjectLink" :path="packageEntity.project_path" /> </div> diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js index c0f7f150337..f7de31c2c86 100644 --- a/app/assets/javascripts/packages/shared/constants.js +++ b/app/assets/javascripts/packages/shared/constants.js @@ -7,6 +7,7 @@ export const PackageType = { NUGET: 'nuget', PYPI: 'pypi', COMPOSER: 'composer', + RUBYGEMS: 'rubygems', GENERIC: 'generic', }; diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js index d34372e89b6..bd35a47ca4d 100644 --- a/app/assets/javascripts/packages/shared/utils.js +++ b/app/assets/javascripts/packages/shared/utils.js @@ -10,19 +10,21 @@ export const beautifyPath = (path) => (path ? path.split('/').join(' / ') : ''); export const getPackageTypeLabel = (packageType) => { switch (packageType) { case PackageType.CONAN: - return s__('PackageType|Conan'); + return s__('PackageRegistry|Conan'); case PackageType.MAVEN: - return s__('PackageType|Maven'); + return s__('PackageRegistry|Maven'); case PackageType.NPM: - return s__('PackageType|npm'); + return s__('PackageRegistry|npm'); case PackageType.NUGET: - return s__('PackageType|NuGet'); + return s__('PackageRegistry|NuGet'); case PackageType.PYPI: - return s__('PackageType|PyPI'); + return s__('PackageRegistry|PyPI'); + case PackageType.RUBYGEMS: + return s__('PackageRegistry|RubyGems'); case PackageType.COMPOSER: - return s__('PackageType|Composer'); + return s__('PackageRegistry|Composer'); case PackageType.GENERIC: - return s__('PackageType|Generic'); + return s__('PackageRegistry|Generic'); default: return null; } diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue new file mode 100644 index 00000000000..3100a1a7296 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_icon_and_name.vue @@ -0,0 +1,17 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + name: 'InfrastructureIconAndName', + components: { + GlIcon, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <gl-icon name="infrastructure-registry" class="gl-ml-3 gl-mr-2" /> + <span>{{ s__('InfrastructureRegistry|Terraform') }}</span> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue new file mode 100644 index 00000000000..4928da862ea --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue @@ -0,0 +1,45 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { LIST_KEY_PACKAGE_TYPE } from '~/packages/list/constants'; +import { sortableFields } from '~/packages/list/utils'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; +import UrlSync from '~/vue_shared/components/url_sync.vue'; + +export default { + components: { RegistrySearch, UrlSync }, + computed: { + ...mapState({ + isGroupPage: (state) => state.config.isGroupPage, + sorting: (state) => state.sorting, + filter: (state) => state.filter, + }), + sortableFields() { + return sortableFields(this.isGroupPage).filter((h) => h.orderBy !== LIST_KEY_PACKAGE_TYPE); + }, + }, + methods: { + ...mapActions(['setSorting', 'setFilter']), + updateSorting(newValue) { + this.setSorting(newValue); + this.$emit('update'); + }, + }, +}; +</script> + +<template> + <url-sync> + <template #default="{ updateQuery }"> + <registry-search + :filter="filter" + :sorting="sorting" + :tokens="[]" + :sortable-fields="sortableFields" + @sorting:changed="updateSorting" + @filter:changed="setFilter" + @filter:submit="$emit('update')" + @query:changed="updateQuery" + /> + </template> + </url-sync> +</template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue new file mode 100644 index 00000000000..2a479c65d0c --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue @@ -0,0 +1,53 @@ +<script> +import { s__, n__ } from '~/locale'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; + +export default { + name: 'InfrastructureTitle', + components: { + TitleArea, + MetadataItem, + }, + props: { + count: { + type: Number, + required: false, + default: null, + }, + helpUrl: { + type: String, + required: true, + }, + }, + computed: { + showModuleCount() { + return Number.isInteger(this.count); + }, + moduleAmountText() { + return n__(`%d Module`, `%d Modules`, this.count); + }, + infoMessages() { + return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }]; + }, + }, + i18n: { + LIST_TITLE_TEXT: s__('InfrastructureRegistry|Infrastructure Registry'), + LIST_INTRO_TEXT: s__( + 'InfrastructureRegistry|Publish and share your modules. %{docLinkStart}More information%{docLinkEnd}', + ), + }, +}; +</script> + +<template> + <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages"> + <template #metadata-amount> + <metadata-item + v-if="showModuleCount" + icon="infrastructure-registry" + :text="moduleAmountText" + /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js new file mode 100644 index 00000000000..88ee8a4200e --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/list_app_bundle.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import { s__ } from '~/locale'; +import PackagesListApp from '~/packages/list/components/packages_list_app.vue'; +import { createStore } from '~/packages/list/stores'; +import Translate from '~/vue_shared/translate'; + +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-vue-packages-list'); + const store = createStore(); + store.dispatch('setInitialState', el.dataset); + + return new Vue({ + el, + store, + components: { + PackagesListApp, + }, + provide: { + titleComponent: 'InfrastructureTitle', + searchComponent: 'InfrastructureSearch', + iconComponent: 'InfrastructureIconAndName', + emptyPageTitle: s__('InfrastructureRegistry|You have no Terraform modules in your project'), + noResultsText: s__( + 'InfrastructureRegistry|Terraform modules are the main way to package and reuse resource configurations with Terraform. Learn more about how to %{noPackagesLinkStart}create Terraform modules%{noPackagesLinkEnd} in GitLab.', + ), + }, + render(createElement) { + return createElement('packages-list-app'); + }, + }); +}; diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue index d4f51b83e1e..faacabb44ce 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue @@ -2,6 +2,7 @@ import { GlSprintf, GlToggle, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { + MAVEN_TOGGLE_LABEL, MAVEN_TITLE, MAVEN_SETTINGS_SUBTITLE, MAVEN_DUPLICATES_ALLOWED_DISABLED, @@ -15,6 +16,7 @@ import { export default { name: 'MavenSettings', i18n: { + MAVEN_TOGGLE_LABEL, MAVEN_TITLE, MAVEN_SETTINGS_SUBTITLE, MAVEN_SETTING_EXCEPTION_TITLE, @@ -80,6 +82,8 @@ export default { <div class="gl-display-flex"> <gl-toggle data-qa-selector="allow_duplicates_toggle" + :label="$options.i18n.MAVEN_TOGGLE_LABEL" + label-position="hidden" :value="mavenDuplicatesAllowed" @change="update($options.modelNames.MAVEN_DUPLICATES_ALLOWED, $event)" /> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index 72bec74060c..d52a6a626f9 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -8,6 +8,7 @@ export const PACKAGE_SETTINGS_DESCRIPTION = s__( export const MAVEN_TITLE = s__('PackageRegistry|Maven'); export const MAVEN_SETTINGS_SUBTITLE = s__('PackageRegistry|Settings for Maven packages'); +export const MAVEN_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates'); export const MAVEN_DUPLICATES_ALLOWED_DISABLED = s__( 'PackageRegistry|%{boldStart}Do not allow duplicates%{boldEnd} - Packages with the same name and version are rejected.', ); diff --git a/app/assets/javascripts/packages_and_registries/shared/constants.js b/app/assets/javascripts/packages_and_registries/shared/constants.js new file mode 100644 index 00000000000..55b5816cc5a --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/constants.js @@ -0,0 +1 @@ +export const FILTERED_SEARCH_TERM = 'filtered-search-term'; diff --git a/app/assets/javascripts/packages_and_registries/shared/utils.js b/app/assets/javascripts/packages_and_registries/shared/utils.js new file mode 100644 index 00000000000..cc5c7ce82bf --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/shared/utils.js @@ -0,0 +1,29 @@ +import { queryToObject } from '~/lib/utils/url_utility'; +import { FILTERED_SEARCH_TERM } from './constants'; + +export const getQueryParams = (query) => queryToObject(query, { gatherArrays: true }); + +export const keyValueToFilterToken = (type, data) => ({ type, value: { data } }); + +export const searchArrayToFilterTokens = (search) => + search.map((s) => keyValueToFilterToken(FILTERED_SEARCH_TERM, s)); + +export const extractFilterAndSorting = (queryObject) => { + const { type, search, sort, orderBy } = queryObject; + const filters = []; + const sorting = {}; + + if (type) { + filters.push(keyValueToFilterToken('type', type)); + } + if (search) { + filters.push(...searchArrayToFilterTokens(search)); + } + if (sort) { + sorting.sort = sort; + } + if (orderBy) { + sorting.orderBy = orderBy; + } + return { filters, sorting }; +}; diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js index f78d2b0dbd3..3ad9d80b4f2 100644 --- a/app/assets/javascripts/pager.js +++ b/app/assets/javascripts/pager.js @@ -8,20 +8,21 @@ const ENDLESS_SCROLL_BOTTOM_PX = 400; const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000; export default { - init( + init({ limit = 0, preload = false, disable = false, prepareData = $.noop, - callback = $.noop, + successCallback = $.noop, + errorCallback = $.noop, container = '', - ) { - this.url = $('.content_list').data('href') || removeParams(['limit', 'offset']); + } = {}) { this.limit = limit; this.offset = parseInt(getParameterByName('offset'), 10) || this.limit; this.disable = disable; this.prepareData = prepareData; - this.callback = callback; + this.successCallback = successCallback; + this.errorCallback = errorCallback; this.loading = $(`${container} .loading`).first(); if (preload) { this.offset = 0; @@ -32,8 +33,10 @@ export default { getOld() { this.loading.show(); + const url = $('.content_list').data('href') || removeParams(['limit', 'offset']); + axios - .get(this.url, { + .get(url, { params: { limit: this.limit, offset: this.offset, @@ -41,7 +44,7 @@ export default { }) .then(({ data }) => { this.append(data.count, this.prepareData(data.html)); - this.callback(); + this.successCallback(); // keep loading until we've filled the viewport height if (!this.disable && !this.isScrollable()) { @@ -50,7 +53,8 @@ export default { this.loading.hide(); } }) - .catch(() => this.loading.hide()); + .catch((err) => this.errorCallback(err)) + .finally(() => this.loading.hide()); }, append(count, html) { diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js index 0a4311ec73a..a88d35796f7 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/index.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js @@ -1,5 +1,8 @@ +import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; import UsersSelect from '~/users_select'; import AbuseReports from './abuse_reports'; new AbuseReports(); /* eslint-disable-line no-new */ new UsersSelect(); /* eslint-disable-line no-new */ + +document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior); diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js index 2732fc191be..6b7bfbf217d 100644 --- a/app/assets/javascripts/pages/admin/admin.js +++ b/app/assets/javascripts/pages/admin/admin.js @@ -1,16 +1,6 @@ import $ from 'jquery'; import { refreshCurrentPage } from '../../lib/utils/url_utility'; -function showDenylistType() { - if ($('input[name="denylist_type"]:checked').val() === 'file') { - $('.js-denylist-file').show(); - $('.js-denylist-raw').hide(); - } else { - $('.js-denylist-file').hide(); - $('.js-denylist-raw').show(); - } -} - export default function adminInit() { $('input#user_force_random_password').on('change', function randomPasswordClick() { const $elems = $('#user_password, #user_password_confirmation'); @@ -27,7 +17,4 @@ export default function adminInit() { }); $('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage); - - $("input[name='denylist_type']").on('click', showDenylistType); - showDenylistType(); } diff --git a/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue new file mode 100644 index 00000000000..2217792d7f3 --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_checkbox.vue @@ -0,0 +1,50 @@ +<script> +import { GlFormCheckbox } from '@gitlab/ui'; + +export default { + components: { + GlFormCheckbox, + }, + props: { + name: { + type: String, + required: true, + }, + helpText: { + type: String, + required: false, + default: '', + }, + label: { + type: String, + required: true, + }, + value: { + type: Boolean, + required: true, + }, + dataQaSelector: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <div> + <input :name="name" type="hidden" :value="value ? '1' : '0'" data-testid="input" /> + + <gl-form-checkbox + :checked="value" + :data-qa-selector="dataQaSelector" + @input="$emit('input', $event)" + > + <span data-testid="label">{{ label }}</span> + <template v-if="helpText" #help> + <span data-testid="helpText">{{ helpText }}</span> + </template> + </gl-form-checkbox> + </div> +</template> 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 new file mode 100644 index 00000000000..9850113d4be --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/general/components/signup_form.vue @@ -0,0 +1,417 @@ +<script> +import { + GlButton, + GlFormGroup, + GlFormInput, + GlFormRadio, + GlFormRadioGroup, + GlSprintf, + GlLink, + GlModal, +} from '@gitlab/ui'; +import { toSafeInteger } from 'lodash'; +import csrf from '~/lib/utils/csrf'; +import { __, s__, sprintf } from '~/locale'; +import SignupCheckbox from './signup_checkbox.vue'; + +const DENYLIST_TYPE_RAW = 'raw'; +const DENYLIST_TYPE_FILE = 'file'; + +export default { + csrf, + DENYLIST_TYPE_RAW, + DENYLIST_TYPE_FILE, + components: { + GlButton, + GlFormGroup, + GlFormInput, + GlFormRadio, + GlFormRadioGroup, + GlSprintf, + GlLink, + SignupCheckbox, + GlModal, + }, + inject: [ + 'host', + 'settingsPath', + 'signupEnabled', + 'requireAdminApprovalAfterUserSignup', + 'sendUserConfirmationEmail', + 'minimumPasswordLength', + 'minimumPasswordLengthMin', + 'minimumPasswordLengthMax', + 'minimumPasswordLengthHelpLink', + 'domainAllowlistRaw', + 'newUserSignupsCap', + 'domainDenylistEnabled', + 'denylistTypeRawSelected', + 'domainDenylistRaw', + 'emailRestrictionsEnabled', + 'supportedSyntaxLinkUrl', + 'emailRestrictions', + 'afterSignUpText', + ], + data() { + return { + showModal: false, + form: { + signupEnabled: this.signupEnabled, + requireAdminApproval: this.requireAdminApprovalAfterUserSignup, + sendConfirmationEmail: this.sendUserConfirmationEmail, + minimumPasswordLength: this.minimumPasswordLength, + minimumPasswordLengthMin: this.minimumPasswordLengthMin, + minimumPasswordLengthMax: this.minimumPasswordLengthMax, + minimumPasswordLengthHelpLink: this.minimumPasswordLengthHelpLink, + domainAllowlistRaw: this.domainAllowlistRaw, + userCap: this.newUserSignupsCap, + domainDenylistEnabled: this.domainDenylistEnabled, + denylistType: this.denylistTypeRawSelected + ? this.$options.DENYLIST_TYPE_RAW + : this.$options.DENYLIST_TYPE_FILE, + domainDenylistRaw: this.domainDenylistRaw, + emailRestrictionsEnabled: this.emailRestrictionsEnabled, + supportedSyntaxLinkUrl: this.supportedSyntaxLinkUrl, + emailRestrictions: this.emailRestrictions, + afterSignUpText: this.afterSignUpText, + }, + }; + }, + computed: { + isOldUserCapUnlimited() { + // User cap is set to unlimited if no value is provided in the field + return this.newUserSignupsCap === ''; + }, + isNewUserCapUnlimited() { + // User cap is set to unlimited if no value is provided in the field + return this.form.userCap === ''; + }, + hasUserCapChangedFromUnlimitedToLimited() { + return this.isOldUserCapUnlimited && !this.isNewUserCapUnlimited; + }, + hasUserCapChangedFromLimitedToUnlimited() { + return !this.isOldUserCapUnlimited && this.isNewUserCapUnlimited; + }, + hasUserCapBeenIncreased() { + if (this.hasUserCapChangedFromUnlimitedToLimited) { + return false; + } + + const oldValueAsInteger = toSafeInteger(this.newUserSignupsCap); + const newValueAsInteger = toSafeInteger(this.form.userCap); + + return this.hasUserCapChangedFromLimitedToUnlimited || newValueAsInteger > oldValueAsInteger; + }, + canUsersBeAccidentallyApproved() { + const hasUserCapBeenToggledOff = + this.requireAdminApprovalAfterUserSignup && !this.form.requireAdminApproval; + + return this.hasUserCapBeenIncreased || hasUserCapBeenToggledOff; + }, + signupEnabledHelpText() { + const text = sprintf( + s__( + 'ApplicationSettings|When enabled, any user visiting %{host} will be able to create an account.', + ), + { + host: this.host, + }, + ); + + return text; + }, + requireAdminApprovalHelpText() { + const text = sprintf( + s__( + 'ApplicationSettings|When enabled, any user visiting %{host} and creating an account will have to be explicitly approved by an admin before they can sign in. This setting is effective only if sign-ups are enabled.', + ), + { + host: this.host, + }, + ); + + return text; + }, + }, + watch: { + showModal(value) { + if (value === true) { + this.$refs[this.$options.modal.id].show(); + } else { + this.$refs[this.$options.modal.id].hide(); + } + }, + }, + methods: { + submitButtonHandler() { + if (this.canUsersBeAccidentallyApproved) { + this.showModal = true; + + return; + } + + this.submitForm(); + }, + submitForm() { + this.$refs.form.submit(); + }, + modalHideHandler() { + this.showModal = false; + }, + }, + i18n: { + 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'), + minimumPasswordLengthLabel: s__( + 'ApplicationSettings|Minimum password length (number of characters)', + ), + domainAllowListLabel: s__('ApplicationSettings|Allowed domains for sign-ups'), + domainAllowListDescription: s__( + 'ApplicationSettings|ONLY users with e-mail addresses that match these domain(s) will be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com', + ), + userCapLabel: s__('ApplicationSettings|User cap'), + userCapDescription: s__( + 'ApplicationSettings|Once the instance reaches the user cap, any user who is added or requests access will have to be approved by an admin. Leave the field empty for unlimited.', + ), + domainDenyListGroupLabel: s__('ApplicationSettings|Domain denylist'), + domainDenyListLabel: s__('ApplicationSettings|Enable domain denylist for sign ups'), + domainDenyListTypeFileLabel: s__('ApplicationSettings|Upload denylist file'), + domainDenyListTypeRawLabel: s__('ApplicationSettings|Enter denylist manually'), + domainDenyListFileLabel: s__('ApplicationSettings|Denylist file'), + domainDenyListFileDescription: s__( + 'ApplicationSettings|Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines or commas for multiple entries.', + ), + domainDenyListListLabel: s__('ApplicationSettings|Denied domains for sign-ups'), + domainDenyListListDescription: s__( + 'ApplicationSettings|Users with e-mail addresses that match these domain(s) will NOT be able to sign-up. Wildcards allowed. Use separate lines for multiple entries. Ex: domain.com, *.domain.com', + ), + domainPlaceholder: s__('ApplicationSettings|domain.com'), + emailRestrictionsEnabledGroupLabel: s__('ApplicationSettings|Email restrictions'), + emailRestrictionsEnabledLabel: s__( + 'ApplicationSettings|Enable email restrictions for sign ups', + ), + emailRestrictionsGroupLabel: s__('ApplicationSettings|Email restrictions for sign-ups'), + afterSignUpTextGroupLabel: s__('ApplicationSettings|After sign up text'), + afterSignUpTextGroupDescription: s__('ApplicationSettings|Markdown enabled'), + }, + modal: { + id: 'signup-settings-modal', + actionPrimary: { + text: s__('ApplicationSettings|Approve users'), + attributes: { + variant: 'confirm', + }, + }, + actionCancel: { + text: __('Cancel'), + }, + title: s__('ApplicationSettings|Approve all users in the pending approval status?'), + text: s__( + 'ApplicationSettings|By making this change, you will automatically approve all users in pending approval status.', + ), + }, +}; +</script> + +<template> + <form + ref="form" + accept-charset="UTF-8" + data-testid="form" + method="post" + :action="settingsPath" + enctype="multipart/form-data" + > + <input type="hidden" name="utf8" value="✓" /> + <input type="hidden" name="_method" value="patch" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + + <section class="gl-mb-8"> + <signup-checkbox + v-model="form.signupEnabled" + class="gl-mb-5" + name="application_setting[signup_enabled]" + :help-text="signupEnabledHelpText" + :label="$options.i18n.signupEnabledLabel" + data-qa-selector="signup_enabled_checkbox" + /> + + <signup-checkbox + v-model="form.requireAdminApproval" + class="gl-mb-5" + name="application_setting[require_admin_approval_after_user_signup]" + :help-text="requireAdminApprovalHelpText" + :label="$options.i18n.requireAdminApprovalLabel" + data-qa-selector="require_admin_approval_after_user_signup_checkbox" + 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.userCapLabel" + :description="$options.i18n.userCapDescription" + > + <gl-form-input + v-model="form.userCap" + type="text" + name="application_setting[new_user_signups_cap]" + data-testid="user-cap-input" + /> + </gl-form-group> + + <gl-form-group :label="$options.i18n.minimumPasswordLengthLabel"> + <gl-form-input + v-model="form.minimumPasswordLength" + :min="form.minimumPasswordLengthMin" + :max="form.minimumPasswordLengthMax" + type="number" + name="application_setting[minimum_password_length]" + /> + + <gl-sprintf + :message=" + s__( + 'ApplicationSettings|See GitLab\'s %{linkStart}Password Policy Guidelines%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="form.minimumPasswordLengthHelpLink" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </gl-form-group> + + <gl-form-group + :description="$options.i18n.domainAllowListDescription" + :label="$options.i18n.domainAllowListLabel" + > + <textarea + v-model="form.domainAllowlistRaw" + :placeholder="$options.i18n.domainPlaceholder" + rows="8" + class="form-control gl-form-input" + name="application_setting[domain_allowlist_raw]" + ></textarea> + </gl-form-group> + + <gl-form-group :label="$options.i18n.domainDenyListGroupLabel"> + <signup-checkbox + v-model="form.domainDenylistEnabled" + name="application_setting[domain_denylist_enabled]" + :label="$options.i18n.domainDenyListLabel" + /> + </gl-form-group> + + <gl-form-radio-group v-model="form.denylistType" name="denylist_type" class="gl-mb-5"> + <gl-form-radio :value="$options.DENYLIST_TYPE_FILE">{{ + $options.i18n.domainDenyListTypeFileLabel + }}</gl-form-radio> + <gl-form-radio :value="$options.DENYLIST_TYPE_RAW">{{ + $options.i18n.domainDenyListTypeRawLabel + }}</gl-form-radio> + </gl-form-radio-group> + + <gl-form-group + v-if="form.denylistType === $options.DENYLIST_TYPE_FILE" + :description="$options.i18n.domainDenyListFileDescription" + :label="$options.i18n.domainDenyListFileLabel" + label-for="domain-denylist-file-input" + data-testid="domain-denylist-file-input-group" + > + <input + id="domain-denylist-file-input" + class="form-control gl-form-input" + type="file" + accept=".txt,.conf" + name="application_setting[domain_denylist_file]" + /> + </gl-form-group> + + <gl-form-group + v-if="form.denylistType !== $options.DENYLIST_TYPE_FILE" + :description="$options.i18n.domainDenyListListDescription" + :label="$options.i18n.domainDenyListListLabel" + data-testid="domain-denylist-raw-input-group" + > + <textarea + v-model="form.domainDenylistRaw" + :placeholder="$options.i18n.domainPlaceholder" + rows="8" + class="form-control gl-form-input" + name="application_setting[domain_denylist_raw]" + ></textarea> + </gl-form-group> + + <gl-form-group :label="$options.i18n.emailRestrictionsEnabledGroupLabel"> + <signup-checkbox + v-model="form.emailRestrictionsEnabled" + name="application_setting[email_restrictions_enabled]" + :label="$options.i18n.emailRestrictionsEnabledLabel" + /> + </gl-form-group> + + <gl-form-group :label="$options.i18n.emailRestrictionsGroupLabel"> + <textarea + v-model="form.emailRestrictions" + rows="4" + class="form-control gl-form-input" + name="application_setting[email_restrictions]" + ></textarea> + + <gl-sprintf + :message=" + s__( + 'ApplicationSettings|Restricts sign-ups for email addresses that match the given regex. See the %{linkStart}supported syntax%{linkEnd} for more information.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="form.supportedSyntaxLinkUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-form-group> + + <gl-form-group + :label="$options.i18n.afterSignUpTextGroupLabel" + :description="$options.i18n.afterSignUpTextGroupDescription" + > + <textarea + v-model="form.afterSignUpText" + rows="4" + class="form-control gl-form-input" + name="application_setting[after_sign_up_text]" + ></textarea> + </gl-form-group> + </section> + + <gl-button + data-qa-selector="save_changes_button" + variant="confirm" + @click.prevent="submitButtonHandler" + > + {{ $options.i18n.buttonText }} + </gl-button> + + <gl-modal + :ref="$options.modal.id" + :modal-id="$options.modal.id" + :action-cancel="$options.modal.actionCancel" + :action-primary="$options.modal.actionPrimary" + :title="$options.modal.title" + @primary="submitForm" + @hide="modalHideHandler" + > + {{ $options.modal.text }} + </gl-modal> + </form> +</template> diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js index eda1a9d3599..c48d99da990 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js @@ -1,27 +1,9 @@ -import Vue from 'vue'; -import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; import initUserInternalRegexPlaceholder from '../account_and_limits'; +import initGitpod from '../gitpod'; +import initSignupRestrictions from '../signup_restrictions'; (() => { initUserInternalRegexPlaceholder(); - - const el = document.querySelector('#js-gitpod-settings-help-text'); - if (!el) { - return; - } - - const { message, messageUrl } = el.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - render(createElement) { - return createElement(IntegrationHelpText, { - props: { - message, - messageUrl, - }, - }); - }, - }); + initGitpod(); + initSignupRestrictions(); })(); diff --git a/app/assets/javascripts/pages/admin/application_settings/gitpod.js b/app/assets/javascripts/pages/admin/application_settings/gitpod.js new file mode 100644 index 00000000000..74e46617d52 --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/gitpod.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; + +export default function initGitpod() { + const el = document.querySelector('#js-gitpod-settings-help-text'); + + if (!el) { + return false; + } + + const { message, messageUrl } = el.dataset; + + return new Vue({ + el, + render(createElement) { + return createElement(IntegrationHelpText, { + props: { + message, + messageUrl, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js index e3c6b0f6f5b..a6e3a7dc08a 100644 --- a/app/assets/javascripts/pages/admin/application_settings/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/index.js @@ -4,9 +4,7 @@ import initSearchSettings from '~/search_settings'; import selfMonitor from '~/self_monitor'; import initSettingsPanels from '~/settings_panels'; -if (gon.features?.ciInstanceVariablesUi) { - initVariableList('js-instance-variables'); -} +initVariableList('js-instance-variables'); selfMonitor(); // Initialize expandable settings panels initSettingsPanels(); diff --git a/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js new file mode 100644 index 00000000000..70b896f6372 --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/signup_restrictions.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import SignupForm from './general/components/signup_form.vue'; +import { getParsedDataset } from './utils'; + +export default function initSignupRestrictions(elementSelector = '#js-signup-form') { + const el = document.querySelector(elementSelector); + + if (!el) { + return false; + } + + const parsedDataset = getParsedDataset({ + dataset: el.dataset, + booleanAttributes: [ + 'signupEnabled', + 'requireAdminApprovalAfterUserSignup', + 'sendUserConfirmationEmail', + 'domainDenylistEnabled', + 'denylistTypeRawSelected', + 'emailRestrictionsEnabled', + ], + }); + + return new Vue({ + el, + provide: { + ...parsedDataset, + }, + render: (createElement) => createElement(SignupForm), + }); +} diff --git a/app/assets/javascripts/pages/admin/application_settings/utils.js b/app/assets/javascripts/pages/admin/application_settings/utils.js new file mode 100644 index 00000000000..5462a13d523 --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/utils.js @@ -0,0 +1,21 @@ +import { includes } from 'lodash'; +import { parseBoolean } from '~/lib/utils/common_utils'; + +/** + * Returns a new dataset that has all the values of keys indicated in + * booleanAttributes transformed by the parseBoolean() helper function + * + * @param {Object} + * @returns {Object} + */ +export const getParsedDataset = ({ dataset = {}, booleanAttributes = [] } = {}) => { + const parsedDataset = {}; + + Object.keys(dataset).forEach((key) => { + parsedDataset[key] = includes(booleanAttributes, key) + ? parseBoolean(dataset[key]) + : dataset[key]; + }); + + return parsedDataset; +}; diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/index.js b/app/assets/javascripts/pages/admin/broadcast_messages/index.js index d6cc6a850eb..b7db6443658 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/index.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/index.js @@ -1,3 +1,7 @@ +import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; import initBroadcastMessagesForm from './broadcast_message'; -document.addEventListener('DOMContentLoaded', initBroadcastMessagesForm); +document.addEventListener('DOMContentLoaded', () => { + initBroadcastMessagesForm(); + initDeprecatedRemoveRowBehavior(); +}); diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js index 94f7cfd55be..1630cfb8253 100644 --- a/app/assets/javascripts/pages/admin/groups/new/index.js +++ b/app/assets/javascripts/pages/admin/groups/new/index.js @@ -2,9 +2,9 @@ import initFilePickers from '~/file_pickers'; import BindInOut from '../../../../behaviors/bind_in_out'; import Group from '../../../../group'; -document.addEventListener('DOMContentLoaded', () => { +(() => { BindInOut.initAll(); initFilePickers(); return new Group(); -}); +})(); diff --git a/app/assets/javascripts/pages/admin/labels/edit/index.js b/app/assets/javascripts/pages/admin/labels/edit/index.js index 5de1d4d6344..f7c25347e75 100644 --- a/app/assets/javascripts/pages/admin/labels/edit/index.js +++ b/app/assets/javascripts/pages/admin/labels/edit/index.js @@ -1,3 +1,3 @@ import Labels from '../../../../labels'; -document.addEventListener('DOMContentLoaded', () => new Labels()); +new Labels(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/labels/index/index.js b/app/assets/javascripts/pages/admin/labels/index/index.js new file mode 100644 index 00000000000..e5ab5d43bbf --- /dev/null +++ b/app/assets/javascripts/pages/admin/labels/index/index.js @@ -0,0 +1,3 @@ +import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; + +document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior); diff --git a/app/assets/javascripts/pages/admin/labels/new/index.js b/app/assets/javascripts/pages/admin/labels/new/index.js index 5de1d4d6344..f7c25347e75 100644 --- a/app/assets/javascripts/pages/admin/labels/new/index.js +++ b/app/assets/javascripts/pages/admin/labels/new/index.js @@ -1,3 +1,3 @@ import Labels from '../../../../labels'; -document.addEventListener('DOMContentLoaded', () => new Labels()); +new Labels(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index/index.js index 45ed3ac6bd8..45ed3ac6bd8 100644 --- a/app/assets/javascripts/pages/admin/runners/index.js +++ b/app/assets/javascripts/pages/admin/runners/index/index.js diff --git a/app/assets/javascripts/pages/admin/runners/show/index.js b/app/assets/javascripts/pages/admin/runners/show/index.js new file mode 100644 index 00000000000..d1853772fda --- /dev/null +++ b/app/assets/javascripts/pages/admin/runners/show/index.js @@ -0,0 +1,3 @@ +import { initRunnerDetail } from '~/runner/runner_details'; + +initRunnerDetail(); diff --git a/app/assets/javascripts/pages/admin/services/edit/index.js b/app/assets/javascripts/pages/admin/services/edit/index.js index 3d692ef4dcc..b8080ddff77 100644 --- a/app/assets/javascripts/pages/admin/services/edit/index.js +++ b/app/assets/javascripts/pages/admin/services/edit/index.js @@ -1,6 +1,4 @@ import IntegrationSettingsForm from '~/integrations/integration_settings_form'; -document.addEventListener('DOMContentLoaded', () => { - const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); -}); +const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); +integrationSettingsForm.init(); diff --git a/app/assets/javascripts/pages/admin/spam_logs/index.js b/app/assets/javascripts/pages/admin/spam_logs/index.js new file mode 100644 index 00000000000..e5ab5d43bbf --- /dev/null +++ b/app/assets/javascripts/pages/admin/spam_logs/index.js @@ -0,0 +1,3 @@ +import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; + +document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior); diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index d2b83f980d7..20407334b3f 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -119,8 +119,8 @@ export default { <gl-button @click="onCancel">{{ s__('Cancel') }}</gl-button> <gl-button :disabled="!canSubmit" - category="primary" - variant="warning" + category="secondary" + variant="danger" @click="onSecondaryAction" > {{ secondaryAction }} diff --git a/app/assets/javascripts/pages/admin/users/new/index.js b/app/assets/javascripts/pages/admin/users/new/index.js index 7b7d4c169ef..34c10e44f4c 100644 --- a/app/assets/javascripts/pages/admin/users/new/index.js +++ b/app/assets/javascripts/pages/admin/users/new/index.js @@ -1,51 +1,3 @@ -import $ from 'jquery'; +import { setupInternalUserRegexHandler } from '~/admin/users/new'; -export default class UserInternalRegexHandler { - constructor() { - this.regexPattern = $('[data-user-internal-regex-pattern]').data('user-internal-regex-pattern'); - if (this.regexPattern && this.regexPattern !== '') { - this.regexOptions = $('[data-user-internal-regex-options]').data( - 'user-internal-regex-options', - ); - this.external = $('#user_external'); - this.warningMessage = $('#warning_external_automatically_set'); - this.addListenerToEmailField(); - this.addListenerToUserExternalCheckbox(); - } - } - - addListenerToEmailField() { - $('#user_email').on('input', (event) => { - this.setExternalCheckbox(event.currentTarget.value); - }); - } - - addListenerToUserExternalCheckbox() { - this.external.on('click', () => { - this.warningMessage.addClass('hidden'); - }); - } - - isEmailInternal(email) { - const regex = new RegExp(this.regexPattern, this.regexOptions); - return regex.test(email); - } - - setExternalCheckbox(email) { - const isChecked = this.external.prop('checked'); - if (this.isEmailInternal(email)) { - if (isChecked) { - this.external.prop('checked', false); - this.warningMessage.removeClass('hidden'); - } - } else if (!isChecked) { - this.external.prop('checked', true); - this.warningMessage.addClass('hidden'); - } - } -} - -document.addEventListener('DOMContentLoaded', () => { - // eslint-disable-next-line - new UserInternalRegexHandler(); -}); +setupInternalUserRegexHandler(); diff --git a/app/assets/javascripts/pages/dashboard/activity/index.js b/app/assets/javascripts/pages/dashboard/activity/index.js index 1b887cad496..8b7c36a0976 100644 --- a/app/assets/javascripts/pages/dashboard/activity/index.js +++ b/app/assets/javascripts/pages/dashboard/activity/index.js @@ -1,3 +1,4 @@ import Activities from '~/activities'; -document.addEventListener('DOMContentLoaded', () => new Activities()); +// eslint-disable-next-line no-new +new Activities(); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index d53cd405504..42341436b55 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -1,6 +1,8 @@ /* eslint-disable class-methods-use-this, no-unneeded-ternary */ import $ from 'jquery'; +import { getGroups } from '~/api/groups_api'; +import { getProjects } from '~/api/projects_api'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -41,14 +43,37 @@ export default class Todos { } initFilters() { - this.initFilterDropdown($('.js-group-search'), 'group_id', ['text']); - this.initFilterDropdown($('.js-project-search'), 'project_id', ['text']); + this.initAjaxFilterDropdown(getGroups, $('.js-group-search'), 'group_id'); + this.initAjaxFilterDropdown(getProjects, $('.js-project-search'), 'project_id'); this.initFilterDropdown($('.js-type-search'), 'type'); this.initFilterDropdown($('.js-action-search'), 'action_id'); return new UsersSelect(); } + initAjaxFilterDropdown(apiMethod, $dropdown, fieldName) { + initDeprecatedJQueryDropdown($dropdown, { + fieldName, + selectable: true, + filterable: true, + filterRemote: true, + data(search, callback) { + return apiMethod(search, {}, (data) => { + callback( + data.map((d) => ({ + id: d.id, + text: d.full_name || d.name_with_namespace, + })), + ); + }); + }, + clicked: () => { + const $formEl = $dropdown.closest('form.filter-form'); + $formEl.submit(); + }, + }); + } + initFilterDropdown($dropdown, fieldName, searchFields) { initDeprecatedJQueryDropdown($dropdown, { fieldName, @@ -58,12 +83,6 @@ export default class Todos { data: $dropdown.data('data'), clicked: () => { const $formEl = $dropdown.closest('form.filter-form'); - const mutexDropdowns = { - group_id: 'project_id', - project_id: 'group_id', - }; - - $formEl.find(`input[name="${mutexDropdowns[fieldName]}"]`).remove(); $formEl.submit(); }, }); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 176d2406751..49b9822795c 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -4,6 +4,7 @@ import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import TransferDropdown from '~/groups/transfer_dropdown'; import groupsSelect from '~/groups_select'; +import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import projectSelect from '~/project_select'; import initSearchSettings from '~/search_settings'; @@ -26,6 +27,7 @@ document.addEventListener('DOMContentLoaded', () => { projectSelect(); initSearchSettings(); + initCascadingSettingsLockPopovers(); return new TransferDropdown(); }); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index ab70fa572ba..b0a70055835 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -8,6 +8,7 @@ import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigg import { s__ } from '~/locale'; import memberExpirationDate from '~/member_expiration_date'; import { initMembersApp } from '~/members'; +import { MEMBER_TYPES } from '~/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; import UsersSelect from '~/users_select'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; @@ -29,6 +30,7 @@ function mountRemoveMemberModal() { const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-group-members-list'), { + namespace: MEMBER_TYPES.user, tableFields: SHARED_FIELDS.concat(['source', 'granted']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], @@ -43,6 +45,7 @@ initMembersApp(document.querySelector('.js-group-members-list'), { }); initMembersApp(document.querySelector('.js-group-group-links-list'), { + namespace: MEMBER_TYPES.group, tableFields: SHARED_FIELDS.concat('granted'), tableAttrs: { table: { 'data-qa-selector': 'groups_list' }, @@ -51,6 +54,7 @@ initMembersApp(document.querySelector('.js-group-group-links-list'), { requestFormatter: groupLinkRequestFormatter, }); initMembersApp(document.querySelector('.js-group-invited-members-list'), { + namespace: MEMBER_TYPES.invite, tableFields: SHARED_FIELDS.concat('invited'), requestFormatter: groupMemberRequestFormatter, filteredSearchBar: { @@ -62,6 +66,7 @@ initMembersApp(document.querySelector('.js-group-invited-members-list'), { }, }); initMembersApp(document.querySelector('.js-group-access-requests-list'), { + namespace: MEMBER_TYPES.accessRequest, tableFields: SHARED_FIELDS.concat('requested'), requestFormatter: groupMemberRequestFormatter, }); diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js index 87d522d7654..95c2c7cd7d0 100644 --- a/app/assets/javascripts/pages/groups/labels/index/index.js +++ b/app/assets/javascripts/pages/groups/labels/index/index.js @@ -1,3 +1,5 @@ +import initDeleteLabelModal from '~/delete_label_modal'; import initLabels from '~/init_labels'; initLabels(); +initDeleteLabelModal(); diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js index 1d68ccd724d..301e0b4f7a2 100644 --- a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js +++ b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js @@ -1,7 +1,12 @@ +import { buildApiUrl } from '~/api/api_utils'; import axios from '~/lib/utils/axios_utils'; -const rootUrl = gon.relative_url_root; +const NAMESPACE_EXISTS_PATH = '/api/:version/namespaces/:id/exists'; -export default function fetchGroupPathAvailability(groupPath) { - return axios.get(`${rootUrl}/users/${groupPath}/suggests`); +export default function fetchGroupPathAvailability(groupPath, parentId) { + const url = buildApiUrl(NAMESPACE_EXISTS_PATH).replace(':id', encodeURIComponent(groupPath)); + + return axios.get(url, { + params: { parent_id: parentId }, + }); } diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js index 89dccea2812..a0ff98645fb 100644 --- a/app/assets/javascripts/pages/groups/new/group_path_validator.js +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -8,6 +8,7 @@ import fetchGroupPathAvailability from './fetch_group_path_availability'; const debounceTimeoutDuration = 1000; const invalidInputClass = 'gl-field-error-outline'; const successInputClass = 'gl-field-success-outline'; +const parentIdSelector = 'group_parent_id'; const successMessageSelector = '.validation-success'; const pendingMessageSelector = '.validation-pending'; const unavailableMessageSelector = '.validation-error'; @@ -20,9 +21,10 @@ export default class GroupPathValidator extends InputValidator { const container = opts.container || ''; const validateElements = document.querySelectorAll(`${container} .js-validate-group-path`); + const parentIdElement = document.getElementById(parentIdSelector); this.debounceValidateInput = debounce((inputDomElement) => { - GroupPathValidator.validateGroupPathInput(inputDomElement); + GroupPathValidator.validateGroupPathInput(inputDomElement, parentIdElement); }, debounceTimeoutDuration); validateElements.forEach((element) => @@ -37,13 +39,14 @@ export default class GroupPathValidator extends InputValidator { this.debounceValidateInput(inputDomElement); } - static validateGroupPathInput(inputDomElement) { + static validateGroupPathInput(inputDomElement, parentIdElement) { const groupPath = inputDomElement.value; + const parentId = parentIdElement.value; if (inputDomElement.checkValidity() && groupPath.length > 1) { GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); - fetchGroupPathAvailability(groupPath) + fetchGroupPathAvailability(groupPath, parentId) .then(({ data }) => data) .then((data) => { GroupPathValidator.setInputState(inputDomElement, !data.exists); diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 322ad2c79e7..569b5afd676 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -5,10 +5,8 @@ import Group from '~/group'; import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; import GroupPathValidator from './group_path_validator'; -const parentId = $('#group_parent_id'); -if (!parentId.val()) { - new GroupPathValidator(); // eslint-disable-line no-new -} +new GroupPathValidator(); // eslint-disable-line no-new + BindInOut.initAll(); initFilePickers(); diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index 0c3fdcf3e75..636eea5d7ac 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -4,7 +4,6 @@ import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; -import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; // Initialize expandable settings panels @@ -21,5 +20,3 @@ initSharedRunnersForm(); initVariableList(); initInstallRunner(); - -initSearchSettings(); diff --git a/app/assets/javascripts/pages/groups/settings/index.js b/app/assets/javascripts/pages/groups/settings/index.js new file mode 100644 index 00000000000..cb787c60002 --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/index.js @@ -0,0 +1,5 @@ +import initRevokeButton from '~/deploy_tokens/init_revoke_button'; +import initSearchSettings from '~/search_settings'; + +initSearchSettings(); +initRevokeButton(); diff --git a/app/assets/javascripts/pages/groups/settings/integrations/index.js b/app/assets/javascripts/pages/groups/settings/integrations/index.js new file mode 100644 index 00000000000..53068f72d3f --- /dev/null +++ b/app/assets/javascripts/pages/groups/settings/integrations/index.js @@ -0,0 +1,3 @@ +import initIntegrationsList from '~/integrations/index'; + +initIntegrationsList(); diff --git a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js index d13bf026777..3b922622d2c 100644 --- a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js +++ b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js @@ -1,6 +1,3 @@ import bundle from '~/packages_and_registries/settings/group/bundle'; -import initSearchSettings from '~/search_settings'; bundle(); - -document.addEventListener('DOMContentLoaded', initSearchSettings); diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js index 2c9867653de..92405f205cb 100644 --- a/app/assets/javascripts/pages/groups/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js @@ -1,10 +1,7 @@ import DueDateSelectors from '~/due_date_select'; -import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; // Initialize expandable settings panels initSettingsPanels(); new DueDateSelectors(); // eslint-disable-line no-new - -initSearchSettings(); diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index a24c6ca7754..b5441127797 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -7,81 +7,78 @@ import { __ } from '~/locale'; import EmojiMenu from './emoji_menu'; const defaultStatusEmoji = 'speech_balloon'; +const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu'; +const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector); +const statusEmojiField = document.getElementById('js-status-emoji-field'); +const statusMessageField = document.getElementById('js-status-message-field'); -document.addEventListener('DOMContentLoaded', () => { - const toggleEmojiMenuButtonSelector = '.js-toggle-emoji-menu'; - const toggleEmojiMenuButton = document.querySelector(toggleEmojiMenuButtonSelector); - const statusEmojiField = document.getElementById('js-status-emoji-field'); - const statusMessageField = document.getElementById('js-status-message-field'); +const toggleNoEmojiPlaceholder = (isVisible) => { + const placeholderElement = document.getElementById('js-no-emoji-placeholder'); + placeholderElement.classList.toggle('hidden', !isVisible); +}; - const toggleNoEmojiPlaceholder = (isVisible) => { - const placeholderElement = document.getElementById('js-no-emoji-placeholder'); - placeholderElement.classList.toggle('hidden', !isVisible); - }; +const findStatusEmoji = () => toggleEmojiMenuButton.querySelector('gl-emoji'); +const removeStatusEmoji = () => { + const statusEmoji = findStatusEmoji(); + if (statusEmoji) { + statusEmoji.remove(); + } +}; - const findStatusEmoji = () => toggleEmojiMenuButton.querySelector('gl-emoji'); - const removeStatusEmoji = () => { - const statusEmoji = findStatusEmoji(); - if (statusEmoji) { - statusEmoji.remove(); - } - }; +const selectEmojiCallback = (emoji, emojiTag) => { + statusEmojiField.value = emoji; + toggleNoEmojiPlaceholder(false); + removeStatusEmoji(); + toggleEmojiMenuButton.innerHTML += emojiTag; +}; - const selectEmojiCallback = (emoji, emojiTag) => { - statusEmojiField.value = emoji; - toggleNoEmojiPlaceholder(false); - removeStatusEmoji(); - toggleEmojiMenuButton.innerHTML += emojiTag; - }; - - const clearEmojiButton = document.getElementById('js-clear-user-status-button'); - clearEmojiButton.addEventListener('click', () => { - statusEmojiField.value = ''; - statusMessageField.value = ''; - removeStatusEmoji(); - toggleNoEmojiPlaceholder(true); - }); +const clearEmojiButton = document.getElementById('js-clear-user-status-button'); +clearEmojiButton.addEventListener('click', () => { + statusEmojiField.value = ''; + statusMessageField.value = ''; + removeStatusEmoji(); + toggleNoEmojiPlaceholder(true); +}); - const emojiAutocomplete = new GfmAutoComplete(); - emojiAutocomplete.setup($(statusMessageField), { emojis: true }); +const emojiAutocomplete = new GfmAutoComplete(); +emojiAutocomplete.setup($(statusMessageField), { emojis: true }); - const userNameInput = document.getElementById('user_name'); - userNameInput.addEventListener('input', () => { - const EMOJI_REGEX = emojiRegex(); - if (EMOJI_REGEX.test(userNameInput.value)) { - // set field to invalid so it gets detected by GlFieldErrors - userNameInput.setCustomValidity(__('Invalid field')); - } else { - userNameInput.setCustomValidity(''); - } - }); +const userNameInput = document.getElementById('user_name'); +userNameInput.addEventListener('input', () => { + const EMOJI_REGEX = emojiRegex(); + if (EMOJI_REGEX.test(userNameInput.value)) { + // set field to invalid so it gets detected by GlFieldErrors + userNameInput.setCustomValidity(__('Invalid field')); + } else { + userNameInput.setCustomValidity(''); + } +}); - Emoji.initEmojiMap() - .then(() => { - const emojiMenu = new EmojiMenu( - Emoji, - toggleEmojiMenuButtonSelector, - 'js-status-emoji-menu', - selectEmojiCallback, - ); - emojiMenu.bindEvents(); +Emoji.initEmojiMap() + .then(() => { + const emojiMenu = new EmojiMenu( + Emoji, + toggleEmojiMenuButtonSelector, + 'js-status-emoji-menu', + selectEmojiCallback, + ); + emojiMenu.bindEvents(); - const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji); - statusMessageField.addEventListener('input', () => { - const hasStatusMessage = statusMessageField.value.trim() !== ''; - const statusEmoji = findStatusEmoji(); - if (hasStatusMessage && statusEmoji) { - return; - } + const defaultEmojiTag = Emoji.glEmojiTag(defaultStatusEmoji); + statusMessageField.addEventListener('input', () => { + const hasStatusMessage = statusMessageField.value.trim() !== ''; + const statusEmoji = findStatusEmoji(); + if (hasStatusMessage && statusEmoji) { + return; + } - if (hasStatusMessage) { - toggleNoEmojiPlaceholder(false); - toggleEmojiMenuButton.innerHTML += defaultEmojiTag; - } else if (statusEmoji.dataset.name === defaultStatusEmoji) { - toggleNoEmojiPlaceholder(true); - removeStatusEmoji(); - } - }); - }) - .catch(() => createFlash(__('Failed to load emoji list.'))); -}); + if (hasStatusMessage) { + toggleNoEmojiPlaceholder(false); + toggleEmojiMenuButton.innerHTML += defaultEmojiTag; + } else if (statusEmoji.dataset.name === defaultStatusEmoji) { + toggleNoEmojiPlaceholder(true); + removeStatusEmoji(); + } + }); + }) + .catch(() => createFlash(__('Failed to load emoji list.'))); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 10bac6d60c2..fc2702b8c37 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -5,10 +5,29 @@ import GpgBadges from '~/gpg_badges'; import initBlob from '~/pages/projects/init_blob'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; +import BlobContentViewer from '~/repository/components/blob_content_viewer.vue'; import '~/sourcegraph/load'; -new BlobViewer(); // eslint-disable-line no-new -initBlob(); +const viewBlobEl = document.querySelector('#js-view-blob-app'); + +if (viewBlobEl) { + const { blobPath } = viewBlobEl.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: viewBlobEl, + render(createElement) { + return createElement(BlobContentViewer, { + props: { + path: blobPath, + }, + }); + }, + }); +} else { + new BlobViewer(); // eslint-disable-line no-new + initBlob(); +} const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); const statusLink = document.querySelector('.commit-actions .ci-status-link'); diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index 72861855c5a..27ec746ad02 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -1,7 +1,16 @@ +import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner'; +import BranchSortDropdown from '~/branches/branch_sort_dropdown'; import DeleteModal from '~/branches/branches_delete_modal'; import initDiverganceGraph from '~/branches/divergence_graph'; AjaxLoadingSpinner.init(); new DeleteModal(); // eslint-disable-line no-new -initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint); + +const { divergingCountsEndpoint, defaultBranch } = document.querySelector( + '.js-branch-list', +).dataset; + +initDiverganceGraph(divergingCountsEndpoint, defaultBranch); +BranchSortDropdown(); +initDeprecatedRemoveRowBehavior(); 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 7112b23775d..288d6711682 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 @@ -13,6 +13,7 @@ import { GlFormRadioGroup, GlFormSelect, } from '@gitlab/ui'; +import { kebabCase } from 'lodash'; import { buildApiUrl } from '~/api/api_utils'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; @@ -145,6 +146,10 @@ export default { this.fork.visibility = visibility; } }, + // eslint-disable-next-line func-names + 'fork.name': function (newVal) { + this.fork.slug = kebabCase(newVal); + }, }, mounted() { this.fetchNamespaces(); @@ -213,6 +218,7 @@ export default { id="fork-url" v-model="selectedNamespace" data-testid="fork-url-input" + data-qa-selector="fork_namespace_dropdown" required > <template slot="first"> @@ -286,6 +292,7 @@ export default { category="primary" variant="confirm" data-testid="submit-button" + data-qa-selector="fork_project_button" :loading="isSaving" > {{ s__('ForkProject|Fork project') }} diff --git a/app/assets/javascripts/pages/projects/hooks/index.js b/app/assets/javascripts/pages/projects/hooks/index.js new file mode 100644 index 00000000000..2a120a690ef --- /dev/null +++ b/app/assets/javascripts/pages/projects/hooks/index.js @@ -0,0 +1,3 @@ +import initSearchSettings from '~/search_settings'; + +initSearchSettings(); diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index 4e35f28ab06..c0da0069a99 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -5,6 +5,7 @@ import IssuableForm from 'ee_else_ce/issuable_form'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; import initSuggestions from '~/issuable_suggestions'; +import initIssuableTypeSelector from '~/issuable_type_selector'; import LabelsSelect from '~/labels_select'; import MilestoneSelect from '~/milestone_select'; import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; @@ -20,4 +21,5 @@ export default () => { }); initSuggestions(); + initIssuableTypeSelector(); }; diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index 366f8dc61bc..85489ae8687 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -20,7 +20,12 @@ initFilteredSearch({ useDefaultState: true, }); -new IssuableIndex(ISSUABLE_INDEX.ISSUE); +if (gon.features?.vueIssuesList) { + new IssuableIndex(); +} else { + new IssuableIndex(ISSUABLE_INDEX.ISSUE); +} + new ShortcutsNavigation(); new UsersSelect(); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 992bf3c54ff..2b679a83eac 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -3,6 +3,8 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import initIssuableSidebar from '~/init_issuable_sidebar'; import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { IssuableType } from '~/issuable_show/constants'; import Issue from '~/issue'; import '~/notes/index'; @@ -34,6 +36,8 @@ export default function initShowIssue() { initIssueHeaderActions(store); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); + initInviteMembersModal(); + initInviteMembersTrigger(); import(/* webpackChunkName: 'design_management' */ '~/design_management') .then((module) => module.default()) @@ -42,10 +46,18 @@ export default function initShowIssue() { new ZenMode(); // eslint-disable-line no-new if (issueType !== IssuableType.TestCase) { + const awardEmojiEl = document.getElementById('js-vue-awards-block'); + new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new initIssuableSidebar(); - loadAwardsHandler(); + if (awardEmojiEl) { + import('~/emoji/awards_app') + .then((m) => m.default(awardEmojiEl)) + .catch(() => {}); + } else { + loadAwardsHandler(); + } initInviteMemberModal(); initInviteMemberTrigger(); } diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js index 681d151b77f..75194499a7f 100644 --- a/app/assets/javascripts/pages/projects/jobs/index/index.js +++ b/app/assets/javascripts/pages/projects/jobs/index/index.js @@ -1,17 +1,23 @@ import Vue from 'vue'; +import initJobsTable from '~/jobs/components/table'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; -const remainingTimeElements = document.querySelectorAll('.js-remaining-time'); -remainingTimeElements.forEach( - (el) => - new Vue({ - el, - render(h) { - return h(GlCountdown, { - props: { - endDateString: el.dateTime, - }, - }); - }, - }), -); +if (gon.features?.jobsTableVue) { + initJobsTable(); +} else { + const remainingTimeElements = document.querySelectorAll('.js-remaining-time'); + + remainingTimeElements.forEach( + (el) => + new Vue({ + el, + render(h) { + return h(GlCountdown, { + props: { + endDateString: el.dateTime, + }, + }); + }, + }), + ); +} diff --git a/app/assets/javascripts/pages/projects/jobs/show/index.js b/app/assets/javascripts/pages/projects/jobs/show/index.js index d57dbeb1242..6fef057dee0 100644 --- a/app/assets/javascripts/pages/projects/jobs/show/index.js +++ b/app/assets/javascripts/pages/projects/jobs/show/index.js @@ -1,3 +1,3 @@ import initJobDetails from '~/jobs'; -document.addEventListener('DOMContentLoaded', initJobDetails); +initJobDetails(); diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js index 9f782c07101..94ab0d64de4 100644 --- a/app/assets/javascripts/pages/projects/labels/index/index.js +++ b/app/assets/javascripts/pages/projects/labels/index/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import initDeleteLabelModal from '~/delete_label_modal'; import initLabels from '~/init_labels'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import Translate from '~/vue_shared/translate'; @@ -9,6 +10,7 @@ Vue.use(Translate); const initLabelIndex = () => { initLabels(); + initDeleteLabelModal(); const onRequestFinished = ({ labelUrl, successful }) => { const button = document.querySelector( diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue index 32ca623ca45..ef9e13f7ccf 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue @@ -1,11 +1,17 @@ <script> -import { GlLink } from '@gitlab/ui'; -import { ACTION_LABELS } from '../constants'; +import { GlProgressBar, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; +import LearnGitlabSectionCard from './learn_gitlab_section_card.vue'; export default { - components: { GlLink }, + components: { GlProgressBar, GlSprintf, LearnGitlabSectionCard }, i18n: { - ACTION_LABELS, + title: s__('LearnGitLab|Learn GitLab'), + description: s__( + 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.', + ), + percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`), }, props: { actions: { @@ -13,15 +19,49 @@ export default { type: Object, }, }, + maxValue: Object.keys(ACTION_LABELS).length, + sections: Object.keys(ACTION_SECTIONS), + computed: { + progressValue() { + return Object.values(this.actions).filter((a) => a.completed).length; + }, + progressPercentage() { + return Math.round((this.progressValue / this.$options.maxValue) * 100); + }, + }, + methods: { + actionsFor(section) { + const actions = Object.fromEntries( + Object.entries(this.actions).filter( + ([action]) => ACTION_LABELS[action].section === section, + ), + ); + return actions; + }, + }, }; </script> <template> - <ul> - <li v-for="(value, action) in actions" :key="action"> - <span v-if="value.completed">{{ $options.i18n.ACTION_LABELS[action].title }}</span> - <span v-else> - <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link> - </span> - </li> - </ul> + <div> + <div class="row"> + <div class="gl-mb-7 gl-ml-5"> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p> + </div> + </div> + <div class="gl-mb-3"> + <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage"> + <gl-sprintf :message="$options.i18n.percentageCompleted"> + <template #percentage>{{ progressPercentage }}</template> + <template #percentSymbol>%</template> + </gl-sprintf> + </p> + <gl-progress-bar :value="progressValue" :max="$options.maxValue" /> + </div> + <div class="row row-cols-1 row-cols-md-3 gl-mt-5"> + <div v-for="section in $options.sections" :key="section" class="col gl-mb-6"> + <learn-gitlab-section-card :section="section" :actions="actionsFor(section)" /> + </div> + </div> + </div> </template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue index 230054ff76e..8f92ce95dbf 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue @@ -1,5 +1,6 @@ <script> import { GlProgressBar, GlSprintf } from '@gitlab/ui'; +import { pick } from 'lodash'; import { s__ } from '~/locale'; import { ACTION_LABELS } from '../constants'; import LearnGitlabInfoCard from './learn_gitlab_info_card.vue'; @@ -42,7 +43,7 @@ export default { infoProps(action) { return { ...this.actions[action], - ...ACTION_LABELS[action], + ...pick(ACTION_LABELS[action], ['title', 'actionLabel', 'description', 'trialRequired']), }; }, progressValue() { @@ -96,6 +97,9 @@ export default { <div class="row row-cols-2 row-cols-md-3 row-cols-lg-4"> <div class="col gl-mb-6"> + <learn-gitlab-info-card v-bind="infoProps('issueCreated')" /> + </div> + <div class="col gl-mb-6"> <learn-gitlab-info-card v-bind="infoProps('mergeRequestCreated')" /> </div> </div> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue index 3d2a8eed9d4..6cd3bbc359b 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue @@ -61,7 +61,7 @@ export default { <div class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" > - <img :src="svg" /> + <img :src="svg" :alt="actionLabel" /> <h6>{{ title }}</h6> <p class="gl-font-sm gl-text-gray-700">{{ description }}</p> <gl-link :href="url" target="_blank">{{ actionLabel }}</gl-link> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue new file mode 100644 index 00000000000..db694a66afd --- /dev/null +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_card.vue @@ -0,0 +1,52 @@ +<script> +import { GlCard } from '@gitlab/ui'; +import { imagePath } from '~/lib/utils/common_utils'; +import { ACTION_LABELS, ACTION_SECTIONS } from '../constants'; + +import LearnGitlabSectionLink from './learn_gitlab_section_link.vue'; + +export default { + name: 'LearnGitlabSectionCard', + components: { GlCard, LearnGitlabSectionLink }, + i18n: { + ...ACTION_SECTIONS, + }, + props: { + section: { + required: true, + type: String, + }, + actions: { + required: true, + type: Object, + }, + }, + computed: { + sortedActions() { + return Object.entries(this.actions).sort( + (a1, a2) => ACTION_LABELS[a1[0]].position - ACTION_LABELS[a2[0]].position, + ); + }, + }, + methods: { + svg(section) { + return imagePath(`learn_gitlab/section_${section}.svg`); + }, + }, +}; +</script> +<template> + <gl-card class="gl-pt-0 learn-gitlab-section-card"> + <div class="learn-gitlab-section-card-header"> + <img :src="svg(section)" /> + <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n[section].title }}</h2> + <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n[section].description }}</p> + </div> + <learn-gitlab-section-link + v-for="[action, value] in sortedActions" + :key="action" + :action="action" + :value="value" + /> + </gl-card> +</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue new file mode 100644 index 00000000000..6f51c7372fd --- /dev/null +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue @@ -0,0 +1,43 @@ +<script> +import { GlLink, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { ACTION_LABELS } from '../constants'; + +export default { + name: 'LearnGitlabSectionLink', + components: { GlLink, GlIcon }, + i18n: { + ACTION_LABELS, + trialOnly: s__('LearnGitlab|Trial only'), + }, + props: { + action: { + required: true, + type: String, + }, + value: { + required: true, + type: Object, + }, + }, + computed: { + trialOnly() { + return ACTION_LABELS[this.action].trialRequired; + }, + }, +}; +</script> +<template> + <div class="gl-mb-4"> + <span v-if="value.completed" class="gl-text-green-500"> + <gl-icon name="check-circle-filled" :size="16" data-testid="completed-icon" /> + {{ $options.i18n.ACTION_LABELS[action].title }} + </span> + <span v-else> + <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link> + </span> + <span v-if="trialOnly" class="gl-font-style-italic gl-text-gray-500" data-testid="trial-only"> + - {{ $options.i18n.trialOnly }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js index 80f04b0cf44..9e204aa6746 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js @@ -5,6 +5,8 @@ export const ACTION_LABELS = { title: s__('LearnGitLab|Create or import a repository'), actionLabel: s__('LearnGitLab|Create or import a repository'), description: s__('LearnGitLab|Create or import your first repository into your new project.'), + section: 'workspace', + position: 1, }, userAdded: { title: s__('LearnGitLab|Invite your colleagues'), @@ -12,16 +14,22 @@ export const ACTION_LABELS = { description: s__( 'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.', ), + section: 'workspace', + position: 0, }, pipelineCreated: { title: s__('LearnGitLab|Set up CI/CD'), actionLabel: s__('LearnGitLab|Set-up CI/CD'), description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'), + section: 'workspace', + position: 2, }, trialStarted: { title: s__('LearnGitLab|Start a free Ultimate trial'), actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'), description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'), + section: 'workspace', + position: 3, }, codeOwnersEnabled: { title: s__('LearnGitLab|Add code owners'), @@ -30,21 +38,59 @@ export const ACTION_LABELS = { 'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.', ), trialRequired: true, + section: 'workspace', + position: 4, }, requiredMrApprovalsEnabled: { title: s__('LearnGitLab|Add merge request approval'), actionLabel: s__('LearnGitLab|Enable require merge approvals'), description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'), trialRequired: true, + section: 'workspace', + position: 5, }, mergeRequestCreated: { title: s__('LearnGitLab|Submit a merge request'), actionLabel: s__('LearnGitLab|Submit a merge request (MR)'), description: s__('LearnGitLab|Review and edit proposed changes to source code.'), + section: 'plan', + position: 1, }, securityScanEnabled: { - title: s__('LearnGitLab|Run a security scan'), - actionLabel: s__('LearnGitLab|Run a Security scan'), + title: s__('LearnGitLab|Run a Security scan using CI/CD'), + actionLabel: s__('LearnGitLab|Run a Security scan using CI/CD'), description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'), + section: 'deploy', + position: 1, + }, + issueCreated: { + title: s__('LearnGitLab|Create an issue'), + actionLabel: s__('LearnGitLab|Create an issue'), + description: s__( + 'LearnGitLab|Create/import issues (tickets) to collaborate on ideas and plan work.', + ), + section: 'plan', + position: 0, + }, +}; + +export const ACTION_SECTIONS = { + workspace: { + title: s__('LearnGitLab|Set up your workspace'), + description: s__( + "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:", + ), + }, + plan: { + title: s__('LearnGitLab|Plan and execute'), + description: s__( + 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:', + ), + }, + deploy: { + title: s__('LearnGitLab|Deploy'), + description: s__( + 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:', + ), }, }; diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index d4d5e9f2711..a5118e3529a 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -5,21 +5,33 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initIssuableSidebar from '~/init_issuable_sidebar'; import initInviteMemberModal from '~/invite_member/init_invite_member_modal'; import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; +import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { handleLocationHash } from '~/lib/utils/common_utils'; import StatusBox from '~/merge_request/components/status_box.vue'; import initSourcegraph from '~/sourcegraph'; import ZenMode from '~/zen_mode'; export default function initMergeRequestShow() { + const awardEmojiEl = document.getElementById('js-vue-awards-block'); + new ZenMode(); // eslint-disable-line no-new initIssuableSidebar(); initPipelines(); new ShortcutsIssuable(true); // eslint-disable-line no-new handleLocationHash(); initSourcegraph(); - loadAwardsHandler(); + if (awardEmojiEl) { + import('~/emoji/awards_app') + .then((m) => m.default(awardEmojiEl)) + .catch(() => {}); + } else { + loadAwardsHandler(); + } initInviteMemberModal(); initInviteMemberTrigger(); + initInviteMembersModal(); + initInviteMembersTrigger(); const el = document.querySelector('.js-mr-status-box'); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js new file mode 100644 index 00000000000..dfb750eca41 --- /dev/null +++ b/app/assets/javascripts/pages/projects/packages/infrastructure_registry/index/index.js @@ -0,0 +1,3 @@ +import initList from '~/packages_and_registries/infrastructure_registry/list_app_bundle'; + +initList(); diff --git a/app/assets/javascripts/pages/projects/pages/index.js b/app/assets/javascripts/pages/projects/pages/index.js new file mode 100644 index 00000000000..2a120a690ef --- /dev/null +++ b/app/assets/javascripts/pages/projects/pages/index.js @@ -0,0 +1,3 @@ +import initSearchSettings from '~/search_settings'; + +initSearchSettings(); diff --git a/app/assets/javascripts/pages/projects/path_locks/index.js b/app/assets/javascripts/pages/projects/path_locks/index.js new file mode 100644 index 00000000000..e5ab5d43bbf --- /dev/null +++ b/app/assets/javascripts/pages/projects/path_locks/index.js @@ -0,0 +1,3 @@ +import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; + +document.addEventListener('DOMContentLoaded', initDeprecatedRemoveRowBehavior); 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 3b19231720a..159c619e16c 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 @@ -139,7 +139,7 @@ export default { v-model="cronInterval" :placeholder="__('Define a custom pattern with cron syntax')" :name="inputNameAttribute" - class="form-control inline cron-interval-input" + class="form-control inline cron-interval-input gl-form-input" type="text" required="true" @input="onCustomInput" diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index da8dc527d79..91f376060f8 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -123,10 +123,19 @@ export default class Project { const loc = window.location.href; if (loc.includes('/-/')) { - const refs = this.fullData.Branches.concat(this.fullData.Tags); - const currentRef = refs.find((ref) => loc.indexOf(ref) > -1); - if (currentRef) { - const targetPath = loc.split(currentRef)[1].slice(1).split('#')[0]; + // Since the current ref in renderRow is outdated on page changes + // (To be addressed in: https://gitlab.com/gitlab-org/gitlab/-/issues/327085) + // We are deciphering the current ref from the dropdown data instead + const currentRef = $dropdown.data('ref'); + // The split and startWith is to ensure an exact word match + // and avoid partial match ie. currentRef is "dev" and loc is "development" + const splitPathAfterRefPortion = loc.split(currentRef)[1]; + const doesPathContainRef = splitPathAfterRefPortion?.startsWith('/'); + + if (doesPathContainRef) { + // We are ignoring the url containing the ref portion + // and plucking the thereafter portion to reconstructure the url that is correct + const targetPath = splitPathAfterRefPortion?.slice(1).split('#')[0]; selectedUrl.searchParams.set('path', targetPath); selectedUrl.hash = window.location.hash; } diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index 4aea5614bfb..471798d2931 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -7,6 +7,7 @@ import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigg import { s__ } from '~/locale'; import memberExpirationDate from '~/member_expiration_date'; import { initMembersApp } from '~/members'; +import { MEMBER_TYPES } from '~/members/constants'; import { groupLinkRequestFormatter } from '~/members/utils'; import { projectMemberRequestFormatter } from '~/projects/members/utils'; import UsersSelect from '~/users_select'; @@ -42,6 +43,7 @@ new UsersSelect(); // eslint-disable-line no-new const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; initMembersApp(document.querySelector('.js-project-members-list'), { + namespace: MEMBER_TYPES.user, tableFields: SHARED_FIELDS.concat(['source', 'granted']), tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], @@ -56,6 +58,7 @@ initMembersApp(document.querySelector('.js-project-members-list'), { }); initMembersApp(document.querySelector('.js-project-group-links-list'), { + namespace: MEMBER_TYPES.group, tableFields: SHARED_FIELDS.concat('granted'), tableAttrs: { table: { 'data-qa-selector': 'groups_list' }, @@ -72,11 +75,13 @@ initMembersApp(document.querySelector('.js-project-group-links-list'), { }); initMembersApp(document.querySelector('.js-project-invited-members-list'), { + namespace: MEMBER_TYPES.invite, tableFields: SHARED_FIELDS.concat('invited'), requestFormatter: projectMemberRequestFormatter, }); initMembersApp(document.querySelector('.js-project-access-requests-list'), { + namespace: MEMBER_TYPES.accessRequest, tableFields: SHARED_FIELDS.concat('requested'), requestFormatter: projectMemberRequestFormatter, }); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index b7e8d4b03ac..be9259ec3ca 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -6,7 +6,6 @@ import initDeployFreeze from '~/deploy_freeze'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; -import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { @@ -43,6 +42,4 @@ document.addEventListener('DOMContentLoaded', () => { } initInstallRunner(); - - initSearchSettings(); }); diff --git a/app/assets/javascripts/pages/projects/settings/index.js b/app/assets/javascripts/pages/projects/settings/index.js new file mode 100644 index 00000000000..cb787c60002 --- /dev/null +++ b/app/assets/javascripts/pages/projects/settings/index.js @@ -0,0 +1,5 @@ +import initRevokeButton from '~/deploy_tokens/init_revoke_button'; +import initSearchSettings from '~/search_settings'; + +initSearchSettings(); +initRevokeButton(); diff --git a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js index bf9ccdbf9a8..01ad87160c5 100644 --- a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js @@ -1,4 +1,7 @@ +import initIntegrationsList from '~/integrations/index'; import PersistentUserCallout from '~/persistent_user_callout'; const callout = document.querySelector('.js-webhooks-moved-alert'); PersistentUserCallout.factory(callout); + +initIntegrationsList(); diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 4a800ab150d..3a46241e2eb 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -3,7 +3,6 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; import mountGrafanaIntegration from '~/grafana_integration'; import initIncidentsSettings from '~/incidents_settings'; import mountOperationSettings from '~/operation_settings'; -import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; initIncidentsSettings(); @@ -14,7 +13,3 @@ if (!IS_EE) { initSettingsPanels(); } mountAlertsSettings(document.querySelector('.js-alerts-settings')); - -document.addEventListener('DOMContentLoaded', () => { - initSearchSettings(); -}); diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index c7bcbb83051..e90954c14c5 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,5 +1,4 @@ import MirrorRepos from '~/mirrors/mirror_repos'; -import initSearchSettings from '~/search_settings'; import initForm from '../form'; document.addEventListener('DOMContentLoaded', () => { @@ -7,6 +6,4 @@ document.addEventListener('DOMContentLoaded', () => { const mirrorReposContainer = document.querySelector('.js-mirror-settings'); if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init(); - - initSearchSettings(); }); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue index d62df77ad2c..c110c1d4d62 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue @@ -12,6 +12,11 @@ export default { event: 'change', }, props: { + label: { + type: String, + required: false, + default: '', + }, name: { type: String, required: false, @@ -82,6 +87,8 @@ export default { class="gl-mr-3" :value="featureEnabled" :disabled="disabledInput" + :label="label" + label-position="hidden" @change="toggleFeature" /> <div class="select-wrapper gl-flex-fill-1"> 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 0b58cb4731d..0b7b4c0ded1 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 @@ -22,6 +22,21 @@ const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone'); export default { i18n: { ...CVE_ID_REQUEST_BUTTON_I18N, + analyticsLabel: s__('ProjectSettings|Analytics'), + containerRegistryLabel: s__('ProjectSettings|Container registry'), + forksLabel: s__('ProjectSettings|Forks'), + issuesLabel: s__('ProjectSettings|Issues'), + lfsLabel: s__('ProjectSettings|Git Large File Storage (LFS)'), + mergeRequestsLabel: s__('ProjectSettings|Merge requests'), + operationsLabel: s__('ProjectSettings|Operations'), + packagesLabel: s__('ProjectSettings|Packages'), + pagesLabel: s__('ProjectSettings|Pages'), + ciCdLabel: s__('CI/CD'), + repositoryLabel: s__('ProjectSettings|Repository'), + requirementsLabel: s__('ProjectSettings|Requirements'), + securityAndComplianceLabel: s__('ProjectSettings|Security & Compliance'), + snippetsLabel: s__('ProjectSettings|Snippets'), + wikiLabel: s__('ProjectSettings|Wiki'), }, components: { @@ -423,11 +438,12 @@ export default { > <project-setting-row ref="issues-settings" - :label="s__('ProjectSettings|Issues')" + :label="$options.i18n.issuesLabel" :help-text="s__('ProjectSettings|Lightweight issue tracking system.')" > <project-feature-setting v-model="issuesAccessLevel" + :label="$options.i18n.issuesLabel" :options="featureAccessLevelOptions" name="project[project_feature_attributes][issues_access_level]" /> @@ -440,6 +456,8 @@ export default { v-model="cveIdRequestEnabled" class="gl-my-2" :disabled="cveIdRequestIsDisabled" + :label="$options.i18n.cve_request_toggle_label" + label-position="hidden" name="project[project_setting_attributes][cve_id_request_enabled]" data-testid="cve_id_request_toggle" /> @@ -447,11 +465,12 @@ export default { </project-setting-row> <project-setting-row ref="repository-settings" - :label="s__('ProjectSettings|Repository')" + :label="$options.i18n.repositoryLabel" :help-text="repositoryHelpText" > <project-feature-setting v-model="repositoryAccessLevel" + :label="$options.i18n.repositoryLabel" :options="featureAccessLevelOptions" name="project[project_feature_attributes][repository_access_level]" /> @@ -459,11 +478,12 @@ export default { <div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5"> <project-setting-row ref="merge-request-settings" - :label="s__('ProjectSettings|Merge requests')" + :label="$options.i18n.mergeRequestsLabel" :help-text="s__('ProjectSettings|Submit changes to be merged upstream.')" > <project-feature-setting v-model="mergeRequestsAccessLevel" + :label="$options.i18n.mergeRequestsLabel" :options="repoFeatureAccessLevelOptions" :disabled-input="!repositoryEnabled" name="project[project_feature_attributes][merge_requests_access_level]" @@ -471,33 +491,22 @@ export default { </project-setting-row> <project-setting-row ref="fork-settings" - :label="s__('ProjectSettings|Forks')" + :label="$options.i18n.forksLabel" :help-text="s__('ProjectSettings|Users can copy the repository to a new project.')" > <project-feature-setting v-model="forkingAccessLevel" + :label="$options.i18n.forksLabel" :options="featureAccessLevelOptions" :disabled-input="!repositoryEnabled" name="project[project_feature_attributes][forking_access_level]" /> </project-setting-row> <project-setting-row - ref="pipeline-settings" - :label="s__('ProjectSettings|Pipelines')" - :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')" - > - <project-feature-setting - v-model="buildsAccessLevel" - :options="repoFeatureAccessLevelOptions" - :disabled-input="!repositoryEnabled" - name="project[project_feature_attributes][builds_access_level]" - /> - </project-setting-row> - <project-setting-row v-if="registryAvailable" ref="container-registry-settings" :help-path="registryHelpPath" - :label="s__('ProjectSettings|Container registry')" + :label="$options.i18n.containerRegistryLabel" :help-text=" s__('ProjectSettings|Every project can have its own space to store its Docker images') " @@ -513,6 +522,8 @@ export default { v-model="containerRegistryEnabled" class="gl-my-2" :disabled="!repositoryEnabled" + :label="$options.i18n.containerRegistryLabel" + label-position="hidden" name="project[container_registry_enabled]" /> </project-setting-row> @@ -520,7 +531,7 @@ export default { v-if="lfsAvailable" ref="git-lfs-settings" :help-path="lfsHelpPath" - :label="s__('ProjectSettings|Git Large File Storage (LFS)')" + :label="$options.i18n.lfsLabel" :help-text=" s__('ProjectSettings|Manages large files such as audio, video, and graphics files.') " @@ -529,6 +540,8 @@ export default { v-model="lfsEnabled" class="gl-my-2" :disabled="!repositoryEnabled" + :label="$options.i18n.lfsLabel" + label-position="hidden" name="project[lfs_enabled]" /> <p v-if="!lfsEnabled && lfsObjectsExist"> @@ -553,7 +566,7 @@ export default { v-if="packagesAvailable" ref="package-settings" :help-path="packagesHelpPath" - :label="s__('ProjectSettings|Packages')" + :label="$options.i18n.packagesLabel" :help-text=" s__('ProjectSettings|Every project can have its own space to store its packages.') " @@ -562,17 +575,33 @@ export default { v-model="packagesEnabled" class="gl-my-2" :disabled="!repositoryEnabled" + :label="$options.i18n.packagesLabel" + label-position="hidden" name="project[packages_enabled]" /> </project-setting-row> </div> <project-setting-row + ref="pipeline-settings" + :label="$options.i18n.ciCdLabel" + :help-text="s__('ProjectSettings|Build, test, and deploy your changes.')" + > + <project-feature-setting + v-model="buildsAccessLevel" + :label="$options.i18n.ciCdLabel" + :options="repoFeatureAccessLevelOptions" + :disabled-input="!repositoryEnabled" + name="project[project_feature_attributes][builds_access_level]" + /> + </project-setting-row> + <project-setting-row ref="analytics-settings" - :label="s__('ProjectSettings|Analytics')" + :label="$options.i18n.analyticsLabel" :help-text="s__('ProjectSettings|View project analytics.')" > <project-feature-setting v-model="analyticsAccessLevel" + :label="$options.i18n.analyticsLabel" :options="featureAccessLevelOptions" name="project[project_feature_attributes][analytics_access_level]" /> @@ -580,43 +609,47 @@ export default { <project-setting-row v-if="requirementsAvailable" ref="requirements-settings" - :label="s__('ProjectSettings|Requirements')" + :label="$options.i18n.requirementsLabel" :help-text="s__('ProjectSettings|Requirements management system.')" > <project-feature-setting v-model="requirementsAccessLevel" + :label="$options.i18n.requirementsLabel" :options="featureAccessLevelOptions" name="project[project_feature_attributes][requirements_access_level]" /> </project-setting-row> <project-setting-row - :label="s__('ProjectSettings|Security & Compliance')" + :label="$options.i18n.securityAndComplianceLabel" :help-text="s__('ProjectSettings|Security & Compliance for this project')" > <project-feature-setting v-model="securityAndComplianceAccessLevel" + :label="$options.i18n.securityAndComplianceLabel" :options="featureAccessLevelOptions" name="project[project_feature_attributes][security_and_compliance_access_level]" /> </project-setting-row> <project-setting-row ref="wiki-settings" - :label="s__('ProjectSettings|Wiki')" + :label="$options.i18n.wikiLabel" :help-text="s__('ProjectSettings|Pages for project documentation.')" > <project-feature-setting v-model="wikiAccessLevel" + :label="$options.i18n.wikiLabel" :options="featureAccessLevelOptions" name="project[project_feature_attributes][wiki_access_level]" /> </project-setting-row> <project-setting-row ref="snippet-settings" - :label="s__('ProjectSettings|Snippets')" + :label="$options.i18n.snippetsLabel" :help-text="s__('ProjectSettings|Share code with others outside the project.')" > <project-feature-setting v-model="snippetsAccessLevel" + :label="$options.i18n.snippetsLabel" :options="featureAccessLevelOptions" name="project[project_feature_attributes][snippets_access_level]" /> @@ -625,26 +658,28 @@ export default { v-if="pagesAvailable && pagesAccessControlEnabled" ref="pages-settings" :help-path="pagesHelpPath" - :label="s__('ProjectSettings|Pages')" + :label="$options.i18n.pagesLabel" :help-text=" s__('ProjectSettings|With GitLab Pages you can host your static websites on GitLab.') " > <project-feature-setting v-model="pagesAccessLevel" + :label="$options.i18n.pagesLabel" :options="pagesFeatureAccessLevelOptions" name="project[project_feature_attributes][pages_access_level]" /> </project-setting-row> <project-setting-row ref="operations-settings" - :label="s__('ProjectSettings|Operations')" + :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]" /> diff --git a/app/assets/javascripts/pages/projects/tags/index/index.js b/app/assets/javascripts/pages/projects/tags/index/index.js index 98560c1193b..9e48dd9e463 100644 --- a/app/assets/javascripts/pages/projects/tags/index/index.js +++ b/app/assets/javascripts/pages/projects/tags/index/index.js @@ -1,3 +1,4 @@ +import TagSortDropdown from '~/tags'; import { initRemoveTag } from '../remove_tag'; initRemoveTag({ @@ -5,3 +6,4 @@ initRemoveTag({ document.querySelector(`[data-path="${path}"]`).closest('.js-tag-list').remove(); }, }); +TagSortDropdown(); diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js index 51028e585b8..e83c73edfde 100644 --- a/app/assets/javascripts/pages/shared/mount_runner_instructions.js +++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import InstallRunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; +import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; Vue.use(VueApollo); @@ -10,7 +10,6 @@ export function initInstallRunner(componentId = 'js-install-runner') { if (installRunnerEl) { const defaultClient = createDefaultClient(); - const { projectPath, groupPath } = installRunnerEl.dataset; const apolloProvider = new VueApollo({ defaultClient, @@ -20,12 +19,8 @@ export function initInstallRunner(componentId = 'js-install-runner') { new Vue({ el: installRunnerEl, apolloProvider, - provide: { - projectPath, - groupPath, - }, render(createElement) { - return createElement(InstallRunnerInstructions); + return createElement(RunnerInstructions); }, }); } diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue new file mode 100644 index 00000000000..6afc33ec8a5 --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -0,0 +1,253 @@ +<script> +import { GlForm, GlIcon, GlLink, GlButton, GlSprintf } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { setUrlFragment } from '~/lib/utils/url_utility'; +import { __, s__, sprintf } from '~/locale'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; + +const MARKDOWN_LINK_TEXT = { + markdown: '[Link Title](page-slug)', + rdoc: '{Link title}[link:page-slug]', + asciidoc: 'link:page-slug[Link title]', + org: '[[page-slug]]', +}; + +export default { + components: { + GlForm, + GlSprintf, + GlIcon, + GlLink, + GlButton, + MarkdownField, + }, + inject: ['formatOptions', 'pageInfo'], + data() { + return { + title: this.pageInfo.title?.trim() || '', + format: this.pageInfo.format || 'markdown', + content: this.pageInfo.content?.trim() || '', + commitMessage: '', + }; + }, + computed: { + csrfToken() { + return csrf.token; + }, + formAction() { + return this.pageInfo.persisted ? this.pageInfo.path : this.pageInfo.createPath; + }, + helpPath() { + return setUrlFragment( + this.pageInfo.helpPath, + this.pageInfo.persisted ? 'move-a-wiki-page' : 'create-a-new-wiki-page', + ); + }, + commitMessageI18n() { + return this.pageInfo.persisted + ? s__('WikiPage|Update %{pageTitle}') + : s__('WikiPage|Create %{pageTitle}'); + }, + linkExample() { + return MARKDOWN_LINK_TEXT[this.format]; + }, + submitButtonText() { + if (this.pageInfo.persisted) return __('Save changes'); + return s__('WikiPage|Create page'); + }, + cancelFormPath() { + if (this.pageInfo.persisted) return this.pageInfo.path; + return this.pageInfo.wikiPath; + }, + wikiSpecificMarkdownHelpPath() { + return setUrlFragment(this.pageInfo.markdownHelpPath, 'wiki-specific-markdown'); + }, + }, + mounted() { + this.updateCommitMessage(); + }, + methods: { + handleFormSubmit() { + window.removeEventListener('beforeunload', this.onBeforeUnload); + }, + + handleContentChange() { + window.addEventListener('beforeunload', this.onBeforeUnload); + }, + + onBeforeUnload() { + return ''; + }, + + updateCommitMessage() { + if (!this.title) return; + + // Replace hyphens with spaces + const newTitle = this.title.replace(/-+/g, ' '); + + const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: newTitle }, false); + this.commitMessage = newCommitMessage; + }, + }, +}; +</script> + +<template> + <gl-form + :action="formAction" + method="post" + class="wiki-form common-note-form gl-mt-3 js-quick-submit" + @submit="handleFormSubmit" + > + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + <input v-if="pageInfo.persisted" type="hidden" name="_method" value="put" /> + <input + :v-if="pageInfo.persisted" + type="hidden" + name="wiki[last_commit_sha]" + :value="pageInfo.lastCommitSha" + /> + <div class="form-group row"> + <div class="col-sm-2 col-form-label"> + <label class="control-label-full-width" for="wiki_title">{{ s__('WikiPage|Title') }}</label> + </div> + <div class="col-sm-10"> + <input + id="wiki_title" + v-model.trim="title" + name="wiki[title]" + type="text" + class="form-control" + data-qa-selector="wiki_title_textbox" + :required="true" + :autofocus="!pageInfo.persisted" + :placeholder="s__('WikiPage|Page title')" + @input="updateCommitMessage" + /> + <span class="gl-display-inline-block gl-max-w-full gl-mt-2 gl-text-gray-600"> + <gl-icon class="gl-mr-n1" name="bulb" /> + {{ + pageInfo.persisted + ? s__( + 'WikiPage|Tip: You can move this page by adding the path to the beginning of the title.', + ) + : s__( + 'WikiPage|Tip: You can specify the full path for the new file. We will automatically create any missing directories.', + ) + }} + <gl-link :href="helpPath" target="_blank" data-testid="wiki-title-help-link" + ><gl-icon name="question-o" /> {{ __('More Information.') }}</gl-link + > + </span> + </div> + </div> + <div class="form-group row"> + <div class="col-sm-2 col-form-label"> + <label class="control-label-full-width" for="wiki_format">{{ + s__('WikiPage|Format') + }}</label> + </div> + <div class="col-sm-10"> + <select id="wiki_format" v-model="format" class="form-control" name="wiki[format]"> + <option v-for="(key, label) of formatOptions" :key="key" :value="key"> + {{ label }} + </option> + </select> + </div> + </div> + <div class="form-group row"> + <div class="col-sm-2 col-form-label"> + <label class="control-label-full-width" for="wiki_content">{{ + s__('WikiPage|Content') + }}</label> + </div> + <div class="col-sm-10"> + <markdown-field + :markdown-preview-path="pageInfo.markdownPreviewPath" + :can-attach-file="true" + :enable-autocomplete="true" + :textarea-value="content" + :markdown-docs-path="pageInfo.markdownHelpPath" + :uploads-path="pageInfo.uploadsPath" + class="bordered-box" + > + <template #textarea> + <textarea + id="wiki_content" + ref="textarea" + v-model.trim="content" + name="wiki[content]" + class="note-textarea js-gfm-input js-autosize markdown-area" + dir="auto" + data-supports-quick-actions="false" + data-qa-selector="wiki_content_textarea" + :autofocus="pageInfo.persisted" + :aria-label="s__('WikiPage|Content')" + :placeholder="s__('WikiPage|Write your content or drag files here…')" + @input="handleContentChange" + > + </textarea> + </template> + </markdown-field> + <div class="clearfix"></div> + <div class="error-alert"></div> + + <div class="form-text gl-text-gray-600"> + <gl-sprintf + :message=" + s__( + 'WikiPage|To link to a (new) page, simply type %{linkExample}. More examples are in the %{linkStart}documentation%{linkEnd}.', + ) + " + > + <template #linkExample + ><code>{{ linkExample }}</code></template + > + <template + #link="// eslint-disable-next-line vue/no-template-shadow + { content }" + ><gl-link + :href="wikiSpecificMarkdownHelpPath" + target="_blank" + data-testid="wiki-markdown-help-link" + >{{ content }}</gl-link + ></template + > + </gl-sprintf> + </div> + </div> + </div> + <div class="form-group row"> + <div class="col-sm-2 col-form-label"> + <label class="control-label-full-width" for="wiki_message">{{ + s__('WikiPage|Commit message') + }}</label> + </div> + <div class="col-sm-10"> + <input + id="wiki_message" + v-model.trim="commitMessage" + name="wiki[message]" + type="text" + class="form-control" + data-qa-selector="wiki_message_textbox" + :placeholder="s__('WikiPage|Commit message')" + /> + </div> + </div> + <div class="form-actions"> + <gl-button + category="primary" + variant="confirm" + type="submit" + data-qa-selector="wiki_submit_button" + data-testid="wiki-submit-button" + :disabled="!content || !title" + >{{ submitButtonText }}</gl-button + > + <gl-button :href="cancelFormPath" class="float-right" data-testid="wiki-cancel-button">{{ + __('Cancel') + }}</gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js index c382a372260..c04cd0b3fa4 100644 --- a/app/assets/javascripts/pages/shared/wikis/index.js +++ b/app/assets/javascripts/pages/shared/wikis/index.js @@ -1,12 +1,14 @@ import $ from 'jquery'; import Vue from 'vue'; import ShortcutsWiki from '~/behaviors/shortcuts/shortcuts_wiki'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; import Translate from '~/vue_shared/translate'; import GLForm from '../../../gl_form'; import ZenMode from '../../../zen_mode'; import deleteWikiModal from './components/delete_wiki_modal.vue'; import wikiAlert from './components/wiki_alert.vue'; +import wikiForm from './components/wiki_form.vue'; import Wikis from './wikis'; const createModalVueApp = () => { @@ -61,7 +63,28 @@ const createAlertVueApp = () => { } }; +const createWikiFormApp = () => { + const el = document.getElementById('js-wiki-form'); + + if (el) { + const { pageInfo, formatOptions } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + provide: { + formatOptions: JSON.parse(formatOptions), + pageInfo: convertObjectPropsToCamelCase(JSON.parse(pageInfo)), + }, + render(createElement) { + return createElement(wikiForm); + }, + }); + } +}; + export default () => { createModalVueApp(); createAlertVueApp(); + createWikiFormApp(); }; diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js index 4b4d2f7d238..7d0b0c90c8d 100644 --- a/app/assets/javascripts/pages/shared/wikis/wikis.js +++ b/app/assets/javascripts/pages/shared/wikis/wikis.js @@ -1,15 +1,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; import showToast from '~/vue_shared/plugins/global_toast'; -const MARKDOWN_LINK_TEXT = { - markdown: '[Link Title](page-slug)', - rdoc: '{Link title}[link:page-slug]', - asciidoc: 'link:page-slug[Link title]', - org: '[[page-slug]]', -}; - const TRACKING_EVENT_NAME = 'view_wiki_page'; const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/wiki_page_context/jsonschema/1-0-1'; @@ -23,78 +15,11 @@ export default class Wikis { sidebarToggles[i].addEventListener('click', (e) => this.handleToggleSidebar(e)); } - this.isNewWikiPage = Boolean(document.querySelector('.js-new-wiki-page')); - this.editTitleInput = document.querySelector('form.wiki-form #wiki_title'); - this.commitMessageInput = document.querySelector('form.wiki-form #wiki_message'); - this.submitButton = document.querySelector('.js-wiki-btn-submit'); - this.commitMessageI18n = this.isNewWikiPage - ? s__('WikiPageCreate|Create %{pageTitle}') - : s__('WikiPageEdit|Update %{pageTitle}'); - - if (this.editTitleInput) { - // Initialize the commit message on load - if (this.editTitleInput.value) this.setWikiCommitMessage(this.editTitleInput.value); - - // Set the commit message as the page title is changed - this.editTitleInput.addEventListener('keyup', (e) => this.handleWikiTitleChange(e)); - } - window.addEventListener('resize', () => this.renderSidebar()); this.renderSidebar(); - const changeFormatSelect = document.querySelector('#wiki_format'); - const linkExample = document.querySelector('.js-markup-link-example'); - - if (changeFormatSelect) { - changeFormatSelect.addEventListener('change', (e) => { - linkExample.innerHTML = MARKDOWN_LINK_TEXT[e.target.value]; - }); - } - - this.wikiTextarea = document.querySelector('form.wiki-form #wiki_content'); - const wikiForm = document.querySelector('form.wiki-form'); - - if (this.wikiTextarea) { - this.wikiTextarea.addEventListener('input', () => this.handleWikiContentChange()); - - wikiForm.addEventListener('submit', () => { - window.onbeforeunload = null; - }); - } - Wikis.trackPageView(); Wikis.showToasts(); - - this.updateSubmitButton(); - } - - handleWikiContentChange() { - this.updateSubmitButton(); - - window.onbeforeunload = () => ''; - } - - handleWikiTitleChange(e) { - this.updateSubmitButton(); - this.setWikiCommitMessage(e.target.value); - } - - updateSubmitButton() { - if (!this.wikiTextarea) return; - - const isEnabled = Boolean(this.wikiTextarea.value.trim() && this.editTitleInput.value.trim()); - if (isEnabled) this.submitButton.removeAttribute('disabled'); - else this.submitButton.setAttribute('disabled', 'true'); - } - - setWikiCommitMessage(rawTitle) { - let title = rawTitle; - - // Replace hyphens with spaces - if (title) title = title.replace(/-+/g, ' '); - - const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title }, false); - this.commitMessageInput.value = newCommitMessage; } handleToggleSidebar(e) { diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 3ff455fad32..d236dc4610a 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -39,7 +39,7 @@ function formatTooltipText({ date, count }) { if (count > 0) { contribText = n__('%d contribution', '%d contributions', count); } - return `${contribText}<br />${dateDayName} ${dateText}`; + return `${contribText}<br /><span class="gl-text-gray-300">${dateDayName} ${dateText}</span>`; } const initColorKey = () => d3.scaleLinear().range(['#acd5f2', '#254e77']).domain([0, 3]); diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js index 4ac758550e0..98b2e4238c1 100644 --- a/app/assets/javascripts/performance/constants.js +++ b/app/assets/javascripts/performance/constants.js @@ -74,4 +74,4 @@ export const PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION = // (defined in: app/services/ci/prometheus_metrics/observe_histograms_service.rb) export const PIPELINES_DETAIL_LINK_DURATION = 'pipeline_graph_link_calculation_duration_seconds'; export const PIPELINES_DETAIL_LINKS_TOTAL = 'pipeline_graph_links_total'; -export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_link_per_job_ratio'; +export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_links_per_job_ratio'; diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 9bf77239a6b..e5b26a00c4c 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -1,5 +1,8 @@ <script> -import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective, GlSegmentedControl } from '@gitlab/ui'; + +import { s__ } from '~/locale'; +import { sortOrders, sortOrderOptions } from '../constants'; import RequestWarning from './request_warning.vue'; export default { @@ -7,6 +10,7 @@ export default { RequestWarning, GlButton, GlModal, + GlSegmentedControl, }, directives: { 'gl-modal': GlModalDirective, @@ -39,6 +43,7 @@ export default { data() { return { openedBacktraces: [], + sortOrder: sortOrders.DURATION, }; }, computed: { @@ -48,13 +53,43 @@ export default { metricDetails() { return this.currentRequest.details[this.metric]; }, + metricDetailsSummary() { + const summary = {}; + + if (!this.metricDetails.summaryOptions?.hideTotal) { + summary[s__('Total')] = this.metricDetails.calls; + } + + if (!this.metricDetails.summaryOptions?.hideDuration) { + summary[s__('PerformanceBar|Total duration')] = this.metricDetails.duration; + } + + return { ...summary, ...(this.metricDetails.summary || {}) }; + }, metricDetailsLabel() { - return this.metricDetails.duration - ? `${this.metricDetails.duration} / ${this.metricDetails.calls}` - : this.metricDetails.calls; + if (this.metricDetails.duration && this.metricDetails.calls) { + return `${this.metricDetails.duration} / ${this.metricDetails.calls}`; + } else if (this.metricDetails.calls) { + return this.metricDetails.calls; + } + + return '0'; + }, + displaySortOrder() { + return ( + this.metricDetails.details.length !== 0 && + this.metricDetails.details.every((item) => item.start) + ); }, detailsList() { - return this.metricDetails.details; + return this.metricDetails.details.map((item, index) => ({ ...item, id: index })); + }, + sortedList() { + if (this.sortOrder === sortOrders.CHRONOLOGICAL) { + return this.detailsList.slice().sort(this.sortDetailChronologically); + } + + return this.detailsList.slice().sort(this.sortDetailByDuration); }, warnings() { return this.metricDetails.warnings || []; @@ -82,7 +117,17 @@ 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; + }, + sortDetailChronologically(a, b) { + return a.start < b.start ? -1 : 1; + }, }, + sortOrderOptions, }; </script> <template> @@ -93,18 +138,41 @@ export default { data-qa-selector="detailed_metric_content" > <gl-button v-gl-modal="modalId" class="gl-mr-2" type="button" variant="link"> - <span class="gl-text-blue-300 gl-font-weight-bold">{{ metricDetailsLabel }}</span> + <span + class="gl-text-blue-200 gl-font-weight-bold" + data-testid="performance-bar-details-label" + > + {{ metricDetailsLabel }} + </span> </gl-button> <gl-modal :modal-id="modalId" :title="header" size="lg" footer-class="d-none" scrollable> + <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> + <div class="gl-display-flex gl-align-items-center" data-testid="performance-bar-summary"> + <div v-for="(value, name) in metricDetailsSummary" :key="name" class="gl-pr-8"> + <div v-if="value" data-testid="performance-bar-summary-item"> + <div>{{ name }}</div> + <div class="gl-font-size-h1 gl-font-weight-bold">{{ value }}</div> + </div> + </div> + </div> + <gl-segmented-control + v-if="displaySortOrder" + data-testid="performance-bar-sort-order" + :options="$options.sortOrderOptions" + :checked="sortOrder" + @input="changeSortOrder" + /> + </div> + <hr /> <table class="table gl-table"> - <template v-if="detailsList.length"> - <tr v-for="(item, index) in detailsList" :key="index"> - <td> + <template v-if="sortedList.length"> + <tr v-for="item in sortedList" :key="item.id"> + <td data-testid="performance-item-duration"> <span v-if="item.duration">{{ sprintf(__('%{duration}ms'), { duration: item.duration }) }}</span> </td> - <td> + <td data-testid="performance-item-content"> <div> <div v-for="(key, keyIndex) in keys" @@ -121,12 +189,12 @@ export default { variant="default" icon="ellipsis_h" size="small" - :selected="itemHasOpenedBacktrace(index)" + :selected="itemHasOpenedBacktrace(item.id)" :aria-label="__('Toggle backtrace')" - @click="toggleBacktrace(index)" + @click="toggleBacktrace(item.id)" /> </div> - <pre v-if="itemHasOpenedBacktrace(index)" class="backtrace-row mt-2">{{ + <pre v-if="itemHasOpenedBacktrace(item.id)" class="backtrace-row gl-mt-3">{{ item.backtrace }}</pre> </div> @@ -135,7 +203,7 @@ export default { </template> <template v-else> <tr> - <td> + <td data-testid="performance-bar-empty-detail-notice"> {{ sprintf(__('No %{header} for this request.'), { header: header.toLowerCase() }) }} </td> </tr> @@ -146,7 +214,7 @@ export default { <div></div> </template> </gl-modal> - {{ title }} + <span class="gl-text-white">{{ title }}</span> <request-warning :html-id="htmlId" :warnings="warnings" /> </div> </template> 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 6b446eb6073..ebe9c4eee2f 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -40,7 +40,7 @@ export default { metric: 'active-record', title: 'pg', header: s__('PerformanceBar|SQL queries'), - keys: ['sql', 'cached', 'db_role'], + keys: ['sql', 'cached', 'transaction', 'db_role'], }, { metric: 'bullet', @@ -69,6 +69,7 @@ export default { }, { metric: 'external-http', + title: 'external', header: s__('PerformanceBar|External Http calls'), keys: ['label', 'code', 'proxy', 'error'], }, @@ -135,7 +136,7 @@ export default { <div id="peek-view-host" class="view"> <span v-if="hasHost" - class="current-host" + class="current-host gl-text-white" :class="{ canary: currentRequest.details.host.canary }" > <span v-html="birdEmoji"></span> @@ -156,16 +157,18 @@ export default { id="peek-view-trace" class="view" > - <a class="gl-text-blue-300" :href="currentRequest.details.tracing.tracing_url">{{ - s__('PerformanceBar|trace') + <a class="gl-text-blue-200" :href="currentRequest.details.tracing.tracing_url">{{ + s__('PerformanceBar|Trace') }}</a> </div> - <add-request v-on="$listeners" /> <div v-if="currentRequest.details" id="peek-download" class="view"> - <a class="gl-text-blue-300" :download="downloadName" :href="downloadPath">{{ + <a class="gl-text-blue-200" :download="downloadName" :href="downloadPath">{{ s__('PerformanceBar|Download') }}</a> </div> + <a v-if="statsUrl" class="gl-text-blue-200 view" :href="statsUrl">{{ + s__('PerformanceBar|Stats') + }}</a> <request-selector v-if="currentRequest" :current-request="currentRequest" @@ -173,9 +176,7 @@ export default { class="ml-auto" @change-current-request="changeCurrentRequest" /> - <div v-if="statsUrl" id="peek-stats" class="view"> - <a class="gl-text-blue-300" :href="statsUrl">{{ s__('PerformanceBar|Stats') }}</a> - </div> + <add-request v-on="$listeners" /> </div> </div> </template> diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue index 5666e038f02..75fb7bbc5c5 100644 --- a/app/assets/javascripts/performance_bar/components/request_selector.vue +++ b/app/assets/javascripts/performance_bar/components/request_selector.vue @@ -58,12 +58,12 @@ export default { <span v-if="request.hasWarnings">(!)</span> </option> </select> - <span v-if="requestsWithWarnings.length"> + <span v-if="requestsWithWarnings.length" class="gl-cursor-default"> <span id="performance-bar-request-selector-warning" v-html="glEmojiTag('warning')"></span> <gl-popover + placement="bottom" target="performance-bar-request-selector-warning" :content="warningMessage" - triggers="hover focus" /> </span> </div> diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue index b61e1e5b7a9..7fe6b088ebb 100644 --- a/app/assets/javascripts/performance_bar/components/request_warning.vue +++ b/app/assets/javascripts/performance_bar/components/request_warning.vue @@ -35,8 +35,8 @@ export default { }; </script> <template> - <span v-if="hasWarnings"> + <span v-if="hasWarnings" class="gl-cursor-default"> <span :id="htmlId" v-html="glEmojiTag('warning')"></span> - <gl-popover :target="htmlId" :content="warningMessage" triggers="hover focus" /> + <gl-popover placement="bottom" :target="htmlId" :content="warningMessage" /> </span> </template> diff --git a/app/assets/javascripts/performance_bar/constants.js b/app/assets/javascripts/performance_bar/constants.js new file mode 100644 index 00000000000..9659383edd9 --- /dev/null +++ b/app/assets/javascripts/performance_bar/constants.js @@ -0,0 +1,17 @@ +import { s__ } from '~/locale'; + +export const sortOrders = { + DURATION: 'duration', + CHRONOLOGICAL: 'chronological', +}; + +export const sortOrderOptions = [ + { + value: sortOrders.DURATION, + text: s__('PerformanceBar|Sort by duration'), + }, + { + value: sortOrders.CHRONOLOGICAL, + text: s__('PerformanceBar|Sort chronologically'), + }, +]; diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 51b6108868f..d8aab25a6a8 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -1,6 +1,7 @@ -/* eslint-disable @gitlab/require-i18n-strings */ import Vue from 'vue'; import axios from '~/lib/utils/axios_utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { s__ } from '~/locale'; import Translate from '~/vue_shared/translate'; import initPerformanceBarLog from './performance_bar_log'; @@ -75,40 +76,53 @@ const initPerformanceBar = (el) => { const resourceEntries = performance.getEntriesByType('resource'); let durationString = ''; + let summary = {}; if (navigationEntries.length > 0) { - durationString = `${Math.round(navigationEntries[0].responseEnd)} | `; - durationString += `${Math.round(paintEntries[1].startTime)} | `; - durationString += ` ${Math.round(navigationEntries[0].domContentLoadedEventEnd)}`; + const backend = Math.round(navigationEntries[0].responseEnd); + const firstContentfulPaint = Math.round(paintEntries[1].startTime); + const domContentLoaded = Math.round(navigationEntries[0].domContentLoadedEventEnd); + + summary = { + [s__('PerformanceBar|Backend')]: backend, + [s__('PerformanceBar|First Contentful Paint')]: firstContentfulPaint, + [s__('PerformanceBar|DOM Content Loaded')]: domContentLoaded, + }; + + durationString = `${backend} | ${firstContentfulPaint} | ${domContentLoaded}`; } let newEntries = resourceEntries.map(this.transformResourceEntry); - this.updateFrontendPerformanceMetrics(durationString, newEntries); + this.updateFrontendPerformanceMetrics(durationString, summary, newEntries); if ('PerformanceObserver' in window) { // We start observing for more incoming timings const observer = new PerformanceObserver((list) => { newEntries = newEntries.concat(list.getEntries().map(this.transformResourceEntry)); - this.updateFrontendPerformanceMetrics(durationString, newEntries); + this.updateFrontendPerformanceMetrics(durationString, summary, newEntries); }); observer.observe({ entryTypes: ['resource'] }); } } }, - updateFrontendPerformanceMetrics(durationString, requestEntries) { + updateFrontendPerformanceMetrics(durationString, summary, requestEntries) { this.store.setRequestDetailsData(this.requestId, 'total', { duration: durationString, calls: requestEntries.length, details: requestEntries, + summaryOptions: { + hideDuration: true, + }, + summary, }); }, transformResourceEntry(entry) { - const nf = new Intl.NumberFormat(); return { + start: entry.startTime, name: entry.name.replace(document.location.origin, ''), duration: Math.round(entry.duration), - size: entry.transferSize ? `${nf.format(entry.transferSize)} bytes` : 'cached', + size: entry.transferSize ? numberToHumanSize(entry.transferSize) : 'cached', }; }, }, diff --git a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js index 9d12d228d35..51a8eb5ca69 100644 --- a/app/assets/javascripts/performance_bar/stores/performance_bar_store.js +++ b/app/assets/javascripts/performance_bar/stores/performance_bar_store.js @@ -47,10 +47,15 @@ export default class PerformanceBarStore { } canTrackRequest(requestUrl) { - return ( - requestUrl.endsWith('/api/graphql') || - this.requests.filter((request) => request.url === requestUrl).length < 2 - ); + // We want to store at most 2 unique requests per URL, as additional + // requests to the same URL probably aren't very interesting. + // + // GraphQL requests are the exception: because all GraphQL requests + // go to the same URL, we set a higher limit of 10 to allow + // capturing different queries a page may make. + const requestsLimit = requestUrl.endsWith('/api/graphql') ? 10 : 2; + + return this.requests.filter((request) => request.url === requestUrl).length < requestsLimit; } static truncateUrl(requestUrl) { diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index c177fe25985..cadcab16f16 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -7,7 +7,9 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-buy-pipeline-minutes-notification-callout', '.js-token-expiry-callout', '.js-registration-enabled-callout', + '.js-service-templates-deprecated-callout', '.js-new-user-signups-cap-reached', + '.js-eoa-bronze-plan-banner', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue new file mode 100644 index 00000000000..7b33d98bca0 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue @@ -0,0 +1,42 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { CODE_SNIPPET_SOURCES, CODE_SNIPPET_SOURCE_SETTINGS } from './constants'; + +export default { + name: 'CodeSnippetAlert', + components: { + GlAlert, + }, + inject: ['configurationPaths'], + props: { + source: { + type: String, + required: true, + validator: (source) => CODE_SNIPPET_SOURCES.includes(source), + }, + }, + computed: { + settings() { + return CODE_SNIPPET_SOURCE_SETTINGS[this.source]; + }, + configurationPath() { + return this.configurationPaths[this.source]; + }, + }, +}; +</script> + +<template> + <gl-alert + variant="tip" + :title="__('Code snippet copied. Insert it in the correct location in the YAML file.')" + :dismiss-label="__('Dismiss')" + :primary-button-link="settings.docsPath" + :primary-button-text="__('Read documentation')" + :secondary-button-link="configurationPath" + :secondary-button-text="__('Go back to configuration')" + v-on="$listeners" + > + {{ __('Before inserting code, be sure to read the comment that separated each code group.') }} + </gl-alert> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js new file mode 100644 index 00000000000..582fdfea6c9 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/code_snippet_alert/constants.js @@ -0,0 +1,11 @@ +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const CODE_SNIPPET_SOURCE_URL_PARAM = 'code_snippet_copied_from'; +export const CODE_SNIPPET_SOURCE_API_FUZZING = 'api_fuzzing'; +export const CODE_SNIPPET_SOURCES = [CODE_SNIPPET_SOURCE_API_FUZZING]; +export const CODE_SNIPPET_SOURCE_SETTINGS = { + [CODE_SNIPPET_SOURCE_API_FUZZING]: { + datasetKey: 'apiFuzzingConfigurationPath', + docsPath: helpPagePath('user/application_security/api_fuzzing/index'), + }, +}; diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue index b088678fee8..f6e88738002 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -124,7 +124,7 @@ export default { type="submit" class="js-no-auto-disable" category="primary" - variant="success" + variant="confirm" :disabled="submitDisabled" :loading="isSaving" > diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue index f36b22f33c3..455990f2791 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue @@ -1,22 +1,15 @@ <script> -import { GlAlert, GlIcon } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; import { uniqueId } from 'lodash'; -import { __, s__ } from '~/locale'; -import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; -import { DEFAULT, INVALID_CI_CONFIG } from '~/pipelines/constants'; +import { s__ } from '~/locale'; import EditorLite from '~/vue_shared/components/editor_lite.vue'; export default { i18n: { viewOnlyMessage: s__('Pipelines|Merged YAML is view only'), }, - errorTexts: { - [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'), - [DEFAULT]: __('An unknown error occurred.'), - }, components: { EditorLite, - GlAlert, GlIcon, }, inject: ['ciConfigPath'], @@ -32,69 +25,30 @@ export default { }; }, computed: { - failure() { - switch (this.failureType) { - case INVALID_CI_CONFIG: - return this.$options.errorTexts[INVALID_CI_CONFIG]; - default: - return this.$options.errorTexts[DEFAULT]; - } - }, fileGlobalId() { return `${this.ciConfigPath}-${uniqueId()}`; }, - hasError() { - return this.failureType; - }, - isInvalidConfiguration() { - return this.ciConfigData.status === CI_CONFIG_STATUS_INVALID; - }, mergedYaml() { return this.ciConfigData.mergedYaml; }, }, - watch: { - ciConfigData: { - immediate: true, - handler() { - if (this.isInvalidConfiguration) { - this.reportFailure(INVALID_CI_CONFIG); - } else if (this.hasError) { - this.resetFailure(); - } - }, - }, - }, - methods: { - reportFailure(errorType) { - this.failureType = errorType; - }, - resetFailure() { - this.failureType = null; - }, - }, }; </script> <template> <div> - <gl-alert v-if="hasError" variant="danger" :dismissible="false"> - {{ failure }} - </gl-alert> - <div v-else> - <div class="gl-display-flex gl-align-items-center"> - <gl-icon :size="18" name="lock" use-deprecated-sizes class="gl-text-gray-500 gl-mr-3" /> - {{ $options.i18n.viewOnlyMessage }} - </div> - <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1"> - <editor-lite - ref="editor" - :value="mergedYaml" - :file-name="ciConfigPath" - :file-global-id="fileGlobalId" - :editor-options="{ readOnly: true }" - v-on="$listeners" - /> - </div> + <div class="gl-display-flex gl-align-items-center"> + <gl-icon :size="16" name="lock" class="gl-text-gray-500 gl-mr-3" /> + {{ $options.i18n.viewOnlyMessage }} + </div> + <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1"> + <editor-lite + ref="editor" + :value="mergedYaml" + :file-name="ciConfigPath" + :file-global-id="fileGlobalId" + :editor-options="{ readOnly: true }" + v-on="$listeners" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue index 872da88d3e6..a3410d7b837 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue @@ -27,7 +27,7 @@ export default { registerCiSchema() { const editorInstance = this.$refs.editor.getEditor(); - editorInstance.use(new CiSchemaExtension()); + editorInstance.use(new CiSchemaExtension({ instance: editorInstance })); editorInstance.registerCiSchema({ projectPath: this.projectPath, projectNamespace: this.projectNamespace, diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue new file mode 100644 index 00000000000..b3eba0fcc19 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue @@ -0,0 +1,65 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { DEFAULT_FAILURE } from '~/pipeline_editor/constants'; +import getAvailableBranches from '~/pipeline_editor/graphql/queries/available_branches.graphql'; +import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.graphql'; + +export default { + i18n: { + title: s__('Branches'), + fetchError: s__('Unable to fetch branch list for this project.'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlIcon, + }, + inject: ['projectFullPath'], + apollo: { + branches: { + query: getAvailableBranches, + variables() { + return { + projectFullPath: this.projectFullPath, + }; + }, + update(data) { + return data.project?.repository?.branches || []; + }, + error() { + this.$emit('showError', { + type: DEFAULT_FAILURE, + reasons: [this.$options.i18n.fetchError], + }); + }, + }, + currentBranch: { + query: getCurrentBranch, + }, + }, + computed: { + hasBranchList() { + return this.branches?.length > 0; + }, + }, +}; +</script> + +<template> + <gl-dropdown v-if="hasBranchList" class="gl-ml-2" :text="currentBranch" icon="branch"> + <gl-dropdown-section-header> + {{ this.$options.i18n.title }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="branch in branches" + :key="branch.name" + :is-checked="currentBranch === branch.name" + :is-check-item="true" + > + <gl-icon name="check" class="gl-visibility-hidden" /> + {{ branch.name }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue new file mode 100644 index 00000000000..a945fc542a5 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -0,0 +1,21 @@ +<script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import BranchSwitcher from './branch_switcher.vue'; + +export default { + components: { + BranchSwitcher, + }, + mixins: [glFeatureFlagsMixin()], + computed: { + showBranchSwitcher() { + return this.glFeatures.pipelineEditorBranchSwitcher; + }, + }, +}; +</script> +<template> + <div class="gl-mb-5"> + <branch-switcher v-if="showBranchSwitcher" v-on="$listeners" /> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue index 7a35e31e9ce..fefa784f060 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue @@ -31,22 +31,18 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { - ciFileContent: { - type: String, - required: true, - }, ciConfigData: { type: Object, required: true, }, - isCiConfigDataLoading: { + isNewCiConfigFile: { type: Boolean, required: true, }, }, computed: { showPipelineStatus() { - return this.glFeatures.pipelineStatusForPipelineEditor; + return this.glFeatures.pipelineStatusForPipelineEditor && !this.isNewCiConfigFile; }, // make sure corners are rounded correctly depending on if // pipeline status is rendered @@ -61,11 +57,6 @@ export default { <template> <div class="gl-mb-5"> <pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" /> - <validation-segment - :class="validationStyling" - :loading="isCiConfigDataLoading" - :ci-file-content="ciFileContent" - :ci-config="ciConfigData" - /> + <validation-segment :class="validationStyling" :ci-config="ciConfigData" /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue index b1ea464be99..4a92e106da1 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -1,9 +1,11 @@ <script> import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql'; import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; +import { toggleQueryPollingByVisibility } from '~/pipelines/components/graph/utils'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; const POLL_INTERVAL = 10000; @@ -38,13 +40,11 @@ export default { }; }, update: (data) => { - const { id, commitPath = '', shortSha = '', detailedStatus = {} } = - data.project?.pipeline || {}; + const { id, commitPath = '', detailedStatus = {} } = data.project?.pipeline || {}; return { id, commitPath, - shortSha, detailedStatus, }; }, @@ -61,24 +61,34 @@ export default { }, computed: { hasPipelineData() { - return Boolean(this.$apollo.queries.pipeline?.id); + return Boolean(this.pipeline?.id); }, - isQueryLoading() { - return this.$apollo.queries.pipeline.loading && !this.hasPipelineData; + pipelineId() { + return getIdFromGraphQLId(this.pipeline.id); + }, + showLoadingState() { + // the query is set to poll regularly, so if there is no pipeline data + // (e.g. pipeline is null during fetch when the pipeline hasn't been + // triggered yet), we can just show the loading state until the pipeline + // details are ready to be fetched + return this.$apollo.queries.pipeline.loading || (!this.hasPipelineData && !this.hasError); + }, + shortSha() { + return truncateSha(this.commitSha); }, status() { return this.pipeline.detailedStatus; }, - pipelineId() { - return getIdFromGraphQLId(this.pipeline.id); - }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.pipeline, POLL_INTERVAL); }, }; </script> <template> <div class="gl-white-space-nowrap gl-max-w-full"> - <template v-if="isQueryLoading"> + <template v-if="showLoadingState"> <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span> </template> @@ -88,7 +98,7 @@ export default { </template> <template v-else> <a :href="status.detailsPath" class="gl-mr-auto"> - <ci-icon :status="status" :size="18" /> + <ci-icon :status="status" :size="16" /> </a> <span class="gl-font-weight-bold"> <gl-sprintf :message="$options.i18n.pipelineInfo"> @@ -110,7 +120,7 @@ export default { target="_blank" data-testid="pipeline-commit" > - {{ pipeline.shortSha }} + {{ shortSha }} </gl-link> </template> </gl-sprintf> diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 541ab74b177..d1534655a00 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -1,8 +1,13 @@ <script> import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; +import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.graphql'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import { CI_CONFIG_STATUS_VALID } from '../../constants'; +import { + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, +} from '../../constants'; export const i18n = { empty: __( @@ -29,47 +34,51 @@ export default { }, }, props: { - ciFileContent: { - type: String, - required: true, - }, ciConfig: { type: Object, required: false, default: () => ({}), }, - loading: { - type: Boolean, - required: false, - default: false, + }, + apollo: { + appStatus: { + query: getAppStatus, }, }, computed: { isEmpty() { - return !this.ciFileContent; + return this.appStatus === EDITOR_APP_STATUS_EMPTY; + }, + isLoading() { + return this.appStatus === EDITOR_APP_STATUS_LOADING; }, isValid() { - return this.ciConfig?.status === CI_CONFIG_STATUS_VALID; + return this.appStatus === EDITOR_APP_STATUS_VALID; }, icon() { - if (this.isValid || this.isEmpty) { - return 'check'; + switch (this.appStatus) { + case EDITOR_APP_STATUS_EMPTY: + return 'check'; + case EDITOR_APP_STATUS_VALID: + return 'check'; + default: + return 'warning-solid'; } - return 'warning-solid'; }, message() { - if (this.isEmpty) { - return this.$options.i18n.empty; - } else if (this.isValid) { - return this.$options.i18n.valid; - } - - // Only display first error as a reason const [reason] = this.ciConfig?.errors || []; - if (reason) { - return sprintf(this.$options.i18n.invalidWithReason, { reason }, false); + + switch (this.appStatus) { + case EDITOR_APP_STATUS_EMPTY: + return this.$options.i18n.empty; + case EDITOR_APP_STATUS_VALID: + return this.$options.i18n.valid; + default: + // Only display first error as a reason + return this.ciConfig?.errors.length > 0 + ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false) + : this.$options.i18n.invalid; } - return this.$options.i18n.invalid; }, }, }; @@ -77,7 +86,7 @@ export default { <template> <div> - <template v-if="loading"> + <template v-if="isLoading"> <gl-loading-icon inline /> {{ $options.i18n.loading }} </template> diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue index b27ab9a39d3..f1cf5630fbf 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint.vue @@ -1,6 +1,5 @@ <script> import { flatten } from 'lodash'; -import { CI_CONFIG_STATUS_VALID } from '../../constants'; import CiLintResults from './ci_lint_results.vue'; export default { @@ -13,15 +12,16 @@ export default { }, }, props: { + isValid: { + type: Boolean, + required: true, + }, ciConfig: { type: Object, required: true, }, }, computed: { - isValid() { - return this.ciConfig?.status === CI_CONFIG_STATUS_VALID; - }, stages() { return this.ciConfig?.stages || []; }, @@ -45,9 +45,9 @@ export default { <template> <ci-lint-results - :valid="isValid" - :jobs="jobs" :errors="ciConfig.errors" + :is-valid="isValid" + :jobs="jobs" :lint-help-page-path="lintHelpPagePath" /> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue index 5d9697c9427..7f6dce05b6e 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -42,34 +42,34 @@ export default { CiLintResultsParam, }, props: { - valid: { - type: Boolean, - required: true, - }, - jobs: { - type: Array, - required: false, - default: () => [], - }, errors: { type: Array, required: false, default: () => [], }, - warnings: { - type: Array, - required: false, - default: () => [], - }, dryRun: { type: Boolean, required: false, default: false, }, + isValid: { + type: Boolean, + required: true, + }, + jobs: { + type: Array, + required: false, + default: () => [], + }, lintHelpPagePath: { type: String, required: true, }, + warnings: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -78,7 +78,7 @@ export default { }, computed: { status() { - return this.valid ? this.$options.correct : this.$options.incorrect; + return this.isValid ? this.$options.correct : this.$options.incorrect; }, shouldShowTable() { return this.errors.length === 0; diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 3bdcf383bee..5acb3355b23 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -1,15 +1,20 @@ <script> -import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import { s__ } from '~/locale'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { - CI_CONFIG_STATUS_INVALID, CREATE_TAB, + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_ERROR, + EDITOR_APP_STATUS_INVALID, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, LINT_TAB, MERGED_TAB, VISUALIZE_TAB, } from '../constants'; +import getAppStatus from '../graphql/queries/client/app_status.graphql'; import CiConfigMergedPreview from './editor/ci_config_merged_preview.vue'; import TextEditor from './editor/text_editor.vue'; import CiLint from './lint/ci_lint.vue'; @@ -21,6 +26,17 @@ export default { tabGraph: s__('Pipelines|Visualize'), tabLint: s__('Pipelines|Lint'), tabMergedYaml: s__('Pipelines|View merged YAML'), + empty: { + visualization: s__( + 'PipelineEditor|The pipeline visualization is displayed when the CI/CD configuration file has valid syntax.', + ), + lint: s__( + 'PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty.', + ), + merge: s__( + 'PipelineEditor|The merged YAML view is displayed when the CI/CD configuration file has valid syntax.', + ), + }, }, errorTexts: { loadMergedYaml: s__('Pipelines|Could not load merged YAML content'), @@ -37,7 +53,6 @@ export default { EditorTab, GlAlert, GlLoadingIcon, - GlTab, GlTabs, PipelineGraph, TextEditor, @@ -52,17 +67,28 @@ export default { type: String, required: true, }, - isCiConfigDataLoading: { - type: Boolean, - required: false, - default: false, + }, + apollo: { + appStatus: { + query: getAppStatus, }, }, computed: { - hasMergedYamlLoadError() { - return ( - !this.ciConfigData?.mergedYaml && this.ciConfigData.status !== CI_CONFIG_STATUS_INVALID - ); + hasAppError() { + // Not an invalid config and with `mergedYaml` data missing + return this.appStatus === EDITOR_APP_STATUS_ERROR; + }, + isEmpty() { + return this.appStatus === EDITOR_APP_STATUS_EMPTY; + }, + isInvalid() { + return this.appStatus === EDITOR_APP_STATUS_INVALID; + }, + isValid() { + return this.appStatus === EDITOR_APP_STATUS_VALID; + }, + isLoading() { + return this.appStatus === EDITOR_APP_STATUS_LOADING; }, }, methods: { @@ -83,39 +109,48 @@ export default { > <text-editor :value="ciFileContent" v-on="$listeners" /> </editor-tab> - <gl-tab + <editor-tab v-if="glFeatures.ciConfigVisualizationTab" class="gl-mb-3" + :empty-message="$options.i18n.empty.visualization" + :is-empty="isEmpty" + :is-invalid="isInvalid" :title="$options.i18n.tabGraph" lazy data-testid="visualization-tab" @click="setCurrentTab($options.tabConstants.VISUALIZE_TAB)" > - <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> <pipeline-graph v-else :pipeline-data="ciConfigData" /> - </gl-tab> + </editor-tab> <editor-tab class="gl-mb-3" + :empty-message="$options.i18n.empty.lint" + :is-empty="isEmpty" :title="$options.i18n.tabLint" data-testid="lint-tab" @click="setCurrentTab($options.tabConstants.LINT_TAB)" > - <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> - <ci-lint v-else :ci-config="ciConfigData" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> + <ci-lint v-else :is-valid="isValid" :ci-config="ciConfigData" /> </editor-tab> - <gl-tab + <editor-tab v-if="glFeatures.ciConfigMergedTab" class="gl-mb-3" + :empty-message="$options.i18n.empty.merge" + :keep-component-mounted="false" + :is-empty="isEmpty" + :is-invalid="isInvalid" :title="$options.i18n.tabMergedYaml" lazy data-testid="merged-tab" @click="setCurrentTab($options.tabConstants.MERGED_TAB)" > - <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> - <gl-alert v-else-if="hasMergedYamlLoadError" variant="danger" :dismissible="false"> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> + <gl-alert v-else-if="hasAppError" variant="danger" :dismissible="false"> {{ $options.errorTexts.loadMergedYaml }} </gl-alert> <ci-config-merged-preview v-else :ci-config-data="ciConfigData" v-on="$listeners" /> - </gl-tab> + </editor-tab> </gl-tabs> </template> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue index b0acd3ca2ee..7c032441a04 100644 --- a/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue +++ b/app/assets/javascripts/pipeline_editor/components/ui/editor_tab.vue @@ -1,6 +1,6 @@ <script> -import { GlTab } from '@gitlab/ui'; - +import { GlAlert, GlTab } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; /** * Wrapper of <gl-tab> to optionally lazily render this tab's content * when its shown **without dismounting after its hidden**. @@ -10,10 +10,10 @@ import { GlTab } from '@gitlab/ui'; * API is the same as <gl-tab>, for example: * * <gl-tabs> - * <editor-tab title="Tab 1" :lazy="true"> + * <editor-tab title="Tab 1" lazy> * lazily mounted content (gets mounted if this is first tab) * </editor-tab> - * <editor-tab title="Tab 2" :lazy="true"> + * <editor-tab title="Tab 2" lazy> * lazily mounted content * </editor-tab> * <editor-tab title="Tab 3"> @@ -25,10 +25,26 @@ import { GlTab } from '@gitlab/ui'; * so it's contents are not dismounted. * * lazy is "false" by default, as in <gl-tab>. + * + * It is also possible to pass the `isEmpty` and or `isInvalid` to let + * the tab component handle that state on its own. For example: + * + * * <gl-tabs> + * <editor-tab-with-status title="Tab 1" :is-empty="isEmpty" :is-invalid="isInvalid"> + * ... + * </editor-tab-with-status> + * Will be the same as normal, except it will only render the slot component + * if the status is not empty and not invalid. In any of these 2 cases, it will render + * a generic component and avoid mounting whatever it received in the slot. + * </gl-tabs> */ export default { + i18n: { + invalid: __('Your CI/CD configuration syntax is invalid. View Lint tab for more details.'), + }, components: { + GlAlert, GlTab, // Use a small renderless component to know when the tab content mounts because: // - gl-tab always gets mounted, even if lazy is `true`. See: @@ -40,29 +56,63 @@ export default { }, inheritAttrs: false, props: { + emptyMessage: { + type: String, + required: false, + default: s__( + 'PipelineEditor|This tab will be usable when the CI/CD configuration file is populated with valid syntax.', + ), + }, + isEmpty: { + type: Boolean, + required: false, + default: null, + }, + isInvalid: { + type: Boolean, + required: false, + default: null, + }, lazy: { type: Boolean, required: false, default: false, }, + keepComponentMounted: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { isLazy: this.lazy, }; }, + computed: { + slots() { + return Object.keys(this.$slots); + }, + }, methods: { onContentMounted() { // When a child is first mounted make the entire tab - // permanently mounted by setting 'lazy' to false. - this.isLazy = false; + // permanently mounted by setting 'lazy' to false unless + // explicitly opted out. + if (this.keepComponentMounted) { + this.isLazy = false; + } }, }, }; </script> <template> <gl-tab :lazy="isLazy" v-bind="$attrs" v-on="$listeners"> - <slot v-for="slot in Object.keys($slots)" :slot="slot" :name="slot"></slot> - <mount-spy @hook:mounted="onContentMounted" /> + <gl-alert v-if="isEmpty" variant="tip">{{ emptyMessage }}</gl-alert> + <gl-alert v-else-if="isInvalid" variant="danger">{{ $options.i18n.invalid }}</gl-alert> + <template v-else> + <slot v-for="slot in slots" :name="slot"></slot> + <mount-spy @hook:mounted="onContentMounted" /> + </template> </gl-tab> </template> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index 353deafe770..8d0ec6c3e2d 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -1,5 +1,14 @@ -export const CI_CONFIG_STATUS_VALID = 'VALID'; +// Values for CI_CONFIG_STATUS_* comes from lint graphQL export const CI_CONFIG_STATUS_INVALID = 'INVALID'; +export const CI_CONFIG_STATUS_VALID = 'VALID'; + +// Values for EDITOR_APP_STATUS_* are frontend specifics and +// represent the global state of the pipeline editor app. +export const EDITOR_APP_STATUS_EMPTY = 'EMPTY'; +export const EDITOR_APP_STATUS_ERROR = 'ERROR'; +export const EDITOR_APP_STATUS_INVALID = CI_CONFIG_STATUS_INVALID; +export const EDITOR_APP_STATUS_LOADING = 'LOADING'; +export const EDITOR_APP_STATUS_VALID = CI_CONFIG_STATUS_VALID; export const COMMIT_FAILURE = 'COMMIT_FAILURE'; export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql new file mode 100644 index 00000000000..f162bb11d47 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/available_branches.graphql @@ -0,0 +1,9 @@ +query getAvailableBranches($projectFullPath: ID!) { + project(fullPath: $projectFullPath) @client { + repository { + branches { + name + } + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql new file mode 100644 index 00000000000..938f36c7d5c --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/app_status.graphql @@ -0,0 +1,3 @@ +query getAppStatus { + appStatus @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql index 7cc7f92fb60..d3a7387ad2d 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql @@ -1,10 +1,9 @@ query getPipeline($fullPath: ID!, $sha: String!) { - project(fullPath: $fullPath) @client { + project(fullPath: $fullPath) { pipeline(sha: $sha) { commitPath id iid - shortSha status detailedStatus { detailsPath diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index 13f6200693b..caa2a65d424 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -11,25 +11,19 @@ export const resolvers = { }), }; }, - /* eslint-disable @gitlab/require-i18n-strings */ project() { return { __typename: 'Project', - pipeline: { - __typename: 'Pipeline', - commitPath: `/-/commit/aabbccdd`, - id: 'gid://gitlab/Ci::Pipeline/118', - iid: '28', - shortSha: 'aabbccdd', - status: 'SUCCESS', - detailedStatus: { - __typename: 'DetailedStatus', - detailsPath: '/root/sample-ci-project/-/pipelines/118"', - group: 'success', - icon: 'status_success', - text: 'passed', - }, + repository: { + __typename: 'Repository', + branches: [ + { __typename: 'Branch', name: 'master' }, + { __typename: 'Branch', name: 'main' }, + { __typename: 'Branch', name: 'develop' }, + { __typename: 'Branch', name: 'production' }, + { __typename: 'Branch', name: 'test' }, + ], }, }; }, diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index b17ec2d5c25..8a1e26f9bff 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -3,6 +3,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; +import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants'; +import getCommitSha from './graphql/queries/client/commit_sha.graphql'; +import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; import { resolvers } from './graphql/resolvers'; import typeDefs from './graphql/typedefs.graphql'; import PipelineEditorApp from './pipeline_editor_app.vue'; @@ -35,15 +38,30 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ymlHelpPagePath, } = el?.dataset; + const configurationPaths = Object.fromEntries( + Object.entries(CODE_SNIPPET_SOURCE_SETTINGS).map(([source, { datasetKey }]) => [ + source, + el.dataset[datasetKey], + ]), + ); + Vue.use(VueApollo); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(resolvers, { typeDefs }), }); + const { cache } = apolloProvider.clients.defaultClient; - apolloProvider.clients.defaultClient.cache.writeData({ + cache.writeQuery({ + query: getCurrentBranch, data: { currentBranch: initialBranchName || defaultBranch, + }, + }); + + cache.writeQuery({ + query: getCommitSha, + data: { commitSha, }, }); @@ -61,6 +79,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { projectPath, projectNamespace, ymlHelpPagePath, + configurationPaths, }, render(h) { return h(PipelineEditorApp); diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index c1168979e9f..e0fb38004ec 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,14 +1,29 @@ <script> import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import httpStatusCodes from '~/lib/utils/http_status'; +import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; +import CodeSnippetAlert from './components/code_snippet_alert/code_snippet_alert.vue'; +import { + CODE_SNIPPET_SOURCE_URL_PARAM, + CODE_SNIPPET_SOURCES, +} from './components/code_snippet_alert/constants'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; -import { COMMIT_FAILURE, COMMIT_SUCCESS, DEFAULT_FAILURE, LOAD_FAILURE_UNKNOWN } from './constants'; +import { + COMMIT_FAILURE, + COMMIT_SUCCESS, + DEFAULT_FAILURE, + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_ERROR, + EDITOR_APP_STATUS_LOADING, + LOAD_FAILURE_UNKNOWN, +} from './constants'; import getBlobContent from './graphql/queries/blob_content.graphql'; import getCiConfigData from './graphql/queries/ci_config.graphql'; +import getAppStatus from './graphql/queries/client/app_status.graphql'; import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql'; import PipelineEditorHome from './pipeline_editor_home.vue'; @@ -20,6 +35,7 @@ export default { GlLoadingIcon, PipelineEditorEmptyState, PipelineEditorHome, + CodeSnippetAlert, }, inject: { ciConfigPath: { @@ -32,7 +48,6 @@ export default { data() { return { ciConfigData: {}, - // Success and failure state failureType: null, failureReasons: [], showStartScreen: false, @@ -43,8 +58,10 @@ export default { showFailureAlert: false, showSuccessAlert: false, successType: null, + codeSnippetCopiedFrom: '', }; }, + apollo: { initialCiFileContent: { query: getBlobContent, @@ -77,8 +94,7 @@ export default { }, ciConfigData: { query: getCiConfigData, - // If content is not loaded, we can't lint the data - skip: ({ currentCiFileContent }) => { + skip({ currentCiFileContent }) { return !currentCiFileContent; }, variables() { @@ -94,9 +110,20 @@ export default { return { ...ciConfig, stages }; }, + result({ data }) { + this.setAppStatus(data?.ciConfig?.status || EDITOR_APP_STATUS_ERROR); + }, error() { this.reportFailure(LOAD_FAILURE_UNKNOWN); }, + watchLoading(isLoading) { + if (isLoading) { + this.setAppStatus(EDITOR_APP_STATUS_LOADING); + } + }, + }, + appStatus: { + query: getAppStatus, }, currentBranch: { query: getCurrentBranch, @@ -115,6 +142,9 @@ export default { isCiConfigDataLoading() { return this.$apollo.queries.ciConfigData.loading; }, + isEmpty() { + return this.currentCiFileContent === ''; + }, failure() { switch (this.failureType) { case LOAD_FAILURE_UNKNOWN: @@ -159,6 +189,16 @@ export default { successTexts: { [COMMIT_SUCCESS]: __('Your changes have been successfully committed.'), }, + watch: { + isEmpty(flag) { + if (flag) { + this.setAppStatus(EDITOR_APP_STATUS_EMPTY); + } + }, + }, + created() { + this.parseCodeSnippetSourceParam(); + }, methods: { handleBlobContentError(error = {}) { const { networkError } = error; @@ -170,6 +210,7 @@ export default { response?.status === httpStatusCodes.NOT_FOUND || response?.status === httpStatusCodes.BAD_REQUEST ) { + this.setAppStatus(EDITOR_APP_STATUS_EMPTY); this.showStartScreen = true; } else { this.reportFailure(LOAD_FAILURE_UNKNOWN); @@ -183,6 +224,8 @@ export default { this.showSuccessAlert = false; }, reportFailure(type, reasons = []) { + this.setAppStatus(EDITOR_APP_STATUS_ERROR); + window.scrollTo({ top: 0, behavior: 'smooth' }); this.showFailureAlert = true; this.failureType = type; @@ -196,6 +239,9 @@ export default { resetContent() { this.currentCiFileContent = this.lastCommittedContent; }, + setAppStatus(appStatus) { + this.$apollo.getClient().writeQuery({ query: getAppStatus, data: { appStatus } }); + }, setNewEmptyCiConfigFile() { this.$apollo .getClient() @@ -220,6 +266,20 @@ export default { // if the user has made changes to the file that are unsaved. this.lastCommittedContent = this.currentCiFileContent; }, + parseCodeSnippetSourceParam() { + const [codeSnippetCopiedFrom] = getParameterValues(CODE_SNIPPET_SOURCE_URL_PARAM); + if (codeSnippetCopiedFrom && CODE_SNIPPET_SOURCES.includes(codeSnippetCopiedFrom)) { + this.codeSnippetCopiedFrom = codeSnippetCopiedFrom; + window.history.replaceState( + {}, + document.title, + removeParams([CODE_SNIPPET_SOURCE_URL_PARAM]), + ); + } + }, + dismissCodeSnippetAlert() { + this.codeSnippetCopiedFrom = ''; + }, }, }; </script> @@ -232,19 +292,35 @@ export default { @createEmptyConfigFile="setNewEmptyCiConfigFile" /> <div v-else> - <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess"> + <code-snippet-alert + v-if="codeSnippetCopiedFrom" + :source="codeSnippetCopiedFrom" + class="gl-mb-5" + @dismiss="dismissCodeSnippetAlert" + /> + <gl-alert + v-if="showSuccessAlert" + :variant="success.variant" + class="gl-mb-5" + @dismiss="dismissSuccess" + > {{ success.text }} </gl-alert> - <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure"> + <gl-alert + v-if="showFailureAlert" + :variant="failure.variant" + class="gl-mb-5" + @dismiss="dismissFailure" + > {{ failure.text }} <ul v-if="failureReasons.length" class="gl-mb-0"> <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> </ul> </gl-alert> <pipeline-editor-home - :is-ci-config-data-loading="isCiConfigDataLoading" :ci-config-data="ciConfigData" :ci-file-content="currentCiFileContent" + :is-new-ci-config-file="isNewCiConfigFile" @commit="updateOnCommit" @resetContent="resetContent" @showError="showErrorAlert" diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index ef46040153f..adba55f9f4b 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -1,5 +1,6 @@ <script> import CommitSection from './components/commit/commit_section.vue'; +import PipelineEditorFileNav from './components/file_nav/pipeline_editor_file_nav.vue'; import PipelineEditorHeader from './components/header/pipeline_editor_header.vue'; import PipelineEditorTabs from './components/pipeline_editor_tabs.vue'; import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants'; @@ -7,6 +8,7 @@ import { TABS_WITH_COMMIT_FORM, CREATE_TAB } from './constants'; export default { components: { CommitSection, + PipelineEditorFileNav, PipelineEditorHeader, PipelineEditorTabs, }, @@ -19,7 +21,7 @@ export default { type: String, required: true, }, - isCiConfigDataLoading: { + isNewCiConfigFile: { type: Boolean, required: true, }, @@ -44,15 +46,14 @@ export default { <template> <div> + <pipeline-editor-file-nav v-on="$listeners" /> <pipeline-editor-header - :ci-file-content="ciFileContent" :ci-config-data="ciConfigData" - :is-ci-config-data-loading="isCiConfigDataLoading" + :is-new-ci-config-file="isNewCiConfigFile" /> <pipeline-editor-tabs :ci-config-data="ciConfigData" :ci-file-content="ciFileContent" - :is-ci-config-data-loading="isCiConfigDataLoading" v-on="$listeners" @set-current-tab="setCurrentTab" /> 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 ff6a354f673..e44d80ee9d1 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -33,6 +33,7 @@ const i18n = { 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 { @@ -416,15 +417,17 @@ export default { 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">{{ s__('CiVariables|Remove variable') }}</span> + <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> @@ -441,18 +444,16 @@ export default { </gl-sprintf></template > </gl-form-group> - <div - class="gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-p-5 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between" - > + <div class="gl-pt-5 gl-display-flex"> <gl-button type="submit" category="primary" - variant="success" - class="js-no-auto-disable" + 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 + >{{ s__('Pipeline|Run pipeline') }}</gl-button > <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button> </div> diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index e44dedfe2ee..16fb931ec2b 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -50,6 +50,10 @@ export default { }; }, update(data) { + if (!data?.project?.pipeline) { + return this.graphData; + } + const { stages: { nodes: stages }, } = data.project.pipeline; diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js index caa269f5095..dd9cdae518f 100644 --- a/app/assets/javascripts/pipelines/components/graph/constants.js +++ b/app/assets/javascripts/pipelines/components/graph/constants.js @@ -10,3 +10,12 @@ export const ONE_COL_WIDTH = 180; export const REST = 'rest'; export const GRAPHQL = 'graphql'; + +export const STAGE_VIEW = 'stage'; +export const LAYER_VIEW = 'layer'; +export const VIEW_TYPE_KEY = 'pipeline_graph_view_type'; + +export const SINGLE_JOB = 'single_job'; +export const JOB_DROPDOWN = 'job_dropdown'; + +export const IID_FAILURE = 'missing_iid'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 363226a0d85..63048777724 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,10 +1,11 @@ <script> +import { reportToSentry } from '../../utils'; import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; import LinksLayer from '../graph_shared/links_layer.vue'; -import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH } from './constants'; +import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; -import { reportToSentry, validateConfigPaths } from './utils'; +import { validateConfigPaths } from './utils'; export default { name: 'PipelineGraph', @@ -24,11 +25,20 @@ export default { type: Object, required: true, }, + viewType: { + type: String, + required: true, + }, isLinkedPipeline: { type: Boolean, required: false, default: false, }, + pipelineLayers: { + type: Array, + required: false, + default: () => [], + }, type: { type: String, required: false, @@ -44,6 +54,7 @@ export default { data() { return { hoveredJobName: '', + hoveredSourceJobName: '', highlightedJobs: [], measurements: { width: 0, @@ -62,8 +73,8 @@ export default { downstreamPipelines() { return this.hasDownstreamPipelines ? this.pipeline.downstream : []; }, - graph() { - return this.pipeline.stages; + layout() { + return this.isStageView ? this.pipeline.stages : this.generateColumnsFromLayersList(); }, hasDownstreamPipelines() { return Boolean(this.pipeline?.downstream?.length > 0); @@ -71,12 +82,21 @@ export default { hasUpstreamPipelines() { return Boolean(this.pipeline?.upstream?.length > 0); }, + isStageView() { + return this.viewType === STAGE_VIEW; + }, metricsConfig() { return { path: this.configPaths.metricsPath, collectMetrics: true, }; }, + shouldHideLinks() { + return this.isStageView; + }, + shouldShowStageName() { + return !this.isStageView; + }, // The show downstream check prevents showing redundant linked columns showDownstreamPipelines() { return ( @@ -100,6 +120,26 @@ export default { this.getMeasurements(); }, methods: { + generateColumnsFromLayersList() { + return this.pipelineLayers.map((layers, idx) => { + /* + look up the groups in each layer, + then add each set of layer groups to a stage-like object + */ + + const groups = layers.map((id) => { + const { stageIdx, groupIdx } = this.pipeline.stagesLookup[id]; + return this.pipeline.stages?.[stageIdx]?.groups?.[groupIdx]; + }); + + return { + name: '', + id: `layer-${idx}`, + status: { action: null }, + groups: groups.filter(Boolean), + }; + }); + }, getMeasurements() { this.measurements = { width: this.$refs[this.containerId].scrollWidth, @@ -112,6 +152,9 @@ export default { setJob(jobName) { this.hoveredJobName = jobName; }, + setSourceJob(jobName) { + this.hoveredSourceJobName = jobName; + }, slidePipelineContainer() { this.$refs.mainPipelineContainer.scrollBy({ left: ONE_COL_WIDTH, @@ -146,31 +189,35 @@ export default { :linked-pipelines="upstreamPipelines" :column-title="__('Upstream')" :type="$options.pipelineTypeConstants.UPSTREAM" + :view-type="viewType" @error="onError" /> </template> <template #main> <div :id="containerId" :ref="containerId"> <links-layer - :pipeline-data="graph" + :pipeline-data="layout" :pipeline-id="pipeline.id" :container-id="containerId" :container-measurements="measurements" :highlighted-job="hoveredJobName" :metrics-config="metricsConfig" - :never-show-links="true" + :never-show-links="shouldHideLinks" + :view-type="viewType" default-link-color="gl-stroke-transparent" @error="onError" @highlightedJobsChange="updateHighlightedJobs" > <stage-column-component - v-for="stage in graph" - :key="stage.name" - :title="stage.name" - :groups="stage.groups" - :action="stage.status.action" + v-for="column in layout" + :key="column.id || column.name" + :name="column.name" + :groups="column.groups" + :action="column.status.action" :highlighted-jobs="highlightedJobs" + :show-stage-name="shouldShowStageName" :job-hovered="hoveredJobName" + :source-job-hovered="hoveredSourceJobName" :pipeline-expanded="pipelineExpanded" :pipeline-id="pipeline.id" @refreshPipelineGraph="$emit('refreshPipelineGraph')" @@ -188,7 +235,8 @@ export default { :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" :type="$options.pipelineTypeConstants.DOWNSTREAM" - @downstreamHovered="setJob" + :view-type="viewType" + @downstreamHovered="setSourceJob" @pipelineExpandToggle="togglePipelineExpanded" @scrollContainer="slidePipelineContainer" @error="onError" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue index abbf8df6eed..39d0fa8a8ca 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue @@ -2,10 +2,10 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { escape, capitalize } from 'lodash'; import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; +import { reportToSentry } from '../../utils'; import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue'; import StageColumnComponentLegacy from './stage_column_component_legacy.vue'; -import { reportToSentry } from './utils'; export default { name: 'PipelineGraphLegacy', 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 962f2ca2a4c..0bc6d883245 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -2,11 +2,16 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { __ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; +import { reportToSentry } from '../../utils'; +import { listByLayers } from '../parsing_utils'; +import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; import PipelineGraph from './graph_component.vue'; +import GraphViewSelector from './graph_view_selector.vue'; import { getQueryHeaders, - reportToSentry, serializeLoadErrors, toggleQueryPollingByVisibility, unwrapPipelineData, @@ -17,8 +22,11 @@ export default { components: { GlAlert, GlLoadingIcon, + GraphViewSelector, + LocalStorageSync, PipelineGraph, }, + mixins: [glFeatureFlagMixin()], inject: { graphqlResourceEtag: { default: '', @@ -35,13 +43,18 @@ export default { }, data() { return { - pipeline: null, alertType: null, + currentViewType: STAGE_VIEW, + pipeline: null, + pipelineLayers: null, showAlert: false, }; }, errorTexts: { [DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'), + [IID_FAILURE]: __( + 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', + ), [LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'), [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, @@ -58,6 +71,9 @@ export default { iid: this.pipelineIid, }; }, + skip() { + return !(this.pipelineProjectPath && this.pipelineIid); + }, update(data) { /* This check prevents the pipeline from being overwritten @@ -98,6 +114,11 @@ export default { text: this.$options.errorTexts[DRAW_FAILURE], variant: 'danger', }; + case IID_FAILURE: + return { + text: this.$options.errorTexts[IID_FAILURE], + variant: 'info', + }; case LOAD_FAILURE: return { text: this.$options.errorTexts[LOAD_FAILURE], @@ -123,14 +144,28 @@ export default { */ return this.$apollo.queries.pipeline.loading && !this.pipeline; }, + showGraphViewSelector() { + return Boolean(this.glFeatures.pipelineGraphLayersView && this.pipeline?.usesNeeds); + }, }, mounted() { + if (!this.pipelineIid) { + this.reportFailure({ type: IID_FAILURE, skipSentry: true }); + } + toggleQueryPollingByVisibility(this.$apollo.queries.pipeline); }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, methods: { + getPipelineLayers() { + if (this.currentViewType === LAYER_VIEW && !this.pipelineLayers) { + this.pipelineLayers = listByLayers(this.pipeline); + } + + return this.pipelineLayers; + }, hideAlert() { this.showAlert = false; this.alertType = null; @@ -147,7 +182,11 @@ export default { } }, /* eslint-enable @gitlab/require-i18n-strings */ + updateViewType(type) { + this.currentViewType = type; + }, }, + viewTypeKey: VIEW_TYPE_KEY, }; </script> <template> @@ -155,11 +194,24 @@ export default { <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert"> {{ alert.text }} </gl-alert> + <local-storage-sync + :storage-key="$options.viewTypeKey" + :value="currentViewType" + @input="updateViewType" + > + <graph-view-selector + v-if="showGraphViewSelector" + :type="currentViewType" + @updateViewType="updateViewType" + /> + </local-storage-sync> <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" /> <pipeline-graph v-if="pipeline" :config-paths="configPaths" :pipeline="pipeline" + :pipeline-layers="getPipelineLayers()" + :view-type="currentViewType" @error="reportFailure" @refreshPipelineGraph="refreshPipelineGraph" /> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue new file mode 100644 index 00000000000..f33e6290e37 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue @@ -0,0 +1,85 @@ +<script> +import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { STAGE_VIEW, LAYER_VIEW } from './constants'; + +export default { + name: 'GraphViewSelector', + components: { + GlDropdown, + GlDropdownItem, + GlIcon, + GlSprintf, + }, + props: { + type: { + type: String, + required: true, + }, + }, + data() { + return { + currentViewType: STAGE_VIEW, + }; + }, + i18n: { + labelText: __('Order jobs by'), + }, + views: { + [STAGE_VIEW]: { + type: STAGE_VIEW, + text: { + primary: __('Stage'), + secondary: __('View the jobs grouped into stages'), + }, + }, + [LAYER_VIEW]: { + type: LAYER_VIEW, + text: { + primary: __('%{codeStart}needs:%{codeEnd} relationships'), + secondary: __('View what jobs are needed for a job to run'), + }, + }, + }, + computed: { + currentDropdownText() { + return this.$options.views[this.type].text.primary; + }, + }, + methods: { + itemClick(type) { + this.$emit('updateViewType', type); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-my-4"> + <span>{{ $options.i18n.labelText }}</span> + <gl-dropdown data-testid="pipeline-view-selector" class="gl-ml-4"> + <template #button-content> + <gl-sprintf :message="currentDropdownText"> + <template #code="{ content }"> + <code> {{ content }} </code> + </template> + </gl-sprintf> + <gl-icon class="gl-px-2" name="angle-down" :size="16" /> + </template> + <gl-dropdown-item + v-for="view in $options.views" + :key="view.type" + :secondary-text="view.text.secondary" + @click="itemClick(view.type)" + > + <b> + <gl-sprintf :message="view.text.primary"> + <template #code="{ content }"> + <code> {{ content }} </code> + </template> + </gl-sprintf> + </b> + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index f6aee8c5fcf..6451605a222 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -1,8 +1,7 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; -import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { reportToSentry } from '../../utils'; +import { JOB_DROPDOWN, SINGLE_JOB } from './constants'; import JobItem from './job_item.vue'; -import { reportToSentry } from './utils'; /** * Renders the dropdown for the pipeline graph. @@ -11,12 +10,8 @@ import { reportToSentry } from './utils'; * */ export default { - directives: { - GlTooltip: GlTooltipDirective, - }, components: { JobItem, - CiIcon, }, props: { group: { @@ -28,6 +23,15 @@ export default { required: false, default: -1, }, + stageName: { + type: String, + required: false, + default: '', + }, + }, + jobItemTypes: { + jobDropdown: JOB_DROPDOWN, + singleJob: SINGLE_JOB, }, computed: { computedJobId() { @@ -51,22 +55,20 @@ export default { <template> <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright"> <button - v-gl-tooltip.hover="{ boundary: 'viewport' }" - :title="tooltipText" type="button" data-toggle="dropdown" data-display="static" - class="dropdown-menu-toggle build-content gl-build-content" + class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!" > <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> - <span class="gl-display-flex gl-align-items-center gl-min-w-0"> - <ci-icon :status="group.status" :size="24" class="gl-line-height-0" /> - <span class="gl-text-truncate mw-70p gl-pl-3"> - {{ group.name }} - </span> - </span> + <job-item + :type="$options.jobItemTypes.jobDropdown" + :group-tooltip="tooltipText" + :job="group" + :stage-name="stageName" + /> - <span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span> + <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div> </div> </button> @@ -77,6 +79,7 @@ export default { <job-item :dropdown-length="group.size" :job="job" + :type="$options.jobItemTypes.singleJob" css-class-job-name="mini-pipeline-graph-dropdown-item" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 46ef0457d40..6584d89d87c 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -3,11 +3,12 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { sprintf } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { reportToSentry } from '../../utils'; +import ActionComponent from '../jobs_shared/action_component.vue'; +import JobNameComponent from '../jobs_shared/job_name_component.vue'; import { accessValue } from './accessors'; -import ActionComponent from './action_component.vue'; -import { REST } from './constants'; -import JobNameComponent from './job_name_component.vue'; -import { reportToSentry } from './utils'; +import { REST, SINGLE_JOB } from './constants'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -38,6 +39,7 @@ export default { hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, + CiIcon, JobNameComponent, GlLink, }, @@ -65,6 +67,11 @@ export default { required: false, default: Infinity, }, + groupTooltip: { + type: String, + required: false, + default: '', + }, jobHovered: { type: String, required: false, @@ -80,24 +87,55 @@ export default { required: false, default: -1, }, + sourceJobHovered: { + type: String, + required: false, + default: '', + }, + stageName: { + type: String, + required: false, + default: '', + }, + type: { + type: String, + required: false, + default: SINGLE_JOB, + }, }, computed: { boundary() { return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; }, + computedJobId() { + return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : ''; + }, detailsPath() { return accessValue(this.dataMethod, 'detailsPath', this.status); }, hasDetails() { return accessValue(this.dataMethod, 'hasDetails', this.status); }, - computedJobId() { - return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : ''; + isSingleItem() { + return this.type === SINGLE_JOB; + }, + nameComponent() { + return this.hasDetails ? 'gl-link' : 'div'; + }, + showStageName() { + return Boolean(this.stageName); }, status() { return this.job && this.job.status ? this.job.status : {}; }, + testId() { + return this.hasDetails ? 'job-with-link' : 'job-without-link'; + }, tooltipText() { + if (this.groupTooltip) { + return this.groupTooltip; + } + const textBuilder = []; const { name: jobName } = this.job; @@ -129,7 +167,7 @@ export default { return this.job.status && this.job.status.action && this.job.status.action.path; }, relatedDownstreamHovered() { - return this.job.name === this.jobHovered; + return this.job.name === this.sourceJobHovered; }, relatedDownstreamExpanded() { return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded; @@ -147,6 +185,17 @@ export default { hideTooltips() { this.$root.$emit(BV_HIDE_TOOLTIP); }, + jobItemClick(evt) { + if (this.isSingleItem) { + /* + This is so the jobDropdown still toggles. Issue to refactor: + https://gitlab.com/gitlab-org/gitlab/-/issues/267117 + */ + evt.stopPropagation(); + } + + this.hideTooltips(); + }, pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); }, @@ -156,40 +205,45 @@ export default { <template> <div :id="computedJobId" - class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" + class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full" data-qa-selector="job_item_container" > - <gl-link - v-if="hasDetails" - v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" - :href="detailsPath" - :title="tooltipText" - :class="jobClasses" - class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" - data-testid="job-with-link" - @click.stop="hideTooltips" - @mouseout="hideTooltips" - > - <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> - </gl-link> - - <div - v-else - v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" + <component + :is="nameComponent" + v-gl-tooltip="{ + boundary: 'viewport', + placement: 'bottom', + customClass: 'gl-pointer-events-none', + }" :title="tooltipText" :class="jobClasses" - class="js-job-component-tooltip non-details-job-component menu-item" - data-testid="job-without-link" + :href="detailsPath" + class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full" + :data-testid="testId" + @click="jobItemClick" @mouseout="hideTooltips" > - <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> - </div> + <div class="ci-job-name-component gl-display-flex gl-align-items-center"> + <ci-icon :size="24" :status="job.status" class="gl-line-height-0" /> + <div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full"> + <div class="gl-text-truncate mw-70p gl-line-height-normal">{{ job.name }}</div> + <div + v-if="showStageName" + data-testid="stage-name-in-job" + class="gl-text-truncate mw-70p gl-font-sm gl-text-gray-500 gl-line-height-normal" + > + {{ stageName }} + </div> + </div> + </div> + </component> <action-component v-if="hasAction" :tooltip-text="status.action.title" :link="status.action.path" :action-icon="status.action.icon" + class="gl-mr-1" data-qa-selector="action_button" @pipelineActionRequestComplete="pipelineActionRequestComplete" /> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index add7b3445f7..3f746731e34 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -3,9 +3,9 @@ import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@g import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; +import { reportToSentry } from '../../utils'; import { accessValue } from './accessors'; import { DOWNSTREAM, REST, UPSTREAM } from './constants'; -import { reportToSentry } from './utils'; export default { directives: { @@ -183,6 +183,7 @@ export default { class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!" :class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`" :icon="expandedIcon" + :aria-label="__('Expand pipeline')" data-testid="expand-pipeline-button" data-qa-selector="expand_pipeline_button" @click="onClickLinkedPipeline" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index b55a77a3c4f..7f772e35e55 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -1,11 +1,12 @@ <script> import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { LOAD_FAILURE } from '../../constants'; -import { ONE_COL_WIDTH, UPSTREAM } from './constants'; +import { reportToSentry } from '../../utils'; +import { listByLayers } from '../parsing_utils'; +import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW } from './constants'; import LinkedPipeline from './linked_pipeline.vue'; import { getQueryHeaders, - reportToSentry, serializeLoadErrors, toggleQueryPollingByVisibility, unwrapPipelineData, @@ -35,11 +36,16 @@ export default { type: String, required: true, }, + viewType: { + type: String, + required: true, + }, }, data() { return { currentPipeline: null, loadingPipelineId: null, + pipelineLayers: {}, pipelineExpanded: false, }; }, @@ -123,6 +129,13 @@ export default { toggleQueryPollingByVisibility(this.$apollo.queries.currentPipeline); }, + getPipelineLayers(id) { + if (this.viewType === LAYER_VIEW && !this.pipelineLayers[id]) { + this.pipelineLayers[id] = listByLayers(this.currentPipeline); + } + + return this.pipelineLayers[id]; + }, isExpanded(id) { return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id); }, @@ -203,7 +216,9 @@ export default { class="d-inline-block gl-mt-n2" :config-paths="configPaths" :pipeline="currentPipeline" + :pipeline-layers="getPipelineLayers(pipeline.id)" :is-linked-pipeline="true" + :view-type="viewType" /> </div> </li> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue index 0d1ff94c275..39baeb6e1c3 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue @@ -1,7 +1,7 @@ <script> +import { reportToSentry } from '../../utils'; import { UPSTREAM } from './constants'; import LinkedPipeline from './linked_pipeline.vue'; -import { reportToSentry } from './utils'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue index 0a762563114..fa2f381c8a4 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -1,12 +1,13 @@ <script> import { capitalize, escape, isEmpty } from 'lodash'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { reportToSentry } from '../../utils'; import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue'; +import ActionComponent from '../jobs_shared/action_component.vue'; import { accessValue } from './accessors'; -import ActionComponent from './action_component.vue'; import { GRAPHQL } from './constants'; import JobGroupDropdown from './job_group_dropdown.vue'; import JobItem from './job_item.vue'; -import { reportToSentry } from './utils'; export default { components: { @@ -15,17 +16,18 @@ export default { JobItem, MainGraphWrapper, }, + mixins: [glFeatureFlagMixin()], props: { groups: { type: Array, required: true, }, - pipelineId: { - type: Number, + name: { + type: String, required: true, }, - title: { - type: String, + pipelineId: { + type: Number, required: true, }, action: { @@ -48,6 +50,16 @@ export default { required: false, default: () => ({}), }, + showStageName: { + type: Boolean, + required: false, + default: false, + }, + sourceJobHovered: { + type: String, + required: false, + default: '', + }, }, titleClasses: [ 'gl-font-weight-bold', @@ -57,8 +69,23 @@ export default { 'gl-pl-3', ], computed: { + /* + currentGroups and filteredGroups are part of + a test to hunt down a bug + (see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57142). + + They should be removed when the bug is rectified. + */ + currentGroups() { + return this.glFeatures.pipelineFilterJobs ? this.filteredGroups : this.groups; + }, + filteredGroups() { + return this.groups.map((group) => { + return { ...group, jobs: group.jobs.filter(Boolean) }; + }); + }, formattedTitle() { - return capitalize(escape(this.title)); + return capitalize(escape(this.name)); }, hasAction() { return !isEmpty(this.action); @@ -80,6 +107,18 @@ export default { isFadedOut(jobName) { return this.highlightedJobs.length > 1 && !this.highlightedJobs.includes(jobName); }, + isParallel(group) { + return group.size > 1 && group.jobs.length > 1; + }, + singleJobExists(group) { + const firstJobDefined = Boolean(group.jobs?.[0]); + + if (!firstJobDefined) { + reportToSentry('stage_column_component', 'undefined_job_hunt'); + } + + return group.size === 1 && firstJobDefined; + }, }, }; </script> @@ -104,7 +143,7 @@ export default { </template> <template #jobs> <div - v-for="group in groups" + v-for="group in currentGroups" :id="groupId(group)" :key="getGroupId(group)" data-testid="stage-column-group" @@ -113,17 +152,23 @@ export default { @mouseleave="$emit('jobHover', '')" > <job-item - v-if="group.size === 1" + v-if="singleJobExists(group)" :job="group.jobs[0]" :job-hovered="jobHovered" + :source-job-hovered="sourceJobHovered" :pipeline-expanded="pipelineExpanded" :pipeline-id="pipelineId" + :stage-name="showStageName ? group.stageName : ''" css-class-job-name="gl-build-content" :class="{ 'gl-opacity-3': isFadedOut(group.name) }" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" /> - <div v-else :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> - <job-group-dropdown :group="group" :pipeline-id="pipelineId" /> + <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> + <job-group-dropdown + :group="group" + :stage-name="showStageName ? group.stageName : ''" + :pipeline-id="pipelineId" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue index 2cee2fbbd8f..cbaf07c05cf 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue @@ -1,10 +1,10 @@ <script> import { isEmpty, escape } from 'lodash'; import stageColumnMixin from '../../mixins/stage_column_mixin'; -import ActionComponent from './action_component.vue'; +import { reportToSentry } from '../../utils'; +import ActionComponent from '../jobs_shared/action_component.vue'; import JobGroupDropdown from './job_group_dropdown.vue'; import JobItem from './job_item.vue'; -import { reportToSentry } from './utils'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index b9a8e2638bc..373aa6bf9a1 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -1,7 +1,6 @@ -import * as Sentry from '@sentry/browser'; import Visibility from 'visibilityjs'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { unwrapStagesWithNeeds } from '../unwrapping_utils'; +import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils'; const addMulti = (mainPipelineProjectPath, linkedPipeline) => { return { @@ -24,13 +23,6 @@ const getQueryHeaders = (etagResource) => { }; }; -const reportToSentry = (component, failureType) => { - Sentry.withScope((scope) => { - scope.setTag('component', component); - Sentry.captureException(failureType); - }); -}; - const serializeGqlErr = (gqlError) => { const { locations = [], message = '', path = [] } = gqlError; @@ -94,12 +86,13 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => { stages: { nodes: stages }, } = pipeline; - const nodes = unwrapStagesWithNeeds(stages); + const { stages: updatedStages, lookup } = unwrapStagesWithNeedsAndLookup(stages); return { ...pipeline, id: getIdFromGraphQLId(pipeline.id), - stages: nodes, + stages: updatedStages, + stagesLookup: lookup, upstream: upstream ? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId) : [], @@ -113,7 +106,6 @@ const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0; export { getQueryHeaders, - reportToSentry, serializeGqlErr, serializeLoadErrors, toggleQueryPollingByVisibility, diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js index 04ac15ae24c..49cd04d11e9 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/api.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js @@ -1,5 +1,5 @@ import axios from '~/lib/utils/axios_utils'; -import { reportToSentry } from '../graph/utils'; +import { reportToSentry } from '../../utils'; export const reportPerformance = (path, stats) => { axios.post(path, stats).catch((err) => { diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue index fad57084992..0ed5b8a5f09 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -10,8 +10,8 @@ import { } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import { DRAW_FAILURE } from '../../constants'; -import { createJobsHash, generateJobNeedsDict } from '../../utils'; -import { reportToSentry } from '../graph/utils'; +import { createJobsHash, generateJobNeedsDict, reportToSentry } from '../../utils'; +import { STAGE_VIEW } from '../graph/constants'; import { parseData } from '../parsing_utils'; import { reportPerformance } from './api'; import { generateLinksData } from './drawing_utils'; @@ -55,11 +55,17 @@ export default { required: false, default: '', }, + viewType: { + type: String, + required: false, + default: STAGE_VIEW, + }, }, data() { return { links: [], needsObject: null, + parsedData: {}, }; }, computed: { @@ -109,6 +115,15 @@ export default { highlightedJobs(jobs) { this.$emit('highlightedJobsChange', jobs); }, + viewType() { + /* + We need to wait a tick so that the layout reflows + before the links refresh. + */ + this.$nextTick(() => { + this.refreshLinks(); + }); + }, }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); @@ -167,14 +182,17 @@ export default { this.beginPerfMeasure(); try { const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); - const parsedData = parseData(arrayOfJobs); - this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`); + this.parsedData = parseData(arrayOfJobs); + this.refreshLinks(); } catch (err) { this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false }); reportToSentry(this.$options.name, err); } this.finishPerfMeasureAndSend(); }, + refreshLinks() { + this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`); + }, getLinkClasses(link) { return [ this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor, diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue index 42eab13b0bd..8dbab245f44 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -11,7 +11,7 @@ import { PIPELINES_DETAIL_LINKS_JOB_RATIO, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; -import { reportToSentry } from '../graph/utils'; +import { reportToSentry } from '../../utils'; import { parseData } from '../parsing_utils'; import { reportPerformance } from './api'; import LinksInner from './links_inner.vue'; diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 4ce43b92c93..d8e7b83a8c1 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -8,6 +8,7 @@ import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutatio import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql'; import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; +import { getQueryHeaders } from './graph/utils'; const DELETE_MODAL_ID = 'pipeline-delete-modal'; const POLL_INTERVAL = 10000; @@ -34,7 +35,9 @@ export default { [DEFAULT]: __('An unknown error occurred.'), }, inject: { - // Receive `fullProject` and `pipelinesPath` + graphqlResourceEtag: { + default: '', + }, paths: { default: {}, }, @@ -47,6 +50,9 @@ export default { }, apollo: { pipeline: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, query: getPipelineQuery, variables() { return { diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue index 1df693704d4..3972c126673 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue @@ -5,7 +5,7 @@ import axios from '~/lib/utils/axios_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { dasherize } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; -import { reportToSentry } from './utils'; +import { reportToSentry } from '../../utils'; /** * Renders either a cancel, retry or play icon button and handles the post request diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue index fffd8e1818a..fffd8e1818a 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue new file mode 100644 index 00000000000..6982586ab12 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue @@ -0,0 +1,90 @@ +<script> +import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; +import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql'; + +const featureName = 'pipeline_needs_banner'; +const enumFeatureName = featureName.toUpperCase(); + +export default { + i18n: { + title: __('View job dependencies in the pipeline graph!'), + description: __( + 'You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}', + ), + buttonText: __('Provide feedback'), + }, + components: { + GlBanner, + GlLink, + GlSprintf, + }, + apollo: { + callouts: { + query: getUserCallouts, + update(data) { + return data?.currentUser?.callouts?.nodes.map((c) => c.featureName); + }, + error() { + this.hasError = true; + }, + }, + }, + inject: ['dagDocPath'], + data() { + return { + callouts: [], + dismissedAlert: false, + hasError: false, + }; + }, + computed: { + showBanner() { + return ( + !this.$apollo.queries.callouts?.loading && + !this.hasError && + !this.dismissedAlert && + !this.callouts.includes(enumFeatureName) + ); + }, + }, + methods: { + handleClose() { + this.dismissedAlert = true; + try { + this.$apollo.mutate({ + mutation: DismissPipelineNotification, + variables: { + featureName, + }, + }); + } catch { + createFlash(__('There was a problem dismissing this notification.')); + } + }, + }, +}; +</script> +<template> + <gl-banner + v-if="showBanner" + :title="$options.i18n.title" + :button-text="$options.i18n.buttonText" + button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/327688" + variant="introduction" + @close="handleClose" + > + <p> + <gl-sprintf :message="$options.i18n.description"> + <template #link="{ content }"> + <gl-link :href="dagDocPath" target="_blank"> {{ content }}</gl-link> + </template> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </gl-banner> +</template> diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index 9c97fa832d0..f5ab869633b 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -1,4 +1,5 @@ import { uniqWith, isEqual } from 'lodash'; +import { createSankey } from './dag/drawing_utils'; /* The following functions are the main engine in transforming the data as @@ -144,3 +145,28 @@ export const getMaxNodes = (nodes) => { export const removeOrphanNodes = (sankeyfiedNodes) => { return sankeyfiedNodes.filter((node) => node.sourceLinks.length || node.targetLinks.length); }; + +/* + This utility accepts unwrapped pipeline data in the format returned from + our standard pipeline GraphQL query and returns a list of names by layer + for the layer view. It can be combined with the stageLookup on the pipeline + to generate columns by layer. +*/ + +export const listByLayers = ({ stages }) => { + const arrayOfJobs = stages.flatMap(({ groups }) => groups); + const parsedData = parseData(arrayOfJobs); + const dataWithLayers = createSankey()(parsedData); + + return dataWithLayers.nodes.reduce((acc, { layer, name }) => { + /* sort groups by layer */ + + if (!acc[layer]) { + acc[layer] = []; + } + + acc[layer].push(name); + + return acc; + }, []); +}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue index 51a95612d3f..01baf0a42d5 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue @@ -10,6 +10,10 @@ export default { type: String, required: true, }, + pipelineId: { + type: Number, + required: true, + }, isHighlighted: { type: Boolean, required: false, @@ -32,6 +36,9 @@ export default { }, }, computed: { + id() { + return `${this.jobName}-${this.pipelineId}`; + }, jobPillClasses() { return [ { 'gl-opacity-3': this.isFadedOut }, @@ -52,7 +59,7 @@ export default { <template> <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> <div - :id="jobName" + :id="id" class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" :class="jobPillClasses" @mouseover="onMouseEnter" diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 707d6966e77..3ba0d7d0120 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -1,11 +1,8 @@ <script> import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; -import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; -import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants'; -import { createJobsHash, generateJobNeedsDict } from '../../utils'; -import { generateLinksData } from '../graph_shared/drawing_utils'; -import { parseData } from '../parsing_utils'; +import { DRAW_FAILURE, DEFAULT } from '../../constants'; +import LinksLayer from '../graph_shared/links_layer.vue'; import JobPill from './job_pill.vue'; import StagePill from './stage_pill.vue'; @@ -13,18 +10,16 @@ export default { components: { GlAlert, JobPill, + LinksLayer, StagePill, }, CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF', - CONTAINER_ID: 'pipeline-graph-container', + BASE_CONTAINER_ID: 'pipeline-graph-container', + PIPELINE_ID: 0, STROKE_WIDTH: 2, errorTexts: { [DRAW_FAILURE]: __('Could not draw the lines for job relationships'), [DEFAULT]: __('An unknown error occurred.'), - [EMPTY_PIPELINE_DATA]: __( - 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', - ), - [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'), }, props: { pipelineData: { @@ -36,33 +31,16 @@ export default { return { failureType: null, highlightedJob: null, - links: [], - needsObject: null, - height: 0, - width: 0, + highlightedJobs: [], + measurements: { + height: 0, + width: 0, + }, }; }, computed: { - hideGraph() { - // We won't even try to render the graph with these condition - // because it would cause additional errors down the line for the user - // which is confusing. - return this.isPipelineDataEmpty || this.isInvalidCiConfig; - }, - pipelineStages() { - return this.pipelineData?.stages || []; - }, - isPipelineDataEmpty() { - return !this.isInvalidCiConfig && this.pipelineStages.length === 0; - }, - isInvalidCiConfig() { - return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID; - }, - hasError() { - return this.failureType; - }, - hasHighlightedJob() { - return Boolean(this.highlightedJob); + containerId() { + return `${this.$options.BASE_CONTAINER_ID}-${this.$options.PIPELINE_ID}`; }, failure() { switch (this.failureType) { @@ -72,18 +50,6 @@ export default { variant: 'danger', dismissible: true, }; - case EMPTY_PIPELINE_DATA: - return { - text: this.$options.errorTexts[EMPTY_PIPELINE_DATA], - variant: 'tip', - dismissible: false, - }; - case INVALID_CI_CONFIG: - return { - text: this.$options.errorTexts[INVALID_CI_CONFIG], - variant: 'danger', - dismissible: false, - }; default: return { text: this.$options.errorTexts[DEFAULT], @@ -92,56 +58,32 @@ export default { }; } }, - viewBox() { - return [0, 0, this.width, this.height]; + hasError() { + return this.failureType; }, - highlightedJobs() { - // If you are hovering on a job, then the jobs we want to highlight are: - // The job you are currently hovering + all of its needs. - return [this.highlightedJob, ...this.needsObject[this.highlightedJob]]; + hasHighlightedJob() { + return Boolean(this.highlightedJob); }, - highlightedLinks() { - // If you are hovering on a job, then the links we want to highlight are: - // All the links whose `source` and `target` are highlighted jobs. - if (this.hasHighlightedJob) { - const filteredLinks = this.links.filter((link) => { - return ( - this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target) - ); - }); - - return filteredLinks.map((link) => link.ref); - } - - return []; + pipelineStages() { + return this.pipelineData?.stages || []; }, }, watch: { pipelineData: { immediate: true, handler() { - if (this.isPipelineDataEmpty) { - this.reportFailure(EMPTY_PIPELINE_DATA); - } else if (this.isInvalidCiConfig) { - this.reportFailure(INVALID_CI_CONFIG); - } else { - this.$nextTick(() => { - this.computeGraphDimensions(); - this.prepareLinkData(); - }); - } + this.$nextTick(() => { + this.computeGraphDimensions(); + }); }, }, }, methods: { - prepareLinkData() { - try { - const arrayOfJobs = this.pipelineStages.flatMap(({ groups }) => groups); - const parsedData = parseData(arrayOfJobs); - this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID); - } catch { - this.reportFailure(DRAW_FAILURE); - } + computeGraphDimensions() { + this.measurements = { + width: this.$refs[this.$options.CONTAINER_REF].scrollWidth, + height: this.$refs[this.$options.CONTAINER_REF].scrollHeight, + }; }, getStageBackgroundClasses(index) { const { length } = this.pipelineStages; @@ -161,22 +103,14 @@ export default { return ''; }, - highlightNeeds(uniqueJobId) { - // The first time we hover, we create the object where - // we store all the data to properly highlight the needs. - if (!this.needsObject) { - const jobs = createJobsHash(this.pipelineStages); - this.needsObject = generateJobNeedsDict(jobs) ?? {}; - } - - this.highlightedJob = uniqueJobId; + isJobHighlighted(jobName) { + return this.highlightedJobs.includes(jobName); }, - removeHighlightNeeds() { - this.highlightedJob = null; + onError(error) { + this.reportFailure(error.type); }, - computeGraphDimensions() { - this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`; - this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`; + removeHoveredJob() { + this.highlightedJob = null; }, reportFailure(errorType) { this.failureType = errorType; @@ -184,17 +118,11 @@ export default { resetFailure() { this.failureType = null; }, - isJobHighlighted(jobName) { - return this.highlightedJobs.includes(jobName); + setHoveredJob(jobName) { + this.highlightedJob = jobName; }, - isLinkHighlighted(linkRef) { - return this.highlightedLinks.includes(linkRef); - }, - getLinkClasses(link) { - return [ - this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : 'gl-stroke-gray-200', - { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) }, - ]; + updateHighlightedJobs(jobs) { + this.highlightedJobs = jobs; }, }, }; @@ -209,50 +137,44 @@ export default { > {{ failure.text }} </gl-alert> - <div - v-if="!hideGraph" - :id="$options.CONTAINER_ID" - :ref="$options.CONTAINER_REF" - class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7" - data-testid="graph-container" - > - <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute"> - <path - v-for="link in links" - :key="link.path" - :ref="link.ref" - :d="link.path" - class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease" - :class="getLinkClasses(link)" - :stroke-width="$options.STROKE_WIDTH" - /> - </svg> - <div - v-for="(stage, index) in pipelineStages" - :key="`${stage.name}-${index}`" - class="gl-flex-direction-column" + <div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container"> + <links-layer + :pipeline-data="pipelineStages" + :pipeline-id="$options.PIPELINE_ID" + :container-id="containerId" + :container-measurements="measurements" + :highlighted-job="highlightedJob" + @highlightedJobsChange="updateHighlightedJobs" + @error="onError" > <div - class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5" - :class="getStageBackgroundClasses(index)" - data-testid="stage-background" - > - <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" /> - </div> - <div - class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8" + v-for="(stage, index) in pipelineStages" + :key="`${stage.name}-${index}`" + class="gl-flex-direction-column" > - <job-pill - v-for="group in stage.groups" - :key="group.name" - :job-name="group.name" - :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)" - :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)" - @on-mouse-enter="highlightNeeds" - @on-mouse-leave="removeHighlightNeeds" - /> + <div + class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5" + :class="getStageBackgroundClasses(index)" + data-testid="stage-background" + > + <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" /> + </div> + <div + class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8" + > + <job-pill + v-for="group in stage.groups" + :key="group.name" + :job-name="group.name" + :pipeline-id="$options.PIPELINE_ID" + :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)" + :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)" + @on-mouse-enter="setHoveredJob" + @on-mouse-leave="removeHoveredJob" + /> + </div> </div> - </div> + </links-layer> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue deleted file mode 100644 index 6c3a4a27606..00000000000 --- a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue +++ /dev/null @@ -1,30 +0,0 @@ -<script> -export default { - name: 'PipelinesSvgState', - props: { - svgPath: { - type: String, - required: true, - }, - - message: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <div class="row empty-state"> - <div class="col-12"> - <div class="svg-content"><img :src="svgPath" /></div> - </div> - - <div class="col-12 text-center"> - <div class="text-content"> - <h4>{{ message }}</h4> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index f8107d288d9..c3bcfcb18fb 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,7 +1,9 @@ <script> import { GlEmptyState } from '@gitlab/ui'; +import Experiment from '~/experimentation/components/experiment.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; +import PipelinesCiTemplates from './pipelines_ci_templates.vue'; export default { i18n: { @@ -15,6 +17,8 @@ export default { name: 'PipelinesEmptyState', components: { GlEmptyState, + Experiment, + PipelinesCiTemplates, }, props: { emptyStateSvgPath: { @@ -35,19 +39,26 @@ export default { </script> <template> <div> - <gl-empty-state - v-if="canSetCi" - :title="$options.i18n.title" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.description" - :primary-button-text="$options.i18n.btnText" - :primary-button-link="ciHelpPagePath" - /> - <gl-empty-state - v-else - title="" - :svg-path="emptyStateSvgPath" - :description="$options.i18n.noCiDescription" - /> + <experiment name="pipeline_empty_state_templates"> + <template #control> + <gl-empty-state + v-if="canSetCi" + :title="$options.i18n.title" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.description" + :primary-button-text="$options.i18n.btnText" + :primary-button-link="ciHelpPagePath" + /> + <gl-empty-state + v-else + title="" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.noCiDescription" + /> + </template> + <template #candidate> + <pipelines-ci-templates /> + </template> + </experiment> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue new file mode 100644 index 00000000000..670fa398536 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue @@ -0,0 +1,190 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { sprintf } from '~/locale'; +import { reportToSentry } from '../../utils'; +import ActionComponent from '../jobs_shared/action_component.vue'; +import JobNameComponent from '../jobs_shared/job_name_component.vue'; + +/** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "tooltip": "passed", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + +export default { + hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', + components: { + ActionComponent, + JobNameComponent, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + cssClassJobName: { + type: String, + required: false, + default: '', + }, + dropdownLength: { + type: Number, + required: false, + default: Infinity, + }, + jobHovered: { + type: String, + required: false, + default: '', + }, + pipelineExpanded: { + type: Object, + required: false, + default: () => ({}), + }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, + }, + computed: { + boundary() { + return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; + }, + detailsPath() { + return this.status.details_path; + }, + hasDetails() { + return this.status.has_details; + }, + status() { + return this.job && this.job.status ? this.job.status : {}; + }, + tooltipText() { + const textBuilder = []; + const { name: jobName } = this.job; + + if (jobName) { + textBuilder.push(jobName); + } + + const { tooltip: statusTooltip } = this.status; + if (jobName && statusTooltip) { + textBuilder.push('-'); + } + + if (statusTooltip) { + if (this.isDelayedJob) { + textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime })); + } else { + textBuilder.push(statusTooltip); + } + } + + return textBuilder.join(' '); + }, + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasAction() { + return this.job.status && this.job.status.action && this.job.status.action.path; + }, + relatedDownstreamHovered() { + return this.job.name === this.jobHovered; + }, + relatedDownstreamExpanded() { + return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded; + }, + jobClasses() { + return this.relatedDownstreamHovered || this.relatedDownstreamExpanded + ? `${this.$options.hoverClass} ${this.cssClassJobName}` + : this.cssClassJobName; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`); + }, + methods: { + hideTooltips() { + this.$root.$emit(BV_HIDE_TOOLTIP); + }, + pipelineActionRequestComplete() { + this.$emit('pipelineActionRequestComplete'); + }, + }, +}; +</script> +<template> + <div + class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" + data-qa-selector="job_item_container" + > + <gl-link + v-if="hasDetails" + v-gl-tooltip="{ + boundary: 'viewport', + placement: 'bottom', + customClass: 'gl-pointer-events-none', + }" + :href="detailsPath" + :title="tooltipText" + :class="jobClasses" + class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" + data-testid="job-with-link" + @click.stop="hideTooltips" + @mouseout="hideTooltips" + > + <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> + </gl-link> + + <div + v-else + v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" + :title="tooltipText" + :class="jobClasses" + class="js-job-component-tooltip non-details-job-component menu-item" + data-testid="job-without-link" + @mouseout="hideTooltips" + > + <job-name-component :name="job.name" :status="job.status" :icon-size="24" /> + </div> + + <action-component + v-if="hasAction" + :tooltip-text="status.action.title" + :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/pipelines_list/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue index cf0849751df..235126fea0c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue @@ -41,29 +41,29 @@ export default { <template> <div class="nav-controls"> <gl-button - v-if="newPipelinePath" - :href="newPipelinePath" - variant="success" - category="primary" - class="js-run-pipeline" - data-testid="run-pipeline-button" - data-qa-selector="run_pipeline_button" - > - {{ s__('Pipelines|Run Pipeline') }} - </gl-button> - - <gl-button v-if="resetCachePath" :loading="isResetCacheButtonLoading" class="js-clear-cache" data-testid="clear-cache-button" @click="onClickResetCache" > - {{ s__('Pipelines|Clear Runner Caches') }} + {{ s__('Pipelines|Clear runner caches') }} </gl-button> <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button"> - {{ s__('Pipelines|CI Lint') }} + {{ s__('Pipelines|CI lint') }} + </gl-button> + + <gl-button + v-if="newPipelinePath" + :href="newPipelinePath" + variant="confirm" + category="primary" + class="js-run-pipeline" + data-testid="run-pipeline-button" + data-qa-selector="run_pipeline_button" + > + {{ s__('Pipeline|Run pipeline') }} </gl-button> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue index 05372010d0f..2b33467e948 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue @@ -36,7 +36,7 @@ export default { }; </script> <template> - <div data-testid="widget-mini-pipeline-graph"> + <div data-testid="pipeline-mini-graph"> <div v-for="stage in stages" :key="stage.name" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue index bdb7dd06620..bf992b84387 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue @@ -17,7 +17,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; import eventHub from '../../event_hub'; -import JobItem from '../graph/job_item.vue'; +import JobItem from './job_item.vue'; export default { components: { @@ -103,7 +103,7 @@ export default { <template> <gl-dropdown ref="dropdown" - v-gl-tooltip.hover + v-gl-tooltip.hover.ds0 data-testid="mini-pipeline-graph-dropdown" :title="stage.title" variant="link" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue index c707b395192..0528e4c147c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue @@ -17,19 +17,11 @@ export default { user() { return this.pipeline.user; }, - classes() { - const triggererClass = 'pipeline-triggerer'; - - if (this.glFeatures.newPipelinesTable) { - return triggererClass; - } - return `table-section section-10 d-none d-md-block ${triggererClass}`; - }, }, }; </script> <template> - <div :class="classes" data-testid="pipeline-triggerer"> + <div class="pipeline-triggerer" data-testid="pipeline-triggerer"> <user-avatar-link v-if="user" :link-href="user.path" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 0de520a2ca7..d39e120dc6c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -49,19 +49,11 @@ export default { autoDevopsHelpPath() { return helpPagePath('topics/autodevops/index.md'); }, - classes() { - const tagsClass = 'pipeline-tags'; - - if (this.glFeatures.newPipelinesTable) { - return tagsClass; - } - return `table-section section-10 d-none d-md-block ${tagsClass}`; - }, }, }; </script> <template> - <div :class="classes" data-testid="pipeline-url-table-cell"> + <div class="pipeline-tags" data-testid="pipeline-url-table-cell"> <gl-link :href="pipeline.path" data-testid="pipeline-url-link" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 19d93e7d083..f14a582d731 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { isEqual } from 'lodash'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { getParameterByName } from '~/lib/utils/common_utils'; @@ -10,7 +10,6 @@ import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../ import PipelinesMixin from '../../mixins/pipelines_mixin'; import PipelinesService from '../../services/pipelines_service'; import { validateParams } from '../../utils'; -import SvgBlankState from './blank_state.vue'; import EmptyState from './empty_state.vue'; import NavigationControls from './nav_controls.vue'; import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; @@ -19,13 +18,13 @@ import PipelinesTableComponent from './pipelines_table.vue'; export default { components: { EmptyState, + GlEmptyState, GlIcon, GlLoadingIcon, NavigationTabs, NavigationControls, PipelinesFilteredSearch, PipelinesTableComponent, - SvgBlankState, TablePagination, }, mixins: [PipelinesMixin], @@ -314,6 +313,7 @@ export default { </div> <pipelines-filtered-search + v-if="stateToRender !== $options.stateMap.emptyState" :project-id="projectId" :params="validatedParams" @filterPipelines="filterPipelines" @@ -333,19 +333,19 @@ export default { :can-set-ci="canCreatePipeline" /> - <svg-blank-state + <gl-empty-state v-else-if="stateToRender === $options.stateMap.error" :svg-path="errorStateSvgPath" - :message=" + :title=" s__(`Pipelines|There was an error fetching the pipelines. Try again in a few moments or contact your support team.`) " /> - <svg-blank-state + <gl-empty-state v-else-if="stateToRender === $options.stateMap.emptyTab" :svg-path="noPipelinesSvgPath" - :message="emptyTabMessage" + :title="emptyTabMessage" /> <div v-else-if="stateToRender === $options.stateMap.tableList"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue new file mode 100644 index 00000000000..c2ec8c57fd7 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue @@ -0,0 +1,143 @@ +<script> +import { GlButton, GlCard, GlSprintf } from '@gitlab/ui'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { s__, sprintf } from '~/locale'; +import { HELLO_WORLD_TEMPLATE_KEY } from '../../constants'; + +export default { + components: { + GlButton, + GlCard, + GlSprintf, + }, + HELLO_WORLD_TEMPLATE_KEY, + i18n: { + cta: s__('Pipelines|Use template'), + testTemplates: { + title: s__('Pipelines|Use a sample CI/CD template'), + subtitle: s__( + 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.', + ), + helloWorld: { + title: s__('Pipelines|“Hello world” with GitLab CI/CD'), + description: s__( + 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a simple pipeline that runs a “Hello world” script.', + ), + }, + }, + templates: { + title: s__('Pipelines|Use a CI/CD template'), + subtitle: s__( + "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.", + ), + description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'), + }, + }, + inject: ['addCiYmlPath', 'suggestedCiTemplates'], + data() { + const templates = this.suggestedCiTemplates.map(({ name, logo }) => { + return { + name, + logo, + link: mergeUrlParams({ template: name }, this.addCiYmlPath), + description: sprintf(this.$options.i18n.templates.description, { name }), + }; + }); + + return { + templates, + helloWorldTemplateUrl: mergeUrlParams( + { template: HELLO_WORLD_TEMPLATE_KEY }, + this.addCiYmlPath, + ), + }; + }, + methods: { + trackEvent(template) { + const tracking = new ExperimentTracking('pipeline_empty_state_templates', { + label: template, + }); + tracking.event('template_clicked'); + }, + }, +}; +</script> +<template> + <div> + <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.testTemplates.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6"> + <gl-sprintf :message="$options.i18n.testTemplates.subtitle"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <div class="row gl-mb-8"> + <div class="col-lg-3"> + <gl-card> + <div class="gl-flex-direction-row"> + <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800 gl-mb-2">{{ + $options.i18n.testTemplates.helloWorld.title + }}</strong> + </div> + <p class="gl-font-sm">{{ $options.i18n.testTemplates.helloWorld.description }}</p> + </div> + + <gl-button + category="primary" + variant="confirm" + :href="helloWorldTemplateUrl" + data-testid="test-template-link" + @click="trackEvent($options.HELLO_WORLD_TEMPLATE_KEY)" + > + {{ $options.i18n.cta }} + </gl-button> + </gl-card> + </div> + </div> + + <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.templates.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6">{{ $options.i18n.templates.subtitle }}</p> + + <ul class="gl-list-style-none gl-pl-0"> + <li v-for="template in templates" :key="template.name"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3" + > + <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> + <img + width="64" + height="64" + :src="template.logo" + class="gl-mr-6" + data-testid="template-logo" + /> + <div class="gl-flex-direction-row"> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800" data-testid="template-name">{{ + template.name + }}</strong> + </div> + <p class="gl-mb-0 gl-font-sm" data-testid="template-description"> + {{ template.description }} + </p> + </div> + </div> + <gl-button + category="primary" + variant="confirm" + :href="template.link" + data-testid="template-link" + @click="trackEvent(template.name)" + > + {{ $options.i18n.cta }} + </gl-button> + </div> + </li> + </ul> + </div> +</template> 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 aa27aa7e50d..47fc7023222 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -1,7 +1,6 @@ <script> import { GlTable, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; import PipelineMiniGraph from './pipeline_mini_graph.vue'; import PipelineOperations from './pipeline_operations.vue'; @@ -10,7 +9,6 @@ import PipelineTriggerer from './pipeline_triggerer.vue'; import PipelineUrl from './pipeline_url.vue'; import PipelinesCommit from './pipelines_commit.vue'; import PipelinesStatusBadge from './pipelines_status_badge.vue'; -import PipelinesTableRowComponent from './pipelines_table_row.vue'; import PipelinesTimeago from './time_ago.vue'; const DEFAULT_TD_CLASS = 'gl-p-5!'; @@ -83,7 +81,6 @@ export default { PipelineOperations, PipelinesStatusBadge, PipelineStopModal, - PipelinesTableRowComponent, PipelinesTimeago, PipelineTriggerer, PipelineUrl, @@ -91,7 +88,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], props: { pipelines: { type: Array, @@ -149,41 +145,7 @@ export default { </script> <template> <div class="ci-table"> - <div v-if="!glFeatures.newPipelinesTable" data-testid="legacy-ci-table"> - <div class="gl-responsive-table-row table-row-header" role="row"> - <div class="table-section section-10 js-pipeline-status" role="rowheader"> - {{ s__('Pipeline|Status') }} - </div> - <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader"> - {{ s__('Pipeline|Pipeline') }} - </div> - <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader"> - {{ s__('Pipeline|Triggerer') }} - </div> - <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader"> - {{ s__('Pipeline|Commit') }} - </div> - <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader"> - {{ s__('Pipeline|Stages') }} - </div> - <div class="table-section section-15" role="rowheader"></div> - <div class="table-section section-20" role="rowheader"> - <slot name="table-header-actions"></slot> - </div> - </div> - <pipelines-table-row-component - v-for="model in pipelines" - :key="model.id" - :pipeline="model" - :pipeline-schedule-url="pipelineScheduleUrl" - :update-graph-dropdown="updateGraphDropdown" - :view-type="viewType" - :canceling-pipeline="cancelingPipeline" - /> - </div> - <gl-table - v-else :fields="$options.fields" :items="pipelines" tbody-tr-class="commit" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue deleted file mode 100644 index f684a0b0fcd..00000000000 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue +++ /dev/null @@ -1,269 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; -import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; -import CommitComponent from '~/vue_shared/components/commit.vue'; -import eventHub from '../../event_hub'; -import PipelineMiniGraph from './pipeline_mini_graph.vue'; -import PipelineTriggerer from './pipeline_triggerer.vue'; -import PipelineUrl from './pipeline_url.vue'; -import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; -import PipelinesManualActionsComponent from './pipelines_manual_actions.vue'; -import PipelinesTimeago from './time_ago.vue'; - -export default { - i18n: { - cancelTitle: __('Cancel'), - redeployTitle: __('Retry'), - }, - directives: { - GlTooltip: GlTooltipDirective, - GlModalDirective, - }, - components: { - PipelinesManualActionsComponent, - PipelinesArtifactsComponent, - CommitComponent, - PipelineMiniGraph, - PipelineUrl, - PipelineTriggerer, - CiBadge, - PipelinesTimeago, - GlButton, - }, - props: { - pipeline: { - type: Object, - required: true, - }, - pipelineScheduleUrl: { - type: String, - required: false, - default: '', - }, - updateGraphDropdown: { - type: Boolean, - required: false, - default: false, - }, - viewType: { - type: String, - required: true, - }, - cancelingPipeline: { - type: Number, - required: false, - default: null, - }, - }, - data() { - return { - isRetrying: false, - }; - }, - computed: { - actions() { - if (!this.pipeline || !this.pipeline.details) { - return []; - } - const { details } = this.pipeline; - return [...(details.manual_actions || []), ...(details.scheduled_actions || [])]; - }, - /** - * If provided, returns the commit tag. - * Needed to render the commit component column. - * - * This field needs a lot of verification, because of different possible cases: - * - * 1. person who is an author of a commit might be a GitLab user - * 2. if person who is an author of a commit is a GitLab user, they can have a GitLab avatar - * 3. If GitLab user does not have avatar they might have a Gravatar - * 4. If committer is not a GitLab User they can have a Gravatar - * 5. We do not have consistent API object in this case - * 6. We should improve API and the code - * - * @returns {Object|Undefined} - */ - commitAuthor() { - let commitAuthorInformation; - - if (!this.pipeline || !this.pipeline.commit) { - return null; - } - - // 1. person who is an author of a commit might be a GitLab user - if (this.pipeline.commit.author) { - // 2. if person who is an author of a commit is a GitLab user - // they can have a GitLab avatar - if (this.pipeline.commit.author.avatar_url) { - commitAuthorInformation = this.pipeline.commit.author; - - // 3. If GitLab user does not have avatar, they might have a Gravatar - } else if (this.pipeline.commit.author_gravatar_url) { - commitAuthorInformation = { - ...this.pipeline.commit.author, - avatar_url: this.pipeline.commit.author_gravatar_url, - }; - } - // 4. If committer is not a GitLab User, they can have a Gravatar - } else { - commitAuthorInformation = { - avatar_url: this.pipeline.commit.author_gravatar_url, - path: `mailto:${this.pipeline.commit.author_email}`, - username: this.pipeline.commit.author_name, - }; - } - - return commitAuthorInformation; - }, - commitTag() { - return this.pipeline?.ref?.tag; - }, - commitRef() { - return this.pipeline?.ref; - }, - commitUrl() { - return this.pipeline?.commit?.commit_path; - }, - commitShortSha() { - return this.pipeline?.commit?.short_id; - }, - commitTitle() { - return this.pipeline?.commit?.title; - }, - pipelineStatus() { - return this.pipeline?.details?.status ?? {}; - }, - hasStages() { - return this.pipeline?.details?.stages?.length > 0; - }, - displayPipelineActions() { - return ( - this.pipeline.flags.retryable || - this.pipeline.flags.cancelable || - this.pipeline.details.manual_actions.length || - this.pipeline.details.artifacts.length - ); - }, - isChildView() { - return this.viewType === 'child'; - }, - isCancelling() { - return this.cancelingPipeline === this.pipeline.id; - }, - }, - watch: { - pipeline() { - this.isRetrying = false; - }, - }, - methods: { - handleCancelClick() { - eventHub.$emit('openConfirmationModal', { - pipeline: this.pipeline, - endpoint: this.pipeline.cancel_path, - }); - }, - handleRetryClick() { - this.isRetrying = true; - eventHub.$emit('retryPipeline', this.pipeline.retry_path); - }, - handlePipelineActionRequestComplete() { - // warn the pipelines table to update - eventHub.$emit('refreshPipelinesTable'); - }, - }, -}; -</script> -<template> - <div class="commit gl-responsive-table-row"> - <div class="table-section section-10 commit-link"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div> - <div class="table-mobile-content"> - <ci-badge - :status="pipelineStatus" - :show-text="!isChildView" - :icon-classes="'gl-vertical-align-middle!'" - data-qa-selector="pipeline_commit_status" - /> - </div> - </div> - - <pipeline-url :pipeline="pipeline" :pipeline-schedule-url="pipelineScheduleUrl" /> - <pipeline-triggerer :pipeline="pipeline" /> - - <div class="table-section section-wrap section-20"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Commit') }}</div> - <div class="table-mobile-content"> - <commit-component - :tag="commitTag" - :commit-ref="commitRef" - :commit-url="commitUrl" - :merge-request-ref="pipeline.merge_request" - :short-sha="commitShortSha" - :title="commitTitle" - :author="commitAuthor" - :show-ref-info="!isChildView" - /> - </div> - </div> - - <div class="table-section section-wrap section-15 stage-cell"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div> - <div class="table-mobile-content"> - <pipeline-mini-graph - v-if="hasStages" - :stages="pipeline.details.stages" - :update-dropdown="updateGraphDropdown" - @pipelineActionRequestComplete="handlePipelineActionRequestComplete" - /> - </div> - </div> - - <pipelines-timeago class="gl-text-right" :pipeline="pipeline" /> - - <div - v-if="displayPipelineActions" - class="table-section section-20 table-button-footer pipeline-actions" - > - <div class="btn-group table-action-buttons"> - <pipelines-manual-actions-component v-if="actions.length > 0" :actions="actions" /> - - <pipelines-artifacts-component - v-if="pipeline.details.artifacts.length" - :artifacts="pipeline.details.artifacts" - /> - - <gl-button - v-if="pipeline.flags.retryable" - v-gl-tooltip.hover - :aria-label="$options.i18n.redeployTitle" - :title="$options.i18n.redeployTitle" - :disabled="isRetrying" - :loading="isRetrying" - class="js-pipelines-retry-button" - data-qa-selector="pipeline_retry_button" - icon="repeat" - variant="default" - category="secondary" - @click="handleRetryClick" - /> - - <gl-button - v-if="pipeline.flags.cancelable" - v-gl-tooltip.hover - v-gl-modal-directive="'confirmation-modal'" - :aria-label="$options.i18n.cancelTitle" - :title="$options.i18n.cancelTitle" - :loading="isCancelling" - :disabled="isCancelling" - icon="close" - variant="danger" - category="primary" - class="js-pipelines-cancel-button" - @click="handleCancelClick" - /> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 543bdf94307..e6b03751350 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -22,6 +22,12 @@ export default { finishedTime() { return this.pipeline?.details?.finished_at; }, + skipped() { + return this.pipeline?.details?.status?.label === 'skipped'; + }, + stuck() { + return this.pipeline.flags.stuck; + }, durationFormatted() { const date = new Date(this.duration * 1000); @@ -42,46 +48,50 @@ export default { return `${hh}:${mm}:${ss}`; }, - legacySectionClass() { - return !this.glFeatures.newPipelinesTable ? 'table-section section-15' : ''; - }, - legacyTableMobileClass() { - return !this.glFeatures.newPipelinesTable ? 'table-mobile-content' : ''; - }, showInProgress() { - return !this.duration && !this.finishedTime; + return !this.duration && !this.finishedTime && !this.skipped; + }, + showSkipped() { + return !this.duration && !this.finishedTime && this.skipped; }, }, }; </script> <template> - <div :class="legacySectionClass"> - <div v-if="!glFeatures.newPipelinesTable" class="table-mobile-header" role="rowheader"> - {{ s__('Pipeline|Duration') }} - </div> - <div :class="legacyTableMobileClass"> - <span v-if="showInProgress" data-testid="pipeline-in-progress"> - <gl-icon name="hourglass" class="gl-vertical-align-baseline! gl-mr-2" :size="12" /> - {{ s__('Pipeline|In progress') }} - </span> + <div> + <span v-if="showInProgress" data-testid="pipeline-in-progress"> + <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" /> + <gl-icon + v-else + name="hourglass" + class="gl-vertical-align-baseline! gl-mr-2" + :size="12" + data-testid="hourglass-icon" + /> + {{ s__('Pipeline|In progress') }} + </span> + + <span v-if="showSkipped" data-testid="pipeline-skipped"> + <gl-icon name="status_skipped_borderless" class="gl-mr-2" :size="16" /> + {{ s__('Pipeline|Skipped') }} + </span> - <p v-if="duration" class="duration"> - <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" /> - {{ durationFormatted }} - </p> + <p v-if="duration" class="duration"> + <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" /> + {{ durationFormatted }} + </p> - <p v-if="finishedTime" class="finished-at d-none d-md-block"> - <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" /> + <p v-if="finishedTime" class="finished-at d-none d-md-block"> + <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" /> - <time - v-gl-tooltip - :title="tooltipTitle(finishedTime)" - data-placement="top" - data-container="body" - > - {{ timeFormatted(finishedTime) }} - </time> - </p> - </div> + <time + v-gl-tooltip + :title="tooltipTitle(finishedTime)" + data-placement="top" + data-container="body" + > + {{ timeFormatted(finishedTime) }} + </time> + </p> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue index d33d4e7dfd0..79b1b6af38b 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -72,6 +72,7 @@ export default { size="small" class="gl-mr-3 js-back-button" icon="angle-left" + :aria-label="__('Go back')" @click="onBackClick" /> diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js index 15073079c0a..2d24beb8323 100644 --- a/app/assets/javascripts/pipelines/components/unwrapping_utils.js +++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js @@ -1,15 +1,33 @@ +import { reportToSentry } from '../utils'; + const unwrapGroups = (stages) => { - return stages.map((stage) => { + return stages.map((stage, idx) => { const { groups: { nodes: groups }, } = stage; - return { ...stage, groups }; + + /* + Being peformance conscious here means we don't want to spread and copy the + group value just to add one parameter. + */ + /* eslint-disable no-param-reassign */ + const groupsWithStageName = groups.map((group) => { + group.stageName = stage.name; + return group; + }); + /* eslint-enable no-param-reassign */ + + return { node: { ...stage, groups: groupsWithStageName }, lookup: { stageIdx: idx } }; }); }; const unwrapNodesWithName = (jobArray, prop, field = 'name') => { + if (jobArray.length < 1) { + reportToSentry('unwrapping_utils', 'undefined_job_hunt, array empty from backend'); + } + return jobArray.map((job) => { - return { ...job, [prop]: job[prop].nodes.map((item) => item[field]) }; + return { ...job, [prop]: job[prop].nodes.map((item) => item[field] || '') }; }); }; @@ -17,20 +35,34 @@ const unwrapJobWithNeeds = (denodedJobArray) => { return unwrapNodesWithName(denodedJobArray, 'needs'); }; -const unwrapStagesWithNeeds = (denodedStages) => { +const unwrapStagesWithNeedsAndLookup = (denodedStages) => { const unwrappedNestedGroups = unwrapGroups(denodedStages); - const nodes = unwrappedNestedGroups.map((node) => { + const lookupMap = {}; + + const nodes = unwrappedNestedGroups.map(({ node, lookup }) => { const { groups } = node; - const groupsWithJobs = groups.map((group) => { + const groupsWithJobs = groups.map((group, idx) => { const jobs = unwrapJobWithNeeds(group.jobs.nodes); + + lookupMap[group.name] = { ...lookup, groupIdx: idx }; return { ...group, jobs }; }); return { ...node, groups: groupsWithJobs }; }); - return nodes; + return { stages: nodes, lookup: lookupMap }; +}; + +const unwrapStagesWithNeeds = (denodedStages) => { + return unwrapStagesWithNeedsAndLookup(denodedStages).stages; }; -export { unwrapGroups, unwrapNodesWithName, unwrapJobWithNeeds, unwrapStagesWithNeeds }; +export { + unwrapGroups, + unwrapJobWithNeeds, + unwrapNodesWithName, + unwrapStagesWithNeeds, + unwrapStagesWithNeedsAndLookup, +}; diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 21b114825a6..01705e7726f 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -35,3 +35,6 @@ export const POST_FAILURE = 'post_failure'; export const UNSUPPORTED_DATA = 'unsupported_data'; export const CHILD_VIEW = 'child'; + +// The key of the template is the same as the filename +export const HELLO_WORLD_TEMPLATE_KEY = 'Hello-World'; diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql new file mode 100644 index 00000000000..e4fd55a28be --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql @@ -0,0 +1,5 @@ +mutation DismissPipelineNotification($featureName: String!) { + userCalloutCreate(input: { featureName: $featureName }) { + errors + } +} diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql index c73b186739e..887c217da41 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql +++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql @@ -1,6 +1,7 @@ query getDagVisData($projectPath: ID!, $iid: ID!) { project(fullPath: $projectPath) { pipeline(iid: $iid) { + id stages { nodes { name diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql new file mode 100644 index 00000000000..12b391e41ac --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql @@ -0,0 +1,13 @@ +query getUser { + currentUser { + id + __typename + callouts { + __typename + nodes { + __typename + featureName + } + } + } +} diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js index 2321728e30c..d9c9289f66e 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js @@ -190,7 +190,7 @@ export default { .then(() => this.updateTable()) .catch(() => { createFlash( - __('An error occurred while trying to run a new pipeline for this Merge Request.'), + __('An error occurred while trying to run a new pipeline for this merge request.'), ); }) .finally(() => this.store.toggleIsRunningPipeline(false)); diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index c3444f38ea0..a2bc049c3c7 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,11 +3,15 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; import Translate from '~/vue_shared/translate'; import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; -import { reportToSentry } from './components/graph/utils'; import TestReports from './components/test_reports/test_reports.vue'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import createDagApp from './pipeline_details_dag'; +import { createPipelinesDetailApp } from './pipeline_details_graph'; +import { createPipelineHeaderApp } from './pipeline_details_header'; +import { createPipelineNotificationApp } from './pipeline_details_notification'; +import { apolloProvider } from './pipeline_shared_client'; import createTestReportsStore from './stores/test_reports'; +import { reportToSentry } from './utils'; Vue.use(Translate); @@ -15,6 +19,7 @@ const SELECTORS = { PIPELINE_DETAILS: '.js-pipeline-details-vue', PIPELINE_GRAPH: '#js-pipeline-graph-vue', PIPELINE_HEADER: '#js-pipeline-header-vue', + PIPELINE_NOTIFICATION: '#js-pipeline-notification', PIPELINE_TESTS: '#js-pipeline-tests-detail', }; @@ -79,21 +84,28 @@ const createTestDetails = () => { }; export default async function initPipelineDetailsBundle() { - createTestDetails(); - createDagApp(); - const canShowNewPipelineDetails = gon.features.graphqlPipelineDetails || gon.features.graphqlPipelineDetailsUsers; const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); - if (canShowNewPipelineDetails) { + try { + createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag); + } catch { + Flash(__('An error occurred while loading a section of this page.')); + } + + if (gon.features.pipelineGraphLayersView) { try { - const { createPipelinesDetailApp } = await import( - /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' - ); + createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider); + } catch { + Flash(__('An error occurred while loading a section of this page.')); + } + } - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, dataset); + if (canShowNewPipelineDetails) { + try { + createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); } catch { Flash(__('An error occurred while loading the pipeline.')); } @@ -107,12 +119,6 @@ export default async function initPipelineDetailsBundle() { createLegacyPipelinesDetailApp(mediator); } - try { - const { createPipelineHeaderApp } = await import( - /* webpackChunkName: 'createPipelineHeaderApp' */ './pipeline_details_header' - ); - createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER); - } catch { - Flash(__('An error occurred while loading a section of this page.')); - } + createDagApp(apolloProvider); + createTestDetails(); } diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js index 4ee0ad462d2..e2835ecc4d1 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_dag.js +++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js @@ -1,15 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import Dag from './components/dag/dag.vue'; Vue.use(VueApollo); -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -const createDagApp = () => { +const createDagApp = (apolloProvider) => { const el = document.querySelector('#js-pipeline-dag-vue'); if (!el) { diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js index 9eba39738dc..39c3c2ea5c5 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js @@ -1,23 +1,14 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { GRAPHQL } from './components/graph/constants'; import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue'; -import { reportToSentry } from './components/graph/utils'; +import { reportToSentry } from './utils'; Vue.use(VueApollo); -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - useGet: true, - }, - ), -}); - const createPipelinesDetailApp = ( selector, + apolloProvider, { pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {}, ) => { // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js index cba29acdb32..1c619768764 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -1,15 +1,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import pipelineHeader from './components/header_component.vue'; Vue.use(VueApollo); -const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), -}); - -export const createPipelineHeaderApp = (elSelector) => { +export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => { const el = document.querySelector(elSelector); if (!el) { @@ -27,6 +22,7 @@ export const createPipelineHeaderApp = (elSelector) => { provide: { paths: { fullProject: fullPath, + graphqlResourceEtag, pipelinesPath, }, pipelineId, diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js new file mode 100644 index 00000000000..be234e8972d --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import PipelineNotification from './components/notification/pipeline_notification.vue'; + +Vue.use(VueApollo); + +export const createPipelineNotificationApp = (elSelector, apolloProvider) => { + const el = document.querySelector(elSelector); + + if (!el) { + return; + } + + const { dagDocPath } = el?.dataset; + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + PipelineNotification, + }, + provide: { + dagDocPath, + }, + apolloProvider, + render(createElement) { + return createElement('pipeline-notification'); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js new file mode 100644 index 00000000000..c3be487caae --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js @@ -0,0 +1,11 @@ +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + useGet: true, + }, + ), +}); diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 0e2e9785956..9ed4365ad75 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -27,6 +27,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { errorStateSvgPath, noPipelinesSvgPath, newPipelinePath, + addCiYmlPath, + suggestedCiTemplates, canCreatePipeline, hasGitlabCi, ciLintPath, @@ -37,6 +39,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { return new Vue({ el, + provide: { + addCiYmlPath, + suggestedCiTemplates: JSON.parse(suggestedCiTemplates), + }, data() { return { store: new PipelinesStore(), diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 22820fca43e..0a6c326fa3d 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import { pickBy } from 'lodash'; import { createNodeDict } from './components/parsing_utils'; import { SUPPORTED_FILTER_PARAMETERS } from './constants'; @@ -65,3 +66,10 @@ export const generateJobNeedsDict = (jobs = {}) => { return { ...acc, [value]: uniqueValues }; }, {}); }; + +export const reportToSentry = (component, failureType) => { + Sentry.withScope((scope) => { + scope.setTag('component', component); + Sentry.captureException(failureType); + }); +}; diff --git a/app/assets/javascripts/projects/commit/components/commit_comments_button.vue b/app/assets/javascripts/projects/commit/components/commit_comments_button.vue new file mode 100644 index 00000000000..67b5e1e512c --- /dev/null +++ b/app/assets/javascripts/projects/commit/components/commit_comments_button.vue @@ -0,0 +1,42 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + }, + props: { + commentsCount: { + type: Number, + required: true, + }, + }, + computed: { + tooltipText() { + return n__('%d comment on this commit', '%d comments on this commit', this.commentsCount); + }, + showCommentButton() { + return this.commentsCount > 0; + }, + }, +}; +</script> + +<template> + <span + v-if="showCommentButton" + v-gl-tooltip + class="gl-display-none gl-sm-display-inline-block" + tabindex="0" + :title="tooltipText" + data-testid="comment-button-wrapper" + > + <gl-button icon="comment" class="gl-mr-3" disabled> + {{ commentsCount }} + </gl-button> + </span> +</template> diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue new file mode 100644 index 00000000000..d96d1035ed0 --- /dev/null +++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue @@ -0,0 +1,107 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlDropdownSectionHeader } from '@gitlab/ui'; +import { OPEN_REVERT_MODAL, OPEN_CHERRY_PICK_MODAL } from '../constants'; +import eventHub from '../event_hub'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlDropdownSectionHeader, + }, + inject: { + newProjectTagPath: { + default: '', + }, + emailPatchesPath: { + default: '', + }, + plainDiffPath: { + default: '', + }, + }, + props: { + canRevert: { + type: Boolean, + required: true, + }, + canCherryPick: { + type: Boolean, + required: true, + }, + canTag: { + type: Boolean, + required: true, + }, + canEmailPatches: { + type: Boolean, + required: true, + }, + }, + computed: { + showDivider() { + return this.canRevert || this.canCherryPick || this.canTag; + }, + }, + methods: { + showModal(modalId) { + eventHub.$emit(modalId); + }, + }, + openRevertModal: OPEN_REVERT_MODAL, + openCherryPickModal: OPEN_CHERRY_PICK_MODAL, +}; +</script> + +<template> + <gl-dropdown + :text="__('Options')" + right + data-testid="commit-options-dropdown" + data-qa-selector="options_button" + class="gl-xs-w-full" + > + <gl-dropdown-item + v-if="canRevert" + data-testid="revert-link" + @click="showModal($options.openRevertModal)" + > + {{ s__('ChangeTypeAction|Revert') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canCherryPick" + data-testid="cherry-pick-link" + data-qa-selector="cherry_pick_button" + @click="showModal($options.openCherryPickModal)" + > + {{ s__('ChangeTypeAction|Cherry-pick') }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canTag" :href="newProjectTagPath" data-testid="tag-link"> + {{ s__('CreateTag|Tag') }} + </gl-dropdown-item> + <gl-dropdown-divider v-if="showDivider" /> + <gl-dropdown-section-header> + {{ __('Download') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-if="canEmailPatches" + :href="emailPatchesPath" + download + rel="nofollow" + data-testid="email-patches-link" + data-qa-selector="email_patches" + > + {{ s__('DownloadCommit|Email Patches') }} + </gl-dropdown-item> + <gl-dropdown-item + :href="plainDiffPath" + download + rel="nofollow" + data-testid="plain-diff-link" + data-qa-selector="plain_diff" + > + {{ s__('DownloadCommit|Plain Diff') }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index 30968d29cde..6eefa5f55e4 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -37,6 +37,11 @@ export default { type: String, required: true, }, + isCherryPick: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -47,6 +52,7 @@ export default { { variant: 'success' }, { category: 'primary' }, { 'data-testid': 'submit-commit' }, + { 'data-qa-selector': 'submit_commit_button' }, ], }, actionCancel: { @@ -110,7 +116,7 @@ export default { <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> <gl-form-group - v-if="glFeatures.pickIntoProject" + v-if="glFeatures.pickIntoProject && isCherryPick" :label="i18n.projectLabel" label-for="start_project" data-testid="dropdown-group" diff --git a/app/assets/javascripts/projects/commit/components/form_trigger.vue b/app/assets/javascripts/projects/commit/components/form_trigger.vue deleted file mode 100644 index 3561b5c2473..00000000000 --- a/app/assets/javascripts/projects/commit/components/form_trigger.vue +++ /dev/null @@ -1,35 +0,0 @@ -<script> -import { GlLink } from '@gitlab/ui'; -import eventHub from '../event_hub'; - -export default { - components: { - GlLink, - }, - inject: { - displayText: { - default: '', - }, - testId: { - default: '', - }, - }, - props: { - openModal: { - type: String, - required: true, - }, - }, - methods: { - showModal() { - eventHub.$emit(this.openModal); - }, - }, -}; -</script> - -<template> - <gl-link data-is-link="true" :data-testid="testId" @click="showModal"> - {{ displayText }} - </gl-link> -</template> diff --git a/app/assets/javascripts/projects/commit/constants.js b/app/assets/javascripts/projects/commit/constants.js index d6bb4e9483f..d553bca360e 100644 --- a/app/assets/javascripts/projects/commit/constants.js +++ b/app/assets/javascripts/projects/commit/constants.js @@ -2,10 +2,8 @@ import { s__, __ } from '~/locale'; export const OPEN_REVERT_MODAL = 'openRevertModal'; export const REVERT_MODAL_ID = 'revert-commit-modal'; -export const REVERT_LINK_TEST_ID = 'revert-commit-link'; export const OPEN_CHERRY_PICK_MODAL = 'openCherryPickModal'; export const CHERRY_PICK_MODAL_ID = 'cherry-pick-commit-modal'; -export const CHERRY_PICK_LINK_TEST_ID = 'cherry-pick-commit-link'; export const I18N_MODAL = { startMergeRequest: s__('ChangeTypeAction|Start a %{newMergeRequest} with these changes'), diff --git a/app/assets/javascripts/projects/commit/index.js b/app/assets/javascripts/projects/commit/index.js index b5fdfc25236..d8d30c4332c 100644 --- a/app/assets/javascripts/projects/commit/index.js +++ b/app/assets/javascripts/projects/commit/index.js @@ -1,11 +1,11 @@ import initCherryPickCommitModal from './init_cherry_pick_commit_modal'; -import initCherryPickCommitTrigger from './init_cherry_pick_commit_trigger'; +import initCommitCommentsButton from './init_commit_comments_button'; +import initCommitOptionsDropdown from './init_commit_options_dropdown'; import initRevertCommitModal from './init_revert_commit_modal'; -import initRevertCommitTrigger from './init_revert_commit_trigger'; export default () => { initRevertCommitModal(); - initRevertCommitTrigger(); initCherryPickCommitModal(); - initCherryPickCommitTrigger(); + initCommitCommentsButton(); + initCommitOptionsDropdown(); }; diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js index ad31ad14b2a..47ee8237fea 100644 --- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js +++ b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_modal.js @@ -51,6 +51,7 @@ export default function initInviteMembersModal() { i18n: { ...I18N_CHERRY_PICK_MODAL, ...I18N_MODAL }, openModal: OPEN_CHERRY_PICK_MODAL, modalId: CHERRY_PICK_MODAL_ID, + isCherryPick: true, }, }), }); diff --git a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js b/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js deleted file mode 100644 index 942451dc96a..00000000000 --- a/app/assets/javascripts/projects/commit/init_cherry_pick_commit_trigger.js +++ /dev/null @@ -1,20 +0,0 @@ -import Vue from 'vue'; -import CommitFormTrigger from './components/form_trigger.vue'; -import { OPEN_CHERRY_PICK_MODAL, CHERRY_PICK_LINK_TEST_ID } from './constants'; - -export default function initInviteMembersTrigger() { - const el = document.querySelector('.js-cherry-pick-commit-trigger'); - - if (!el) { - return false; - } - - const { displayText } = el.dataset; - - return new Vue({ - el, - provide: { displayText, testId: CHERRY_PICK_LINK_TEST_ID }, - render: (createElement) => - createElement(CommitFormTrigger, { props: { openModal: OPEN_CHERRY_PICK_MODAL } }), - }); -} diff --git a/app/assets/javascripts/projects/commit/init_commit_comments_button.js b/app/assets/javascripts/projects/commit/init_commit_comments_button.js new file mode 100644 index 00000000000..d70f7cb65f3 --- /dev/null +++ b/app/assets/javascripts/projects/commit/init_commit_comments_button.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import CommitCommentsButton from './components/commit_comments_button.vue'; + +export default function initCommitCommentsButton() { + const el = document.querySelector('#js-commit-comments-button'); + + if (!el) { + return false; + } + + const { commentsCount } = el.dataset; + + return new Vue({ + el, + render: (createElement) => + createElement(CommitCommentsButton, { props: { commentsCount: Number(commentsCount) } }), + }); +} diff --git a/app/assets/javascripts/projects/commit/init_commit_options_dropdown.js b/app/assets/javascripts/projects/commit/init_commit_options_dropdown.js new file mode 100644 index 00000000000..339918e7661 --- /dev/null +++ b/app/assets/javascripts/projects/commit/init_commit_options_dropdown.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import CommitOptionsDropdown from './components/commit_options_dropdown.vue'; + +export default function initCommitOptionsDropdown() { + const el = document.querySelector('#js-commit-options-dropdown'); + + if (!el) { + return false; + } + + const { + newProjectTagPath, + emailPatchesPath, + plainDiffPath, + canRevert, + canCherryPick, + canTag, + canEmailPatches, + } = el.dataset; + + return new Vue({ + el, + provide: { newProjectTagPath, emailPatchesPath, plainDiffPath }, + render: (createElement) => + createElement(CommitOptionsDropdown, { + props: { + canRevert: parseBoolean(canRevert), + canCherryPick: parseBoolean(canCherryPick), + canTag: parseBoolean(canTag), + canEmailPatches: parseBoolean(canEmailPatches), + }, + }), + }); +} diff --git a/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js b/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js deleted file mode 100644 index dc5168524ca..00000000000 --- a/app/assets/javascripts/projects/commit/init_revert_commit_trigger.js +++ /dev/null @@ -1,20 +0,0 @@ -import Vue from 'vue'; -import CommitFormTrigger from './components/form_trigger.vue'; -import { OPEN_REVERT_MODAL, REVERT_LINK_TEST_ID } from './constants'; - -export default function initInviteMembersTrigger() { - const el = document.querySelector('.js-revert-commit-trigger'); - - if (!el) { - return false; - } - - const { displayText } = el.dataset; - - return new Vue({ - el, - provide: { displayText, testId: REVERT_LINK_TEST_ID }, - render: (createElement) => - createElement(CommitFormTrigger, { props: { openModal: OPEN_REVERT_MODAL } }), - }); -} diff --git a/app/assets/javascripts/projects/commit/store/actions.js b/app/assets/javascripts/projects/commit/store/actions.js index c72704303ca..2b25082eced 100644 --- a/app/assets/javascripts/projects/commit/store/actions.js +++ b/app/assets/javascripts/projects/commit/store/actions.js @@ -22,8 +22,8 @@ export const fetchBranches = ({ commit, dispatch, state }, query) => { .get(state.branchesEndpoint, { params: { search: query }, }) - .then(({ data }) => { - commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches || []); + .then(({ data = [] }) => { + commit(types.RECEIVE_BRANCHES_SUCCESS, data.Branches?.length ? data.Branches : data); }) .catch(() => { createFlash({ message: PROJECT_BRANCHES_ERROR }); diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js index 17c63ecf66b..69fe2d30489 100644 --- a/app/assets/javascripts/projects/commit_box/info/index.js +++ b/app/assets/javascripts/projects/commit_box/info/index.js @@ -1,27 +1,17 @@ import { fetchCommitMergeRequests } from '~/commit_merge_requests'; -import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; import { initCommitPipelineMiniGraph } from './init_commit_pipeline_mini_graph'; import { initDetailsButton } from './init_details_button'; import { loadBranches } from './load_branches'; -export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') => { - const containerEl = document.querySelector(containerSelector); - +export const initCommitBoxInfo = () => { // Display commit related branches - loadBranches(containerEl); + loadBranches(); // Related merge requests to this commit fetchCommitMergeRequests(); // Display pipeline mini graph for this commit - // Feature flag ci_commit_pipeline_mini_graph_vue - if (gon.features.ciCommitPipelineMiniGraphVue) { - initCommitPipelineMiniGraph(); - } else { - new MiniPipelineGraph({ - container: '.js-commit-pipeline-graph', - }).bindEvents(); - } + initCommitPipelineMiniGraph(); initDetailsButton(); }; diff --git a/app/assets/javascripts/projects/commit_box/info/load_branches.js b/app/assets/javascripts/projects/commit_box/info/load_branches.js index 8a0b2c30abe..d1136817cb3 100644 --- a/app/assets/javascripts/projects/commit_box/info/load_branches.js +++ b/app/assets/javascripts/projects/commit_box/info/load_branches.js @@ -2,7 +2,8 @@ import axios from 'axios'; import { sanitize } from '~/lib/dompurify'; import { __ } from '~/locale'; -export const loadBranches = (containerEl) => { +export const loadBranches = (containerSelector = '.js-commit-box-info') => { + const containerEl = document.querySelector(containerSelector); if (!containerEl) { return; } diff --git a/app/assets/javascripts/projects/compare/components/app_legacy.vue b/app/assets/javascripts/projects/compare/components/app_legacy.vue index c0ff58ee074..d3f09f7d69f 100644 --- a/app/assets/javascripts/projects/compare/components/app_legacy.vue +++ b/app/assets/javascripts/projects/compare/components/app_legacy.vue @@ -37,10 +37,22 @@ export default { required: true, }, }, + data() { + return { + from: this.paramsFrom, + to: this.paramsTo, + }; + }, methods: { onSubmit() { this.$refs.form.submit(); }, + onSwapRevision() { + [this.from, this.to] = [this.to, this.from]; // swaps 'from' and 'to' + }, + onSelectRevision({ direction, revision }) { + this[direction] = revision; // direction is either 'from' or 'to' + }, }, }; </script> @@ -57,19 +69,30 @@ export default { :refs-project-path="refsProjectPath" revision-text="Source" params-name="to" - :params-branch="paramsTo" + :params-branch="to" + data-testid="sourceRevisionDropdown" + @selectRevision="onSelectRevision" /> <div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div> <revision-dropdown :refs-project-path="refsProjectPath" revision-text="Target" params-name="from" - :params-branch="paramsFrom" + :params-branch="from" + data-testid="targetRevisionDropdown" + @selectRevision="onSelectRevision" /> <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit"> {{ s__('CompareRevisions|Compare') }} </gl-button> <gl-button + data-testid="swapRevisionsButton" + class="btn btn-default gl-button gl-ml-3" + @click="onSwapRevision" + > + {{ s__('CompareRevisions|Swap revisions') }} + </gl-button> + <gl-button v-if="projectMergeRequestPath" :href="projectMergeRequestPath" data-testid="projectMrButton" diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue index 822dfc09d81..cb9d8b64b33 100644 --- a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue @@ -46,14 +46,7 @@ export default { this.emitTargetProject(repo.name); }, setDefaultRepo() { - if (this.isSourceRevision) { - this.selectedRepo = this.projectTo; - return; - } - - const [defaultTargetProject] = this.projectsFrom; - this.emitTargetProject(defaultTargetProject.name); - this.selectedRepo = defaultTargetProject; + this.selectedRepo = this.projectTo; }, emitTargetProject(name) { if (!this.isSourceRevision) { diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue index a175af2f32e..d0b69344c12 100644 --- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue @@ -1,10 +1,12 @@ <script> import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui'; +import { debounce } from 'lodash'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; -const emptyDropdownText = s__('CompareRevisions|Select branch/tag'); +const EMPTY_DROPDOWN_TEXT = s__('CompareRevisions|Select branch/tag'); +const SEARCH_DEBOUNCE_MS = 300; export default { components: { @@ -38,19 +40,11 @@ export default { }; }, computed: { - filteredBranches() { - return this.branches.filter((branch) => - branch.toLowerCase().includes(this.searchTerm.toLowerCase()), - ); + hasBranches() { + return Boolean(this.branches?.length); }, - hasFilteredBranches() { - return this.filteredBranches.length; - }, - filteredTags() { - return this.tags.filter((tag) => tag.toLowerCase().includes(this.searchTerm.toLowerCase())); - }, - hasFilteredTags() { - return this.filteredTags.length; + hasTags() { + return Boolean(this.tags?.length); }, }, watch: { @@ -59,13 +53,34 @@ export default { this.fetchBranchesAndTags(true); } }, + searchTerm: debounce(function debounceSearch() { + this.searchBranchesAndTags(); + }, SEARCH_DEBOUNCE_MS), }, mounted() { this.fetchBranchesAndTags(); }, methods: { + searchBranchesAndTags() { + return axios + .get(this.refsProjectPath, { + params: { + search: this.searchTerm, + }, + }) + .then(({ data }) => { + this.branches = data.Branches || []; + this.tags = data.Tags || []; + }) + .catch(() => { + createFlash({ + message: s__( + 'CompareRevisions|There was an error while searching the branch/tag list. Please try again.', + ), + }); + }); + }, fetchBranchesAndTags(reset = false) { - const endpoint = this.refsProjectPath; this.loading = true; if (reset) { @@ -73,7 +88,7 @@ export default { } return axios - .get(endpoint) + .get(this.refsProjectPath) .then(({ data }) => { this.branches = data.Branches || []; this.tags = data.Tags || []; @@ -90,7 +105,7 @@ export default { }); }, getDefaultBranch() { - return this.paramsBranch || emptyDropdownText; + return this.paramsBranch || EMPTY_DROPDOWN_TEXT; }, onClick(revision) { this.selectedRevision = revision; @@ -119,24 +134,24 @@ export default { @keyup.enter="onSearchEnter" /> </template> - <gl-dropdown-section-header v-if="hasFilteredBranches"> + <gl-dropdown-section-header v-if="hasBranches"> {{ s__('CompareRevisions|Branches') }} </gl-dropdown-section-header> <gl-dropdown-item - v-for="(branch, index) in filteredBranches" - :key="`branch${index}`" + v-for="branch in branches" + :key="branch" is-check-item :is-checked="selectedRevision === branch" @click="onClick(branch)" > {{ branch }} </gl-dropdown-item> - <gl-dropdown-section-header v-if="hasFilteredTags"> + <gl-dropdown-section-header v-if="hasTags"> {{ s__('CompareRevisions|Tags') }} </gl-dropdown-section-header> <gl-dropdown-item - v-for="(tag, index) in filteredTags" - :key="`tag${index}`" + v-for="tag in tags" + :key="tag" is-check-item :is-checked="selectedRevision === tag" @click="onClick(tag)" diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue index 13d80b5ae0b..f57a8942a77 100644 --- a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue @@ -55,6 +55,11 @@ export default { return this.filteredTags.length; }, }, + watch: { + paramsBranch(newBranch) { + this.setSelectedRevision(newBranch); + }, + }, mounted() { this.fetchBranchesAndTags(); }, @@ -83,10 +88,14 @@ export default { return this.paramsBranch || s__('CompareRevisions|Select branch/tag'); }, onClick(revision) { - this.selectedRevision = revision; + this.setSelectedRevision(revision); }, onSearchEnter() { - this.selectedRevision = this.searchTerm; + this.setSelectedRevision(this.searchTerm); + }, + setSelectedRevision(revision) { + this.selectedRevision = revision || s__('CompareRevisions|Select branch/tag'); + this.$emit('selectRevision', { direction: this.paramsName, revision }); }, }, }; diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue index ef61fba88fe..1060b37067e 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue @@ -1,8 +1,9 @@ <script> /* eslint-disable vue/no-v-html */ import { GlBreadcrumb, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { experiment } from '~/experimentation/utils'; import { __, s__ } from '~/locale'; - +import { NEW_REPO_EXPERIMENT } from '../constants'; import blankProjectIllustration from '../illustrations/blank-project.svg'; import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg'; import createFromTemplateIllustration from '../illustrations/create-from-template.svg'; @@ -13,8 +14,10 @@ import WelcomePage from './welcome.vue'; const BLANK_PANEL = 'blank_project'; const CI_CD_PANEL = 'cicd_for_external_repo'; const LAST_ACTIVE_TAB_KEY = 'new_project_last_active_tab'; + const PANELS = [ { + key: 'blank', name: BLANK_PANEL, selector: '#blank-project-pane', title: s__('ProjectsNew|Create blank project'), @@ -24,6 +27,7 @@ const PANELS = [ illustration: blankProjectIllustration, }, { + key: 'template', name: 'create_from_template', selector: '#create-from-template-pane', title: s__('ProjectsNew|Create from template'), @@ -33,6 +37,7 @@ const PANELS = [ illustration: createFromTemplateIllustration, }, { + key: 'import', name: 'import_project', selector: '#import-project-pane', title: s__('ProjectsNew|Import project'), @@ -42,6 +47,7 @@ const PANELS = [ illustration: importProjectIllustration, }, { + key: 'ci', name: CI_CD_PANEL, selector: '#ci-cd-project-pane', title: s__('ProjectsNew|Run CI/CD for external repository'), @@ -85,16 +91,34 @@ export default { }, computed: { + decoratedPanels() { + const PANEL_TITLES = experiment(NEW_REPO_EXPERIMENT, { + use: () => ({ + blank: s__('ProjectsNew|Create blank project'), + import: s__('ProjectsNew|Import project'), + }), + try: () => ({ + blank: s__('ProjectsNew|Create blank project/repository'), + import: s__('ProjectsNew|Import project/repository'), + }), + }); + + return PANELS.map(({ key, title, ...el }) => ({ + ...el, + title: PANEL_TITLES[key] !== undefined ? PANEL_TITLES[key] : title, + })); + }, + availablePanels() { if (this.isCiCdAvailable) { - return PANELS; + return this.decoratedPanels; } - return PANELS.filter((p) => p.name !== CI_CD_PANEL); + return this.decoratedPanels.filter((p) => p.name !== CI_CD_PANEL); }, activePanel() { - return PANELS.find((p) => p.name === this.activeTab); + return this.decoratedPanels.find((p) => p.name === this.activeTab); }, breadcrumbs() { diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue index ed82a635b1f..d342ce4c9c2 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue @@ -1,9 +1,10 @@ <script> /* eslint-disable vue/no-v-html */ import Tracking from '~/tracking'; +import { NEW_REPO_EXPERIMENT } from '../constants'; import NewProjectPushTipPopover from './new_project_push_tip_popover.vue'; -const trackingMixin = Tracking.mixin(gon.tracking_data); +const trackingMixin = Tracking.mixin({ ...gon.tracking_data, experiment: NEW_REPO_EXPERIMENT }); export default { components: { diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/constants.js b/app/assets/javascripts/projects/experiment_new_project_creation/constants.js new file mode 100644 index 00000000000..402ca887cf1 --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/constants.js @@ -0,0 +1 @@ +export const NEW_REPO_EXPERIMENT = 'new_repo'; diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index 4a8e1424fa8..8d005373508 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -3,8 +3,6 @@ import { GlTabs, GlTab } from '@gitlab/ui'; import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility'; import PipelineCharts from './pipeline_charts.vue'; -const charts = ['pipelines', 'deployments']; - export default { components: { GlTabs, @@ -12,9 +10,11 @@ export default { PipelineCharts, DeploymentFrequencyCharts: () => import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'), + LeadTimeCharts: () => + import('ee_component/projects/pipelines/charts/components/lead_time_charts.vue'), }, inject: { - shouldRenderDeploymentFrequencyCharts: { + shouldRenderDoraCharts: { type: Boolean, default: false, }, @@ -24,20 +24,31 @@ export default { selectedTab: 0, }; }, + computed: { + charts() { + const chartsToShow = ['pipelines']; + + if (this.shouldRenderDoraCharts) { + chartsToShow.push('deployments', 'lead-time'); + } + + return chartsToShow; + }, + }, created() { this.selectTab(); window.addEventListener('popstate', this.selectTab); }, methods: { selectTab() { - const [chart] = getParameterValues('chart') || charts; - const tab = charts.indexOf(chart); + const [chart] = getParameterValues('chart') || this.charts; + const tab = this.charts.indexOf(chart); this.selectedTab = tab >= 0 ? tab : 0; }, onTabChange(index) { if (index !== this.selectedTab) { this.selectedTab = index; - const path = mergeUrlParams({ chart: charts[index] }, window.location.pathname); + const path = mergeUrlParams({ chart: this.charts[index] }, window.location.pathname); updateHistory({ url: path, title: window.title }); } }, @@ -46,13 +57,18 @@ export default { </script> <template> <div> - <gl-tabs v-if="shouldRenderDeploymentFrequencyCharts" :value="selectedTab" @input="onTabChange"> + <gl-tabs v-if="charts.length > 1" :value="selectedTab" @input="onTabChange"> <gl-tab :title="__('Pipelines')"> <pipeline-charts /> </gl-tab> - <gl-tab :title="__('Deployments')"> - <deployment-frequency-charts /> - </gl-tab> + <template v-if="shouldRenderDoraCharts"> + <gl-tab :title="__('Deployments')"> + <deployment-frequency-charts /> + </gl-tab> + <gl-tab :title="__('Lead Time')"> + <lead-time-charts /> + </gl-tab> + </template> </gl-tabs> <pipeline-charts v-else /> </div> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue index 3590e2c4632..ad3e6713e45 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue @@ -30,12 +30,16 @@ export default { <resizable-chart-container> <gl-area-chart slot-scope="{ width }" + v-bind="$attrs" :width="width" :height="$options.chartContainerHeight" :data="chartData" :include-legend-avg-max="false" :option="areaChartOptions" - /> + > + <slot slot="tooltip-title" name="tooltip-title"></slot> + <slot slot="tooltip-content" name="tooltip-content"></slot> + </gl-area-chart> </resizable-chart-container> </div> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue index 43b36da8b2c..f4fd57e4cdc 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue @@ -41,10 +41,14 @@ export default { <gl-segmented-control v-model="selectedChart" :options="chartRanges" class="gl-mb-4" /> <ci-cd-analytics-area-chart v-if="chart" + v-bind="$attrs" :chart-data="chart.data" :area-chart-options="chartOptions" > {{ dateRange }} + + <slot slot="tooltip-title" name="tooltip-title"></slot> + <slot slot="tooltip-content" name="tooltip-content"></slot> </ci-cd-analytics-area-chart> </div> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index 7e746423b6a..5f5ee44c204 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -13,9 +13,7 @@ const apolloProvider = new VueApollo({ const mountPipelineChartsApp = (el) => { const { projectPath } = el.dataset; - const shouldRenderDeploymentFrequencyCharts = parseBoolean( - el.dataset.shouldRenderDeploymentFrequencyCharts, - ); + const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts); return new Vue({ el, @@ -26,7 +24,7 @@ const mountPipelineChartsApp = (el) => { apolloProvider, provide: { projectPath, - shouldRenderDeploymentFrequencyCharts, + shouldRenderDoraCharts, }, render: (createElement) => createElement(ProjectPipelinesCharts, {}), }); diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 625d491db6a..589b88d7bbe 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -11,6 +11,8 @@ import { import { get } from 'lodash'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; import createFlash from '~/flash'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; import Tracking from '~/tracking'; import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; import DeleteImage from '../components/delete_image.vue'; @@ -81,6 +83,9 @@ export default { searchConfig: SORT_FIELDS, apollo: { baseImages: { + skip() { + return !this.fetchBaseQuery; + }, query: getContainerRepositoriesQuery, variables() { return this.queryVariables; @@ -124,15 +129,19 @@ export default { sorting: { orderBy: 'UPDATED', sort: 'desc' }, name: null, mutationLoading: false, + fetchBaseQuery: false, fetchAdditionalDetails: false, }; }, computed: { images() { - return this.baseImages.map((image, index) => ({ - ...image, - ...get(this.additionalDetails, index, {}), - })); + if (this.baseImages) { + return this.baseImages.map((image, index) => ({ + ...image, + ...get(this.additionalDetails, index, {}), + })); + } + return []; }, graphqlResource() { return this.config.isGroupPage ? 'group' : 'project'; @@ -171,8 +180,15 @@ export default { }, }, mounted() { + const { sorting, filters } = extractFilterAndSorting(this.$route.query); + + this.filter = [...filters]; + this.name = filters[0]?.value.data; + this.sorting = { ...this.sorting, ...sorting }; + // If the two graphql calls - which are not batched - resolve togheter we will have a race // condition when apollo sets the cache, with this we give the 'base' call an headstart + this.fetchBaseQuery = true; setTimeout(() => { this.fetchAdditionalDetails = true; }, 200); @@ -241,9 +257,12 @@ export default { }; }, doFilter() { - const search = this.filter.find((i) => i.type === 'filtered-search-term'); + const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM); this.name = search?.value?.data; }, + updateUrlQueryString(query) { + this.$router.push({ query }); + }, }, }; </script> @@ -303,6 +322,7 @@ export default { @sorting:changed="updateSorting" @filter:changed="filter = $event" @filter:submit="doFilter" + @query:changed="updateUrlQueryString" /> <div v-if="isLoading" class="gl-mt-5"> diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index eb731c382e1..1360e09a75d 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -110,12 +110,12 @@ export default { mutationVariables() { return { projectPath: this.projectPath, - enabled: this.value.enabled, - cadence: this.value.cadence, - olderThan: this.value.olderThan, - keepN: this.value.keepN, - nameRegex: this.value.nameRegex, - nameRegexKeep: this.value.nameRegexKeep, + enabled: this.prefilledForm.enabled, + cadence: this.prefilledForm.cadence, + olderThan: this.prefilledForm.olderThan, + keepN: this.prefilledForm.keepN, + nameRegex: this.prefilledForm.nameRegex, + nameRegexKeep: this.prefilledForm.nameRegexKeep, }; }, }, @@ -291,8 +291,8 @@ export default { type="submit" :disabled="isSubmitButtonDisabled" :loading="showLoadingIcon" - variant="success" category="primary" + variant="confirm" class="js-no-auto-disable gl-mr-4" > {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index a8c7b7c857a..aecd0d6371e 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -22,7 +22,7 @@ export default { TagField, }, computed: { - ...mapState('detail', [ + ...mapState('editNew', [ 'isFetchingRelease', 'isUpdatingRelease', 'fetchError', @@ -36,13 +36,13 @@ export default { 'groupId', 'groupMilestonesAvailable', ]), - ...mapGetters('detail', ['isValid', 'isExistingRelease']), + ...mapGetters('editNew', ['isValid', 'isExistingRelease']), showForm() { return Boolean(!this.isFetchingRelease && !this.fetchError && this.release); }, releaseTitle: { get() { - return this.$store.state.detail.release.name; + return this.$store.state.editNew.release.name; }, set(title) { this.updateReleaseTitle(title); @@ -50,7 +50,7 @@ export default { }, releaseNotes: { get() { - return this.$store.state.detail.release.description; + return this.$store.state.editNew.release.description; }, set(notes) { this.updateReleaseNotes(notes); @@ -58,7 +58,7 @@ export default { }, releaseMilestones: { get() { - return this.$store.state.detail.release.milestones; + return this.$store.state.editNew.release.milestones; }, set(milestones) { this.updateReleaseMilestones(milestones); @@ -93,7 +93,7 @@ export default { this.$el.querySelector('input:enabled, button:enabled').focus(); }, methods: { - ...mapActions('detail', [ + ...mapActions('editNew', [ 'initializeRelease', 'saveRelease', 'updateReleaseTitle', @@ -114,7 +114,7 @@ export default { <gl-sprintf :message=" __( - 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}.', + 'Releases are based on Git tags. We recommend tags that use semantic versioning, for example %{codeStart}v1.0.0%{codeEnd}, %{codeStart}v2.1.0-pre%{codeEnd}.', ) " > diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index 32183e454c8..262b5614d65 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -20,7 +20,7 @@ export default { ReleasesSort, }, computed: { - ...mapState('list', [ + ...mapState('index', [ 'documentationPath', 'illustrationPath', 'newReleasePath', @@ -46,7 +46,7 @@ export default { window.addEventListener('popstate', this.fetchReleases); }, methods: { - ...mapActions('list', { + ...mapActions('index', { fetchReleasesStoreAction: 'fetchReleases', }), fetchReleases() { diff --git a/app/assets/javascripts/releases/components/app_show.vue b/app/assets/javascripts/releases/components/app_show.vue index 9ef38503c10..c38e93d420b 100644 --- a/app/assets/javascripts/releases/components/app_show.vue +++ b/app/assets/javascripts/releases/components/app_show.vue @@ -1,5 +1,8 @@ <script> -import { mapState, mapActions } from 'vuex'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import oneReleaseQuery from '../queries/one_release.query.graphql'; +import { convertGraphQLRelease } from '../util'; import ReleaseBlock from './release_block.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; @@ -9,21 +12,58 @@ export default { ReleaseBlock, ReleaseSkeletonLoader, }, - computed: { - ...mapState('detail', ['isFetchingRelease', 'fetchError', 'release']), + inject: { + fullPath: { + default: '', + }, + tagName: { + default: '', + }, }, - created() { - this.fetchRelease(); + apollo: { + release: { + query: oneReleaseQuery, + variables() { + return { + fullPath: this.fullPath, + tagName: this.tagName, + }; + }, + update(data) { + if (data.project?.release) { + return convertGraphQLRelease(data.project.release); + } + + return null; + }, + result(result) { + // Handle the case where the query succeeded but didn't return any data + if (!result.error && !this.release) { + this.showFlash( + new Error(`No release found in project "${this.fullPath}" with tag "${this.tagName}"`), + ); + } + }, + error(error) { + this.showFlash(error); + }, + }, }, methods: { - ...mapActions('detail', ['fetchRelease']), + showFlash(error) { + createFlash({ + message: s__('Release|Something went wrong while getting the release details.'), + captureError: true, + error, + }); + }, }, }; </script> <template> <div class="gl-mt-3"> - <release-skeleton-loader v-if="isFetchingRelease" /> + <release-skeleton-loader v-if="$apollo.queries.release.loading" /> - <release-block v-else-if="!fetchError" :release="release" /> + <release-block v-else-if="release" :release="release" /> </div> </template> diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index cfcb9f6978d..b9601428850 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -26,14 +26,14 @@ export default { }, directives: { GlTooltip: GlTooltipDirective }, computed: { - ...mapState('detail', ['release', 'releaseAssetsDocsPath']), - ...mapGetters('detail', ['validationErrors']), + ...mapState('editNew', ['release', 'releaseAssetsDocsPath']), + ...mapGetters('editNew', ['validationErrors']), }, created() { this.ensureAtLeastOneLink(); }, methods: { - ...mapActions('detail', [ + ...mapActions('editNew', [ 'addEmptyAssetLink', 'updateAssetLinkUrl', 'updateAssetLinkName', diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue index 356fc0f3bf3..89bc314db89 100644 --- a/app/assets/javascripts/releases/components/release_block_header.vue +++ b/app/assets/javascripts/releases/components/release_block_header.vue @@ -1,9 +1,13 @@ <script> import { GlTooltipDirective, GlLink, GlBadge, GlButton, GlIcon } from '@gitlab/ui'; import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; import { BACK_URL_PARAM } from '~/releases/constants'; export default { + i18n: { + editButton: __('Edit this release'), + }, name: 'ReleaseBlockHeader', components: { GlLink, @@ -69,7 +73,8 @@ export default { variant="default" icon="pencil" class="gl-mr-3 js-edit-button ml-2 pb-2" - :title="__('Edit this release')" + :title="$options.i18n.editButton" + :aria-label="$options.i18n.editButton" :href="editLink" /> </div> diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue index cf4a6e07af7..de10b210ecd 100644 --- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue +++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue @@ -179,7 +179,7 @@ export default { /> <issuable-stats v-if="showMergeRequestStats" - :label="__('Merge Requests')" + :label="__('Merge requests')" :total="mergeRequestCounts.total" :merged="mergeRequestCounts.merged" :closed="mergeRequestCounts.closed" diff --git a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue index 7d024c47fb9..13cbf95b9af 100644 --- a/app/assets/javascripts/releases/components/releases_pagination_graphql.vue +++ b/app/assets/javascripts/releases/components/releases_pagination_graphql.vue @@ -7,13 +7,13 @@ export default { name: 'ReleasesPaginationGraphql', components: { GlKeysetPagination }, computed: { - ...mapState('list', ['graphQlPageInfo']), + ...mapState('index', ['graphQlPageInfo']), showPagination() { return this.graphQlPageInfo.hasPreviousPage || this.graphQlPageInfo.hasNextPage; }, }, methods: { - ...mapActions('list', ['fetchReleases']), + ...mapActions('index', ['fetchReleases']), onPrev(before) { historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); this.fetchReleases({ before }); diff --git a/app/assets/javascripts/releases/components/releases_pagination_rest.vue b/app/assets/javascripts/releases/components/releases_pagination_rest.vue index 24abb0f4498..5e97a5a0450 100644 --- a/app/assets/javascripts/releases/components/releases_pagination_rest.vue +++ b/app/assets/javascripts/releases/components/releases_pagination_rest.vue @@ -7,10 +7,10 @@ export default { name: 'ReleasesPaginationRest', components: { TablePagination }, computed: { - ...mapState('list', ['restPageInfo']), + ...mapState('index', ['restPageInfo']), }, methods: { - ...mapActions('list', ['fetchReleases']), + ...mapActions('index', ['fetchReleases']), onChangePage(page) { historyPushState(buildUrlWithCurrentLocation(`?page=${page}`)); this.fetchReleases({ page }); diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue index c8e6e0e4996..4988904a2cd 100644 --- a/app/assets/javascripts/releases/components/releases_sort.vue +++ b/app/assets/javascripts/releases/components/releases_sort.vue @@ -10,7 +10,7 @@ export default { GlSortingItem, }, computed: { - ...mapState('list', { + ...mapState('index', { orderBy: (state) => state.sorting.orderBy, sort: (state) => state.sorting.sort, }), @@ -26,7 +26,7 @@ export default { }, }, methods: { - ...mapActions('list', ['setSorting']), + ...mapActions('index', ['setSorting']), onDirectionChange() { const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER; this.setSorting({ sort }); diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue index ed8d6e62926..f4c0fd5e9ce 100644 --- a/app/assets/javascripts/releases/components/tag_field.vue +++ b/app/assets/javascripts/releases/components/tag_field.vue @@ -9,7 +9,7 @@ export default { TagFieldNew, }, computed: { - ...mapGetters('detail', ['isExistingRelease']), + ...mapGetters('editNew', ['isExistingRelease']), }, }; </script> diff --git a/app/assets/javascripts/releases/components/tag_field_existing.vue b/app/assets/javascripts/releases/components/tag_field_existing.vue index 3345bbecf6e..11945fbaf3d 100644 --- a/app/assets/javascripts/releases/components/tag_field_existing.vue +++ b/app/assets/javascripts/releases/components/tag_field_existing.vue @@ -8,7 +8,7 @@ export default { name: 'TagFieldExisting', components: { GlFormGroup, GlFormInput, FormFieldContainer }, computed: { - ...mapState('detail', ['release']), + ...mapState('editNew', ['release']), inputId() { return uniqueId('tag-name-input-'); }, diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index 21360a5c6cb..9df646ca798 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -27,8 +27,8 @@ export default { }; }, computed: { - ...mapState('detail', ['projectId', 'release', 'createFrom']), - ...mapGetters('detail', ['validationErrors']), + ...mapState('editNew', ['projectId', 'release', 'createFrom']), + ...mapGetters('editNew', ['validationErrors']), tagName: { get() { return this.release.tagName; @@ -62,7 +62,7 @@ export default { }, }, methods: { - ...mapActions('detail', ['updateReleaseTagName', 'updateCreateFrom']), + ...mapActions('editNew', ['updateReleaseTagName', 'updateCreateFrom']), markInputAsDirty() { this.isInputDirty = true; }, diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js index 1232d55847b..fad0451ceef 100644 --- a/app/assets/javascripts/releases/mount_edit.js +++ b/app/assets/javascripts/releases/mount_edit.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import ReleaseEditNewApp from './components/app_edit_new.vue'; import createStore from './stores'; -import createDetailModule from './stores/modules/detail'; +import createEditNewModule from './stores/modules/edit_new'; Vue.use(Vuex); @@ -11,7 +11,7 @@ export default () => { const store = createStore({ modules: { - detail: createDetailModule(el.dataset), + editNew: createEditNewModule(el.dataset), }, }); diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js index a9538cbc9e5..0b453467c13 100644 --- a/app/assets/javascripts/releases/mount_index.js +++ b/app/assets/javascripts/releases/mount_index.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import ReleaseListApp from './components/app_index.vue'; +import ReleaseIndexApp from './components/app_index.vue'; import createStore from './stores'; -import createListModule from './stores/modules/list'; +import createIndexModule from './stores/modules/index'; Vue.use(Vuex); @@ -13,7 +13,7 @@ export default () => { el, store: createStore({ modules: { - list: createListModule(el.dataset), + index: createIndexModule(el.dataset), }, featureFlags: { graphqlReleaseData: Boolean(gon.features?.graphqlReleaseData), @@ -21,6 +21,6 @@ export default () => { graphqlMilestoneStats: Boolean(gon.features?.graphqlMilestoneStats), }, }), - render: (h) => h(ReleaseListApp), + render: (h) => h(ReleaseIndexApp), }); }; diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js index d85f4cf77d5..b358a27f06d 100644 --- a/app/assets/javascripts/releases/mount_new.js +++ b/app/assets/javascripts/releases/mount_new.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import ReleaseEditNewApp from './components/app_edit_new.vue'; import createStore from './stores'; -import createDetailModule from './stores/modules/detail'; +import createEditNewModule from './stores/modules/edit_new'; Vue.use(Vuex); @@ -11,7 +11,7 @@ export default () => { const store = createStore({ modules: { - detail: createDetailModule(el.dataset), + editNew: createEditNewModule(el.dataset), }, }); diff --git a/app/assets/javascripts/releases/mount_show.js b/app/assets/javascripts/releases/mount_show.js index f3ed7d6c5ff..7272880197a 100644 --- a/app/assets/javascripts/releases/mount_show.js +++ b/app/assets/javascripts/releases/mount_show.js @@ -1,26 +1,28 @@ import Vue from 'vue'; -import Vuex from 'vuex'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import ReleaseShowApp from './components/app_show.vue'; -import createStore from './stores'; -import createDetailModule from './stores/modules/detail'; -Vue.use(Vuex); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export default () => { const el = document.getElementById('js-show-release-page'); - const store = createStore({ - modules: { - detail: createDetailModule(el.dataset), - }, - featureFlags: { - graphqlIndividualReleasePage: Boolean(gon.features?.graphqlIndividualReleasePage), - }, - }); + if (!el) return false; + + const { projectPath, tagName } = el.dataset; return new Vue({ el, - store, + apolloProvider, + provide: { + fullPath: projectPath, + tagName, + }, render: (h) => h(ReleaseShowApp), }); }; diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index 5fa002706c6..8dc2083dd2b 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -43,7 +43,7 @@ export const fetchRelease = ({ commit, state, rootState }) => { }) .catch((error) => { commit(types.RECEIVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while getting the release details')); + createFlash(s__('Release|Something went wrong while getting the release details.')); }); } @@ -54,7 +54,7 @@ export const fetchRelease = ({ commit, state, rootState }) => { }) .catch((error) => { commit(types.RECEIVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while getting the release details')); + createFlash(s__('Release|Something went wrong while getting the release details.')); }); }; diff --git a/app/assets/javascripts/releases/stores/modules/detail/getters.js b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js index 831037c8861..831037c8861 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/getters.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/getters.js diff --git a/app/assets/javascripts/releases/stores/modules/detail/index.js b/app/assets/javascripts/releases/stores/modules/edit_new/index.js index e1b7e69accc..e1b7e69accc 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/index.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/index.js diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js index 1b2f5f33f02..1b2f5f33f02 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutation_types.js diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js index cf282f9ab2c..cf282f9ab2c 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/mutations.js diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/edit_new/state.js index 315d07ac664..315d07ac664 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/state.js diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js index f1add54626a..f1add54626a 100644 --- a/app/assets/javascripts/releases/stores/modules/list/actions.js +++ b/app/assets/javascripts/releases/stores/modules/index/actions.js diff --git a/app/assets/javascripts/releases/stores/modules/list/index.js b/app/assets/javascripts/releases/stores/modules/index/index.js index d5ca191153a..d5ca191153a 100644 --- a/app/assets/javascripts/releases/stores/modules/list/index.js +++ b/app/assets/javascripts/releases/stores/modules/index/index.js diff --git a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js index 669168efb88..669168efb88 100644 --- a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js diff --git a/app/assets/javascripts/releases/stores/modules/list/mutations.js b/app/assets/javascripts/releases/stores/modules/index/mutations.js index e1aaa2e2a19..e1aaa2e2a19 100644 --- a/app/assets/javascripts/releases/stores/modules/list/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/index/mutations.js diff --git a/app/assets/javascripts/releases/stores/modules/list/state.js b/app/assets/javascripts/releases/stores/modules/index/state.js index 164a496d450..164a496d450 100644 --- a/app/assets/javascripts/releases/stores/modules/list/state.js +++ b/app/assets/javascripts/releases/stores/modules/index/state.js diff --git a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue index c272e3b1dc4..99cdeae545e 100644 --- a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue +++ b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue @@ -46,6 +46,7 @@ export default { :loading-text="groupedSummaryText" :error-text="groupedSummaryText" :has-issues="shouldRenderIssuesList" + track-action="users_expanding_testing_accessibility_report" class="mr-widget-section grouped-security-reports mr-report" > <template #body> diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue index 654508f0736..d293165ef2f 100644 --- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue +++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue @@ -62,7 +62,7 @@ export default { helpPath: this.codequalityHelpPath, }); - this.fetchReports(this.glFeatures.codequalityBackendComparison); + this.fetchReports(); }, methods: { ...mapActions(['fetchReports', 'setPaths']), @@ -87,6 +87,7 @@ export default { :component="$options.componentNames.CodequalityIssueBody" :popover-options="codequalityPopover" :show-report-section-status-icon="false" + track-action="users_expanding_testing_code_quality_report" class="js-codequality-widget mr-widget-border-top mr-report" > <template v-if="hasError" #sub-heading>{{ statusReason }}</template> diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/reports/components/grouped_issues_list.vue index 585127f901e..ca369022938 100644 --- a/app/assets/javascripts/reports/components/grouped_issues_list.vue +++ b/app/assets/javascripts/reports/components/grouped_issues_list.vue @@ -66,8 +66,8 @@ export default { }, listClasses() { return { - 'gl-pl-7': this.nestedLevel === 1, - 'gl-pl-9': this.nestedLevel === 2, + 'gl-pl-9': this.nestedLevel === 1, + 'gl-pl-11-5': this.nestedLevel === 2, }; }, }, diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue index ea3f0d78d8c..9df0a1953b6 100644 --- a/app/assets/javascripts/reports/components/issues_list.vue +++ b/app/assets/javascripts/reports/components/issues_list.vue @@ -88,8 +88,8 @@ export default { }, listClasses() { return { - 'gl-pl-7': this.nestedLevel === 1, - 'gl-pl-8': this.nestedLevel === 2, + 'gl-pl-9': this.nestedLevel === 1, + 'gl-pl-11-5': this.nestedLevel === 2, }; }, }, diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index ff58cd20ca1..12b5cb9f207 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -1,17 +1,22 @@ <script> +import { GlButton } from '@gitlab/ui'; +import api from '~/api'; import { __ } from '~/locale'; import StatusIcon from '~/vue_merge_request_widget/components/mr_widget_status_icon.vue'; import Popover from '~/vue_shared/components/help_popover.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants'; import IssuesList from './issues_list.vue'; export default { name: 'ReportSection', components: { + GlButton, IssuesList, - StatusIcon, Popover, + StatusIcon, }, + mixins: [glFeatureFlagsMixin()], props: { alwaysOpen: { type: Boolean, @@ -96,6 +101,11 @@ export default { required: false, default: false, }, + trackAction: { + type: String, + required: false, + default: null, + }, }, data() { @@ -162,6 +172,10 @@ export default { }, methods: { toggleCollapsed() { + if (this.trackAction && this.glFeatures.usersExpandingWidgetsUsageData) { + api.trackRedisHllUserEvent(this.trackAction); + } + if (this.shouldEmitToggleEvent) { this.$emit('toggleEvent'); } @@ -186,16 +200,15 @@ export default { <slot name="action-buttons" :is-collapsible="isCollapsible"></slot> - <button + <gl-button v-if="isCollapsible" - type="button" + class="js-collapse-btn" data-testid="report-section-expand-button" - class="js-collapse-btn btn float-right btn-sm align-self-center" data-qa-selector="expand_report_button" @click="toggleCollapsed" > {{ collapseText }} - </button> + </gl-button> </div> </div> diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index 8eb43bcf1ba..6b7d81c4878 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -51,7 +51,7 @@ export default { if (!this.nestedSummary) { return ['gl-px-5']; } - return ['gl-pl-7', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }]; + return ['gl-pl-9', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }]; }, statusIconSize() { if (!this.nestedSummary) { diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index 9250bfd7678..acd90ebf1b1 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -1,5 +1,5 @@ export const fieldTypes = { - codeBock: 'codeBlock', + codeBlock: 'codeBlock', link: 'link', seconds: 'seconds', text: 'text', diff --git a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue index b0310fd003e..af93e5bc639 100644 --- a/app/assets/javascripts/reports/grouped_test_report/components/modal.vue +++ b/app/assets/javascripts/reports/grouped_test_report/components/modal.vue @@ -25,6 +25,14 @@ export default { required: true, }, }, + computed: { + filteredModalData() { + // Filter out the properties that don't have a value + return Object.fromEntries( + Object.entries(this.modalData).filter((data) => Boolean(data[1].value)), + ); + }, + }, fieldTypes, }; </script> @@ -36,23 +44,18 @@ export default { :hide-footer="true" @hide="$emit('hide')" > - <div - v-for="(field, key, index) in modalData" - v-if="field.value" - :key="index" - class="row gl-mt-3 gl-mb-3" - > + <div v-for="(field, key, index) in filteredModalData" :key="index" class="row gl-mt-3 gl-mb-3"> <strong class="col-sm-3 text-right"> {{ field.text }}: </strong> <div class="col-sm-9 text-secondary"> - <code-block v-if="field.type === $options.fieldTypes.codeBock" :code="field.value" /> + <code-block v-if="field.type === $options.fieldTypes.codeBlock" :code="field.value" /> <gl-link v-else-if="field.type === $options.fieldTypes.link" - :href="field.value" + :href="field.value.path" target="_blank" > - {{ field.value }} + {{ field.value.text }} </gl-link> <gl-sprintf diff --git a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue index 522245a442d..8913046d62f 100644 --- a/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue +++ b/app/assets/javascripts/reports/grouped_test_report/components/test_issue_body.vue @@ -24,7 +24,7 @@ export default { n__( 'Reports|Failed %{count} time in %{base_branch} in the last 14 days', 'Reports|Failed %{count} times in %{base_branch} in the last 14 days', - this.issue.recent_failures.count, + this.issue.recent_failures?.count, ), this.issue.recent_failures, ); @@ -44,20 +44,20 @@ export default { <template> <div class="gl-display-flex gl-mt-2 gl-mb-2"> <issue-status-icon :status="status" :status-icon-size="24" class="gl-mr-3" /> - <gl-badge - v-if="showRecentFailures" - variant="warning" - class="gl-mr-2" - data-testid="test-issue-body-recent-failures" - > - {{ recentFailureMessage }} - </gl-badge> <gl-button button-text-classes="gl-white-space-normal! gl-word-break-all gl-text-left" variant="link" data-testid="test-issue-body-description" @click="openModal({ issue })" > + <gl-badge + v-if="showRecentFailures" + variant="warning" + class="gl-mr-2" + data-testid="test-issue-body-recent-failures" + > + {{ recentFailureMessage }} + </gl-badge> {{ issue.name }} </gl-button> </div> diff --git a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue index b863e55ae94..82806793401 100644 --- a/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/grouped_test_report/grouped_test_reports_app.vue @@ -1,9 +1,9 @@ <script> import { GlButton, GlIcon } from '@gitlab/ui'; -import { once } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; +import api from '~/api'; import { sprintf, s__ } from '~/locale'; -import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import GroupedIssuesList from '../components/grouped_issues_list.vue'; import { componentNames } from '../components/issue_body'; import ReportSection from '../components/report_section.vue'; @@ -28,7 +28,7 @@ export default { GlButton, GlIcon, }, - mixins: [Tracking.mixin()], + mixins: [glFeatureFlagsMixin()], props: { endpoint: { type: String, @@ -39,6 +39,10 @@ export default { required: false, default: '', }, + headBlobPath: { + type: String, + required: true, + }, }, componentNames, computed: { @@ -66,19 +70,22 @@ export default { showViewFullReport() { return this.pipelinePath.length; }, - handleToggleEvent() { - return once(() => { - this.track(this.$options.expandEvent); - }); - }, }, created() { - this.setEndpoint(this.endpoint); + this.setPaths({ + endpoint: this.endpoint, + headBlobPath: this.headBlobPath, + }); this.fetchReports(); }, methods: { - ...mapActions(['setEndpoint', 'fetchReports', 'closeModal']), + ...mapActions(['setPaths', 'fetchReports', 'closeModal']), + handleToggleEvent() { + if (this.glFeatures.usageDataITestingSummaryWidgetTotal) { + api.trackRedisHllUserEvent(this.$options.expandEvent); + } + }, reportText(report) { const { name, summary } = report || {}; @@ -123,7 +130,7 @@ export default { return report.resolved_failures.concat(report.resolved_errors); }, }, - expandEvent: 'expand_test_report_widget', + expandEvent: 'i_testing_summary_widget_total', }; </script> <template> @@ -135,7 +142,7 @@ export default { :has-issues="reports.length > 0" :should-emit-toggle-event="true" class="mr-widget-section grouped-security-reports mr-report" - @toggleEvent="handleToggleEvent" + @toggleEvent.once="handleToggleEvent" > <template v-if="showViewFullReport" #action-buttons> <gl-button diff --git a/app/assets/javascripts/reports/grouped_test_report/store/actions.js b/app/assets/javascripts/reports/grouped_test_report/store/actions.js index ebc8c735b03..e3db57ad846 100644 --- a/app/assets/javascripts/reports/grouped_test_report/store/actions.js +++ b/app/assets/javascripts/reports/grouped_test_report/store/actions.js @@ -4,7 +4,7 @@ import httpStatusCodes from '../../../lib/utils/http_status'; import Poll from '../../../lib/utils/poll'; import * as types from './mutation_types'; -export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint); +export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); export const requestReports = ({ commit }) => commit(types.REQUEST_REPORTS); diff --git a/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js index 337085f9bf0..ff839c564b6 100644 --- a/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js +++ b/app/assets/javascripts/reports/grouped_test_report/store/mutation_types.js @@ -1,4 +1,4 @@ -export const SET_ENDPOINT = 'SET_ENDPOINT'; +export const SET_PATHS = 'SET_PATHS'; export const REQUEST_REPORTS = 'REQUEST_REPORTS'; export const RECEIVE_REPORTS_SUCCESS = 'RECEIVE_REPORTS_SUCCESS'; diff --git a/app/assets/javascripts/reports/grouped_test_report/store/mutations.js b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js index 3bb31d71d8f..2b88776815b 100644 --- a/app/assets/javascripts/reports/grouped_test_report/store/mutations.js +++ b/app/assets/javascripts/reports/grouped_test_report/store/mutations.js @@ -1,9 +1,10 @@ import * as types from './mutation_types'; -import { countRecentlyFailedTests } from './utils'; +import { countRecentlyFailedTests, formatFilePath } from './utils'; export default { - [types.SET_ENDPOINT](state, endpoint) { + [types.SET_PATHS](state, { endpoint, headBlobPath }) { state.endpoint = endpoint; + state.headBlobPath = headBlobPath; }, [types.REQUEST_REPORTS](state) { state.isLoading = true; @@ -42,17 +43,25 @@ export default { state.status = null; }, [types.SET_ISSUE_MODAL_DATA](state, payload) { - state.modal.title = payload.issue.name; + const { issue } = payload; + state.modal.title = issue.name; - Object.keys(payload.issue).forEach((key) => { + Object.keys(issue).forEach((key) => { if (Object.prototype.hasOwnProperty.call(state.modal.data, key)) { state.modal.data[key] = { ...state.modal.data[key], - value: payload.issue[key], + value: issue[key], }; } }); + if (issue.file) { + state.modal.data.filename.value = { + text: issue.file, + path: `${state.headBlobPath}/${formatFilePath(issue.file)}`, + }; + } + state.modal.open = true; }, [types.RESET_ISSUE_MODAL_DATA](state) { diff --git a/app/assets/javascripts/reports/grouped_test_report/store/state.js b/app/assets/javascripts/reports/grouped_test_report/store/state.js index dd55c7abab4..46909bde337 100644 --- a/app/assets/javascripts/reports/grouped_test_report/store/state.js +++ b/app/assets/javascripts/reports/grouped_test_report/store/state.js @@ -41,16 +41,16 @@ export default () => ({ open: false, data: { - class: { - value: null, - text: s__('Reports|Class'), - type: fieldTypes.link, - }, classname: { value: null, text: s__('Reports|Classname'), type: fieldTypes.text, }, + filename: { + value: null, + text: s__('Reports|Filename'), + type: fieldTypes.link, + }, execution_time: { value: null, text: s__('Reports|Execution time'), @@ -59,12 +59,12 @@ export default () => ({ failure: { value: null, text: s__('Reports|Failure'), - type: fieldTypes.codeBock, + type: fieldTypes.codeBlock, }, system_output: { value: null, text: s__('Reports|System output'), - type: fieldTypes.codeBock, + type: fieldTypes.codeBlock, }, }, }, diff --git a/app/assets/javascripts/reports/grouped_test_report/store/utils.js b/app/assets/javascripts/reports/grouped_test_report/store/utils.js index 189b87bfa8d..df5dd73b66c 100644 --- a/app/assets/javascripts/reports/grouped_test_report/store/utils.js +++ b/app/assets/javascripts/reports/grouped_test_report/store/utils.js @@ -100,3 +100,12 @@ export const statusIcon = (status) => { return ICON_NOTFOUND; }; + +/** + * Removes `./` from the beginning of a file path so it can be appended onto a blob path + * @param {String} file + * @returns {String} - formatted value + */ +export const formatFilePath = (file) => { + return file.replace(/^\.?\/*/, ''); +}; diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue new file mode 100644 index 00000000000..58b42fb7859 --- /dev/null +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -0,0 +1,100 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import BlobContent from '~/blob/components/blob_content.vue'; +import BlobHeader from '~/blob/components/blob_header.vue'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import blobInfoQuery from '../queries/blob_info.query.graphql'; +import projectPathQuery from '../queries/project_path.query.graphql'; + +export default { + components: { + BlobHeader, + BlobContent, + GlLoadingIcon, + }, + apollo: { + projectPath: { + query: projectPathQuery, + }, + blobInfo: { + query: blobInfoQuery, + variables() { + return { + projectPath: this.projectPath, + filePath: this.path, + }; + }, + error() { + createFlash({ message: __('An error occurred while loading the file. Please try again.') }); + }, + }, + }, + provide() { + return { + blobHash: uniqueId(), + }; + }, + props: { + path: { + type: String, + required: true, + }, + }, + data() { + return { + projectPath: '', + blobInfo: { + name: '', + size: '', + rawBlob: '', + type: '', + fileType: '', + tooLarge: false, + path: '', + editBlobPath: '', + ideEditPath: '', + storedExternally: false, + rawPath: '', + externalStorageUrl: '', + replacePath: '', + deletePath: '', + canLock: false, + isLocked: false, + lockLink: '', + canModifyBlob: true, + forkPath: '', + simpleViewer: '', + richViewer: '', + }, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.blobInfo.loading; + }, + viewer() { + const { fileType, tooLarge, type } = this.blobInfo; + + return { fileType, tooLarge, type }; + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="isLoading" /> + <div v-if="blobInfo && !isLoading"> + <blob-header :blob="blobInfo" /> + <blob-content + :blob="blobInfo" + :content="blobInfo.rawBlob" + :is-raw-content="true" + :active-viewer="viewer" + :loading="false" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 28d7dec85f4..0b8408643ac 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -5,6 +5,7 @@ import { GlDropdownSectionHeader, GlDropdownItem, GlIcon, + GlModalDirective, } from '@gitlab/ui'; import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; @@ -12,12 +13,15 @@ import { __ } from '../../locale'; import getRefMixin from '../mixins/get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; import projectShortPathQuery from '../queries/project_short_path.query.graphql'; +import UploadBlobModal from './upload_blob_modal.vue'; const ROW_TYPES = { header: 'header', divider: 'divider', }; +const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob'; + export default { components: { GlDropdown, @@ -25,6 +29,7 @@ export default { GlDropdownSectionHeader, GlDropdownItem, GlIcon, + UploadBlobModal, }, apollo: { projectShortPath: { @@ -46,6 +51,9 @@ export default { }, }, }, + directives: { + GlModal: GlModalDirective, + }, mixins: [getRefMixin], props: { currentPath: { @@ -63,6 +71,21 @@ export default { required: false, default: false, }, + canPushCode: { + type: Boolean, + required: false, + default: false, + }, + selectedBranch: { + type: String, + required: false, + default: '', + }, + originalBranch: { + type: String, + required: false, + default: '', + }, newBranchPath: { type: String, required: false, @@ -93,7 +116,13 @@ export default { required: false, default: null, }, + uploadPath: { + type: String, + required: false, + default: '', + }, }, + uploadBlobModalId: UPLOAD_BLOB_MODAL_ID, data() { return { projectShortPath: '', @@ -126,7 +155,10 @@ export default { ); }, canCreateMrFromFork() { - return this.userPermissions.forkProject && this.userPermissions.createMergeRequestIn; + return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn; + }, + showUploadModal() { + return this.canEditTree && !this.$apollo.queries.userPermissions.loading; }, dropdownItems() { const items = []; @@ -149,10 +181,9 @@ export default { { attrs: { href: '#modal-upload-blob', - 'data-target': '#modal-upload-blob', - 'data-toggle': 'modal', }, text: __('Upload file'), + modalId: UPLOAD_BLOB_MODAL_ID, }, { attrs: { @@ -253,12 +284,26 @@ export default { <gl-icon name="chevron-down" :size="16" class="float-left" /> </template> <template v-for="(item, i) in dropdownItems"> - <component :is="getComponent(item.type)" :key="i" v-bind="item.attrs"> + <component + :is="getComponent(item.type)" + :key="i" + v-bind="item.attrs" + v-gl-modal="item.modalId || null" + > {{ item.text }} </component> </template> </gl-dropdown> </li> </ol> + <upload-blob-modal + v-if="showUploadModal" + :modal-id="$options.uploadBlobModalId" + :commit-message="__('Upload New File')" + :target-branch="selectedBranch" + :original-branch="originalBranch" + :can-push-code="canPushCode" + :path="uploadPath" + /> </nav> </template> diff --git a/app/assets/javascripts/repository/components/directory_download_links.vue b/app/assets/javascripts/repository/components/directory_download_links.vue index 8c029fc9973..c222a83300d 100644 --- a/app/assets/javascripts/repository/components/directory_download_links.vue +++ b/app/assets/javascripts/repository/components/directory_download_links.vue @@ -1,9 +1,9 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; export default { components: { - GlLink, + GlButton, }, props: { currentPath: { @@ -32,15 +32,15 @@ export default { <h5 class="m-0 dropdown-bold-header">{{ __('Download this directory') }}</h5> <div class="dropdown-menu-content"> <div class="btn-group ml-0 w-100"> - <gl-link + <gl-button v-for="(link, index) in normalizedLinks" :key="index" :href="link.path" - :class="{ 'btn-primary': index === 0 }" - class="btn btn-xs" + :variant="index === 0 ? 'confirm' : 'default'" + size="small" > {{ link.text }} - </gl-link> + </gl-button> </div> </div> </section> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 70918dd55e4..8ea5fce92fa 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -12,6 +12,7 @@ import { escapeRegExp } from 'lodash'; import { escapeFileUrl } from '~/lib/utils/url_utility'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getRefMixin from '../../mixins/get_ref'; import commitQuery from '../../queries/commit.query.graphql'; @@ -41,7 +42,7 @@ export default { }, }, }, - mixins: [getRefMixin], + mixins: [getRefMixin, glFeatureFlagMixin()], props: { id: { type: String, @@ -103,10 +104,21 @@ export default { }; }, computed: { + refactorBlobViewerEnabled() { + return this.glFeatures.refactorBlobViewer; + }, routerLinkTo() { - return this.isFolder - ? { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` } - : null; + const blobRouteConfig = { path: `/-/blob/${this.escapedRef}/${escapeFileUrl(this.path)}` }; + const treeRouteConfig = { path: `/-/tree/${this.escapedRef}/${escapeFileUrl(this.path)}` }; + + if (this.refactorBlobViewerEnabled && this.isBlob) { + return blobRouteConfig; + } + + return this.isFolder ? treeRouteConfig : null; + }, + isBlob() { + return this.type === 'blob'; }, isFolder() { return this.type === 'tree'; @@ -115,7 +127,7 @@ export default { return this.type === 'commit'; }, linkComponent() { - return this.isFolder ? 'router-link' : 'a'; + return this.isFolder || (this.refactorBlobViewerEnabled && this.isBlob) ? 'router-link' : 'a'; }, fullPath() { return this.path.replace(new RegExp(`^${escapeRegExp(this.currentPath)}/`), ''); diff --git a/app/assets/javascripts/repository/components/tree_action_link.vue b/app/assets/javascripts/repository/components/tree_action_link.vue deleted file mode 100644 index c5ab150adaf..00000000000 --- a/app/assets/javascripts/repository/components/tree_action_link.vue +++ /dev/null @@ -1,28 +0,0 @@ -<script> -import { GlLink } from '@gitlab/ui'; - -export default { - components: { - GlLink, - }, - props: { - path: { - type: String, - required: true, - }, - text: { - type: String, - required: true, - }, - cssClass: { - type: String, - required: false, - default: null, - }, - }, -}; -</script> - -<template> - <gl-link :href="path" :class="cssClass" class="btn gl-button">{{ text }}</gl-link> -</template> diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue index ec7ba469ca0..d2ff01e7fc1 100644 --- a/app/assets/javascripts/repository/components/upload_blob_modal.vue +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -168,6 +168,7 @@ export default { }); }, }, + validFileMimetypes: [], }; </script> <template> @@ -179,7 +180,12 @@ export default { :action-cancel="cancelOptions" @primary.prevent="uploadFile" > - <upload-dropzone class="gl-h-200! gl-mb-4" single-file-selection @change="setFile"> + <upload-dropzone + class="gl-h-200! gl-mb-4" + single-file-selection + :valid-file-mimetypes="$options.validFileMimetypes" + @change="setFile" + > <div v-if="file" class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index e6969b7c8b2..3a9a2adb417 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,14 +1,18 @@ import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { escapeFileUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; -import { parseBoolean } from '../lib/utils/common_utils'; -import { escapeFileUrl } from '../lib/utils/url_utility'; -import { __ } from '../locale'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; import LastCommit from './components/last_commit.vue'; import apolloProvider from './graphql'; +import commitsQuery from './queries/commits.query.graphql'; +import projectPathQuery from './queries/project_path.query.graphql'; +import projectShortPathQuery from './queries/project_short_path.query.graphql'; +import refsQuery from './queries/ref.query.graphql'; import createRouter from './router'; import { updateFormAction } from './utils/dom'; import { setTitle } from './utils/title'; @@ -19,13 +23,32 @@ export default function setupVueRepositoryList() { const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; const router = createRouter(projectPath, escapedRef); - apolloProvider.clients.defaultClient.cache.writeData({ + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: commitsQuery, + data: { + commits: [], + }, + }); + + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: projectPathQuery, data: { projectPath, + }, + }); + + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: projectShortPathQuery, + data: { projectShortPath, + }, + }); + + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: refsQuery, + data: { ref, escapedRef, - commits: [], }, }); @@ -55,6 +78,8 @@ export default function setupVueRepositoryList() { const { canCollaborate, canEditTree, + canPushCode, + selectedBranch, newBranchPath, newTagPath, newBlobPath, @@ -65,8 +90,7 @@ export default function setupVueRepositoryList() { newDirPath, } = breadcrumbEl.dataset; - router.afterEach(({ params: { path = '/' } }) => { - updateFormAction('.js-upload-blob-form', uploadPath, path); + router.afterEach(({ params: { path } }) => { updateFormAction('.js-create-dir-form', newDirPath, path); }); @@ -81,12 +105,16 @@ export default function setupVueRepositoryList() { currentPath: this.$route.params.path, canCollaborate: parseBoolean(canCollaborate), canEditTree: parseBoolean(canEditTree), + canPushCode: parseBoolean(canPushCode), + originalBranch: ref, + selectedBranch, newBranchPath, newTagPath, newBlobPath, forkNewBlobPath, forkNewDirectoryPath, forkUploadBlobPath, + uploadPath, }, }); }, diff --git a/app/assets/javascripts/repository/pages/blob.vue b/app/assets/javascripts/repository/pages/blob.vue new file mode 100644 index 00000000000..27af398be09 --- /dev/null +++ b/app/assets/javascripts/repository/pages/blob.vue @@ -0,0 +1,22 @@ +<script> +// This file is in progress and behind a feature flag, please see the following issue for more: +// https://gitlab.com/gitlab-org/gitlab/-/issues/323200 + +import BlobContentViewer from '../components/blob_content_viewer.vue'; + +export default { + components: { + BlobContentViewer, + }, + props: { + path: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <blob-content-viewer :path="path" /> +</template> diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql new file mode 100644 index 00000000000..e0bbf12f3eb --- /dev/null +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -0,0 +1,30 @@ +query getBlobInfo($projectPath: ID!, $filePath: String!) { + project(fullPath: $projectPath) { + id + repository { + blobs(path: $filePath) { + name + size + rawBlob + type + fileType + tooLarge + path + editBlobPath + ideEditPath + storedExternally + rawPath + externalStorageUrl + replacePath + deletePath + canLock + isLocked + lockLink + canModifyBlob + forkPath + simpleViewer + richViewer + } + } + } +} diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index ad6e32d7055..c7f7451fb55 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -2,6 +2,7 @@ import { escapeRegExp } from 'lodash'; import Vue from 'vue'; import VueRouter from 'vue-router'; import { joinPaths } from '../lib/utils/url_utility'; +import BlobPage from './pages/blob.vue'; import IndexPage from './pages/index.vue'; import TreePage from './pages/tree.vue'; @@ -15,6 +16,13 @@ export default function createRouter(base, baseRef) { }), }; + const blobPathRoute = { + component: BlobPage, + props: (route) => ({ + path: route.params.path, + }), + }; + return new VueRouter({ mode: 'history', base: joinPaths(gon.relative_url_root || '', base), @@ -32,6 +40,18 @@ export default function createRouter(base, baseRef) { ...treePathRoute, }, { + name: 'blobPathDecoded', + // Sometimes the ref needs decoding depending on how the backend sends it to us + path: `(/-)?/blob/${decodeURI(baseRef)}/:path*`, + ...blobPathRoute, + }, + { + name: 'blobPath', + // Support without decoding as well just in case the ref doesn't need to be decoded + path: `(/-)?/blob/${escapeRegExp(baseRef)}/:path*`, + ...blobPathRoute, + }, + { path: '/', name: 'projectRoot', component: IndexPage, diff --git a/app/assets/javascripts/runner/runner_details/constants.js b/app/assets/javascripts/runner/runner_details/constants.js new file mode 100644 index 00000000000..bb57e85fa8a --- /dev/null +++ b/app/assets/javascripts/runner/runner_details/constants.js @@ -0,0 +1,3 @@ +import { s__ } from '~/locale'; + +export const I18N_TITLE = s__('Runners|Runner #%{runner_id}'); diff --git a/app/assets/javascripts/runner/runner_details/index.js b/app/assets/javascripts/runner/runner_details/index.js new file mode 100644 index 00000000000..cbf70640ef7 --- /dev/null +++ b/app/assets/javascripts/runner/runner_details/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import RunnerDetailsApp from './runner_details_app.vue'; + +export const initRunnerDetail = (selector = '#js-runner-detail') => { + const el = document.querySelector(selector); + + if (!el) { + return null; + } + + const { runnerId } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(RunnerDetailsApp, { + props: { + runnerId, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/runner/runner_details/runner_details_app.vue b/app/assets/javascripts/runner/runner_details/runner_details_app.vue new file mode 100644 index 00000000000..1b1485bfe72 --- /dev/null +++ b/app/assets/javascripts/runner/runner_details/runner_details_app.vue @@ -0,0 +1,20 @@ +<script> +import { I18N_TITLE } from './constants'; + +export default { + i18n: { + I18N_TITLE, + }, + props: { + runnerId: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <h2 class="page-title"> + {{ sprintf($options.i18n.I18N_TITLE, { runner_id: runnerId }) }} + </h2> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 4640259314b..99cf16c8350 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -32,7 +32,9 @@ export default { <status-filter /> <confidentiality-filter /> <div class="gl-display-flex gl-align-items-center gl-mt-3"> - <gl-button variant="success" type="submit">{{ __('Apply') }}</gl-button> + <gl-button category="primary" variant="confirm" size="small" type="submit"> + {{ __('Apply') }} + </gl-button> <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{ __('Reset filters') }}</gl-link> diff --git a/app/assets/javascripts/search/sort/components/app.vue b/app/assets/javascripts/search/sort/components/app.vue index e4eba655e39..2bf144705c4 100644 --- a/app/assets/javascripts/search/sort/components/app.vue +++ b/app/assets/javascripts/search/sort/components/app.vue @@ -96,6 +96,7 @@ export default { v-gl-tooltip :disabled="!selectedSortOption.sortable" :title="sortDirectionData.tooltip" + :aria-label="sortDirectionData.tooltip" :icon="sortDirectionData.icon" @click="handleSortDirectionChange" /> diff --git a/app/assets/javascripts/search/topbar/components/app.vue b/app/assets/javascripts/search/topbar/components/app.vue index 987735ed811..2439ab55923 100644 --- a/app/assets/javascripts/search/topbar/components/app.vue +++ b/app/assets/javascripts/search/topbar/components/app.vue @@ -65,9 +65,9 @@ export default { <label class="gl-display-block">{{ __('Project') }}</label> <project-filter :initial-data="projectInitialData" /> </div> - <gl-button class="btn-search gl-lg-ml-2" variant="success" type="submit">{{ - __('Search') - }}</gl-button> + <gl-button class="btn-search gl-lg-ml-2" category="primary" variant="confirm" type="submit" + >{{ __('Search') }} + </gl-button> </section> </gl-form> </template> diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue index 5fb7217db74..d16850cd889 100644 --- a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue +++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue @@ -9,10 +9,13 @@ import { GlSkeletonLoader, GlTooltipDirective, } from '@gitlab/ui'; - +import { __ } from '~/locale'; import { ANY_OPTION } from '../constants'; export default { + i18n: { + clearLabel: __('Clear'), + }, name: 'SearchableDropdown', components: { GlDropdown, @@ -96,7 +99,8 @@ export default { v-gl-tooltip name="clear" category="tertiary" - :title="__('Clear')" + :title="$options.i18n.clearLabel" + :aria-label="$options.i18n.clearLabel" class="gl-p-0! gl-mr-2" @keydown.enter.stop="resetDropdown" @click.stop="resetDropdown" diff --git a/app/assets/javascripts/search_settings/constants.js b/app/assets/javascripts/search_settings/constants.js index 499e42854ed..9452d149122 100644 --- a/app/assets/javascripts/search_settings/constants.js +++ b/app/assets/javascripts/search_settings/constants.js @@ -5,7 +5,7 @@ export const EXCLUDED_NODES = ['OPTION']; export const HIDE_CLASS = 'gl-display-none'; // used to highlight the text that matches the * search term -export const HIGHLIGHT_CLASS = 'gl-bg-orange-50'; +export const HIGHLIGHT_CLASS = 'gl-bg-orange-100'; // How many seconds to wait until the user * stops typing export const TYPING_DELAY = 400; diff --git a/app/assets/javascripts/security_configuration/components/manage_sast.vue b/app/assets/javascripts/security_configuration/components/manage_sast.vue index a2528edd914..8a8827b41cd 100644 --- a/app/assets/javascripts/security_configuration/components/manage_sast.vue +++ b/app/assets/javascripts/security_configuration/components/manage_sast.vue @@ -54,6 +54,6 @@ export default { <template> <gl-button :loading="isLoading" variant="success" category="secondary" @click="mutate">{{ - s__('SecurityConfiguration|Configure via Merge Request') + s__('SecurityConfiguration|Configure via merge request') }}</gl-button> </template> 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 bed264341a5..bff90254c04 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,14 +1,23 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlToast, GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui'; +import { + GlToast, + GlModal, + GlTooltipDirective, + GlIcon, + GlFormCheckbox, + GlDropdown, + GlDropdownItem, +} from '@gitlab/ui'; import $ from 'jquery'; import Vue from 'vue'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import * as Emoji from '~/emoji'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; -import { __, s__ } from '~/locale'; +import { __, s__, sprintf } from '~/locale'; import { updateUserStatus } from '~/rest_api'; +import { timeRanges } from '~/vue_shared/constants'; import EmojiMenuInModal from './emoji_menu_in_modal'; import { isUserBusy } from './utils'; @@ -20,11 +29,21 @@ export const AVAILABILITY_STATUS = { Vue.use(GlToast); +const statusTimeRanges = [ + { + label: __('Never'), + name: 'never', + }, + ...timeRanges, +]; + export default { components: { GlIcon, GlModal, GlFormCheckbox, + GlDropdown, + GlDropdownItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -53,6 +72,11 @@ export default { required: false, default: false, }, + currentClearStatusAfter: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -65,6 +89,10 @@ export default { modalId: 'set-user-status-modal', noEmoji: true, availability: isUserBusy(this.currentAvailability), + clearStatusAfter: statusTimeRanges[0].label, + clearStatusAfterMessage: sprintf(s__('SetStatusModal|Your status resets on %{date}.'), { + date: this.currentClearStatusAfter, + }), }; }, computed: { @@ -161,12 +189,16 @@ export default { this.setStatus(); }, setStatus() { - const { emoji, message, availability } = this; + const { emoji, message, availability, clearStatusAfter } = this; updateUserStatus({ emoji, message, availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET, + clearStatusAfter: + clearStatusAfter === statusTimeRanges[0].label + ? null + : clearStatusAfter.replace(' ', '_'), }) .then(this.onUpdateSuccess) .catch(this.onUpdateFail); @@ -183,7 +215,11 @@ export default { this.closeModal(); }, + setClearStatusAfter(after) { + this.clearStatusAfter = after; + }, }, + statusTimeRanges, }; </script> @@ -268,10 +304,31 @@ export default { </div> <div class="gl-display-flex"> <span class="gl-text-gray-600 gl-ml-5"> - {{ s__('SetStatusModal|"Busy" will be shown next to your name') }} + {{ s__('SetStatusModal|A busy indicator is shown next to your name and avatar.') }} </span> </div> </div> + <div class="form-group"> + <div class="gl-display-flex gl-align-items-baseline"> + <span class="gl-mr-3">{{ s__('SetStatusModal|Clear status after') }}</span> + <gl-dropdown :text="clearStatusAfter" data-testid="clear-status-at-dropdown"> + <gl-dropdown-item + v-for="after in $options.statusTimeRanges" + :key="after.name" + :data-testid="after.name" + @click="setClearStatusAfter(after.label)" + >{{ after.label }}</gl-dropdown-item + > + </gl-dropdown> + </div> + <div + v-if="currentClearStatusAfter.length" + class="gl-mt-3 gl-text-gray-400 gl-font-sm" + data-testid="clear-status-at-message" + > + {{ clearStatusAfterMessage }} + </div> + </div> </div> </div> </gl-modal> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index d0a65b48522..98fc0b0a783 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -103,10 +103,10 @@ export default { v-gl-tooltip="tooltipOption" :href="assigneeUrl" :title="tooltipTitle" - class="d-inline-block" + class="gl-display-inline-block" > <!-- use d-flex so that slot can be appropriately styled --> - <span class="d-flex"> + <span class="gl-display-flex"> <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> <slot></slot> </span> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue index ca86d6c6c3e..f98798582c1 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 actionCable from '~/actioncable_consumer'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql'; +import { assigneesQueries } from '~/sidebar/constants'; export default { subscription: null, @@ -9,7 +9,8 @@ export default { props: { mediator: { type: Object, - required: true, + required: false, + default: null, }, issuableIid: { type: String, @@ -19,10 +20,16 @@ export default { type: String, required: true, }, + issuableType: { + type: String, + required: true, + }, }, apollo: { - project: { - query, + workspace: { + query() { + return assigneesQueries[this.issuableType].query; + }, variables() { return { iid: this.issuableIid, @@ -30,7 +37,9 @@ export default { }; }, result(data) { - this.handleFetchResult(data); + if (this.mediator) { + this.handleFetchResult(data); + } }, }, }, @@ -43,7 +52,7 @@ export default { methods: { received(data) { if (data.event === 'updated') { - this.$apollo.queries.project.refetch(); + this.$apollo.queries.workspace.refetch(); } }, initActionCablePolling() { @@ -57,7 +66,7 @@ export default { ); }, handleFetchResult({ data }) { - const { nodes } = data.project.issue.assignees; + const { nodes } = data.workspace.issuable.assignees; const assignees = nodes.map((n) => ({ ...n, @@ -69,7 +78,7 @@ export default { }, }, render() { - return this.$slots.default; + return null; }, }; </script> diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index b53b7039018..e93aced12f3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -18,6 +18,11 @@ export default { required: false, default: 'issue', }, + signedIn: { + type: Boolean, + required: false, + default: false, + }, }, computed: { assigneesText() { @@ -34,20 +39,28 @@ export default { <div class="gl-display-flex gl-flex-direction-column issuable-assignees"> <div v-if="emptyUsers" - class="gl-display-flex gl-align-items-center gl-text-gray-500" + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed" data-testid="none" > - <span> {{ __('None') }} -</span> - <gl-button - data-testid="assign-yourself" - category="tertiary" - variant="link" - class="gl-ml-2" - @click="$emit('assign-self')" - > - <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> - </gl-button> + <span> {{ __('None') }}</span> + <template v-if="signedIn"> + <span class="gl-ml-2">-</span> + <gl-button + data-testid="assign-yourself" + category="tertiary" + variant="link" + class="gl-ml-2" + @click="$emit('assign-self')" + > + <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> + </gl-button> + </template> </div> - <uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" /> + <uncollapsed-assignee-list + v-else + :users="users" + :issuable-type="issuableType" + class="gl-mt-2 hide-collapsed" + /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 6595debf9a5..e15ea595190 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -123,6 +123,7 @@ export default { v-if="shouldEnableRealtime" :issuable-iid="issuableIid" :project-path="projectPath" + :issuable-type="issuableType" :mediator="mediator" /> <assignee-title 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 cc2201ad359..78cac989850 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -1,26 +1,28 @@ <script> -import { - GlDropdownItem, - GlDropdownDivider, - GlAvatarLabeled, - GlAvatarLink, - GlSearchBoxByType, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import Vue from 'vue'; import createFlash from '~/flash'; import searchUsers from '~/graphql_shared/queries/users_search.query.graphql'; import { IssuableType } from '~/issue_show/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, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SidebarInviteMembers from './sidebar_invite_members.vue'; +import SidebarParticipant from './sidebar_participant.vue'; export const assigneesWidget = Vue.observable({ updateAssignees: null, }); + +const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { + bubbles: true, +}); + export default { i18n: { unassigned: __('Unassigned'), @@ -28,17 +30,26 @@ export default { assignees: __('Assignees'), assignTo: __('Assign to'), }, - assigneesQueries, components: { SidebarEditableItem, IssuableAssignees, MultiSelectDropdown, GlDropdownItem, GlDropdownDivider, - GlAvatarLabeled, - GlAvatarLink, GlSearchBoxByType, GlLoadingIcon, + SidebarInviteMembers, + SidebarParticipant, + SidebarAssigneesRealtime, + }, + mixins: [glFeatureFlagsMixin()], + inject: { + directlyInviteMembers: { + default: false, + }, + indirectlyInviteMembers: { + default: false, + }, }, props: { iid: { @@ -76,12 +87,13 @@ export default { selected: [], isSettingAssignees: false, isSearching: false, + isDirty: false, }; }, apollo: { issuable: { query() { - return this.$options.assigneesQueries[this.issuableType].query; + return assigneesQueries[this.issuableType].query; }, variables() { return this.queryVariables; @@ -109,15 +121,20 @@ export default { }, update(data) { const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || []; - const mergedSearchResults = this.participants.reduce((acc, current) => { - if ( - !acc.some((user) => current.username === user.username) && - (current.name.includes(this.search) || current.username.includes(this.search)) - ) { + const filteredParticipants = this.participants.filter( + (user) => + user.name.toLowerCase().includes(this.search.toLowerCase()) || + user.username.toLowerCase().includes(this.search.toLowerCase()), + ); + const mergedSearchResults = searchResults.reduce((acc, current) => { + // Some users are duplicated in the query result: + // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 + if (!acc.some((user) => current.username === user.username)) { acc.push(current); } return acc; - }, searchResults); + }, filteredParticipants); + return mergedSearchResults; }, debounce: ASSIGNEES_DEBOUNCE_DELAY, @@ -134,6 +151,10 @@ export default { }, }, computed: { + shouldEnableRealtime() { + // Note: Realtime is only available on issues right now, future support for MR wil be built later. + return this.glFeatures.realTimeIssueSidebar && this.issuableType === IssuableType.Issue; + }, queryVariables() { return { iid: this.iid, @@ -155,6 +176,9 @@ export default { }, assigneeText() { const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected; + if (!items) { + return __('Assignee'); + } return n__('Assignee', '%d Assignees', items.length); }, selectedFiltered() { @@ -197,8 +221,15 @@ export default { noUsersFound() { return !this.isSearchEmpty && this.searchUsers.length === 0; }, + signedIn() { + return this.currentUser.username !== undefined; + }, showCurrentUser() { - return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching); + return ( + this.signedIn && + !this.isCurrentUserInParticipants && + (this.isSearchEmpty || this.isSearching) + ); }, }, watch: { @@ -221,7 +252,7 @@ export default { this.isSettingAssignees = true; return this.$apollo .mutate({ - mutation: this.$options.assigneesQueries[this.issuableType].mutation, + mutation: assigneesQueries[this.issuableType].mutation, variables: { ...this.queryVariables, assigneeUsernames, @@ -239,20 +270,22 @@ export default { }); }, selectAssignee(name) { - if (name === undefined) { - this.clearSelected(); - return; - } + this.isDirty = true; if (!this.multipleAssignees) { - this.selected = [name]; + this.selected = name ? [name] : []; this.collapseWidget(); - } else { - this.selected = this.selected.concat(name); + return; + } + if (name === undefined) { + this.clearSelected(); + return; } + this.selected = this.selected.concat(name); }, unselect(name) { this.selected = this.selected.filter((user) => user.username !== name); + this.isDirty = true; if (!this.multipleAssignees) { this.collapseWidget(); @@ -265,7 +298,9 @@ export default { this.selected = []; }, saveAssignees() { + this.isDirty = false; this.updateAssignees(this.selectedUserNames); + this.$el.dispatchEvent(hideDropdownEvent); }, isChecked(id) { return this.selectedUserNames.includes(id); @@ -291,6 +326,9 @@ export default { collapseWidget() { this.$refs.toggle.collapse(); }, + expandWidget() { + this.$refs.toggle.expand(); + }, showDivider(list) { return list.length > 0 && this.isSearchEmpty; }, @@ -299,121 +337,113 @@ export default { </script> <template> - <div - v-if="isAssigneesLoading" - class="gl-display-flex gl-align-items-center assignee" - data-testid="loading-assignees" - > - {{ __('Assignee') }} - <gl-loading-icon size="sm" class="gl-ml-2" /> - </div> - <sidebar-editable-item - v-else - ref="toggle" - :loading="isSettingAssignees" - :title="assigneeText" - @open="focusSearch" - @close="saveAssignees" - > - <template #collapsed> - <issuable-assignees - :users="assignees" - :issuable-type="issuableType" - class="gl-mt-2" - @assign-self="assignSelf" - /> - </template> + <div data-testid="assignees-widget"> + <sidebar-assignees-realtime + v-if="shouldEnableRealtime" + :project-path="fullPath" + :issuable-iid="iid" + :issuable-type="issuableType" + /> + <sidebar-editable-item + ref="toggle" + :loading="isSettingAssignees" + :initial-loading="isAssigneesLoading" + :title="assigneeText" + :is-dirty="isDirty" + @open="focusSearch" + @close="saveAssignees" + > + <template #collapsed> + <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot> + <issuable-assignees + :users="assignees" + :issuable-type="issuableType" + :signed-in="signedIn" + @assign-self="assignSelf" + @expand-widget="expandWidget" + /> + </template> - <template #default> - <multi-select-dropdown - class="gl-w-full dropdown-menu-user" - :text="$options.i18n.assignees" - :header-text="$options.i18n.assignTo" - @toggle="collapseWidget" - > - <template #search> - <gl-search-box-by-type ref="search" v-model.trim="search" /> - </template> - <template #items> - <gl-loading-icon - v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading" - data-testid="loading-participants" - size="lg" - /> - <template v-else> - <template v-if="isSearchEmpty || isSearching"> + <template #default> + <multi-select-dropdown + class="gl-w-full dropdown-menu-user" + :text="$options.i18n.assignees" + :header-text="$options.i18n.assignTo" + @toggle="collapseWidget" + > + <template #search> + <gl-search-box-by-type + ref="search" + v-model.trim="search" + class="js-dropdown-input-field" + /> + </template> + <template #items> + <gl-loading-icon + v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading" + data-testid="loading-participants" + size="lg" + /> + <template v-else> + <template v-if="isSearchEmpty || isSearching"> + <gl-dropdown-item + :is-checked="selectedIsEmpty" + :is-check-centered="true" + data-testid="unassign" + @click="selectAssignee()" + > + <span + :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" + class="gl-font-weight-bold" + >{{ $options.i18n.unassigned }}</span + ></gl-dropdown-item + > + </template> + <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> <gl-dropdown-item - :is-checked="selectedIsEmpty" + v-for="item in selectedFiltered" + :key="item.id" + :is-checked="isChecked(item.username)" :is-check-centered="true" - data-testid="unassign" - @click="selectAssignee()" + data-testid="selected-participant" + @click.stop="unselect(item.username)" > - <span - :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" - class="gl-font-weight-bold" - >{{ $options.i18n.unassigned }}</span - ></gl-dropdown-item + <sidebar-participant :user="item" /> + </gl-dropdown-item> + <template v-if="showCurrentUser"> + <gl-dropdown-divider /> + <gl-dropdown-item + data-testid="current-user" + @click.stop="selectAssignee(currentUser)" + > + <sidebar-participant :user="currentUser" class="gl-pl-6!" /> + </gl-dropdown-item> + </template> + <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> + <gl-dropdown-item + v-for="unselectedUser in unselectedFiltered" + :key="unselectedUser.id" + data-testid="unselected-participant" + @click="selectAssignee(unselectedUser)" > - </template> - <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> - <gl-dropdown-item - v-for="item in selectedFiltered" - :key="item.id" - :is-checked="isChecked(item.username)" - :is-check-centered="true" - data-testid="selected-participant" - @click.stop="unselect(item.username)" - > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="item.name" - :sub-label="item.username" - :src="item.avatarUrl || item.avatar || item.avatar_url" - class="gl-align-items-center" - /> - </gl-avatar-link> - </gl-dropdown-item> - <template v-if="showCurrentUser"> - <gl-dropdown-divider /> + <sidebar-participant :user="unselectedUser" class="gl-pl-6!" /> + </gl-dropdown-item> <gl-dropdown-item - data-testid="current-user" - @click.stop="selectAssignee(currentUser)" + v-if="noUsersFound && !isSearching" + data-testid="empty-results" + class="gl-pl-6!" > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="currentUser.name" - :sub-label="currentUser.username" - :src="currentUser.avatarUrl" - class="gl-align-items-center gl-pl-6!" - /> - </gl-avatar-link> + {{ __('No matching results') }} </gl-dropdown-item> </template> - <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> - <gl-dropdown-item - v-for="unselectedUser in unselectedFiltered" - :key="unselectedUser.id" - data-testid="unselected-participant" - @click="selectAssignee(unselectedUser)" - > - <gl-avatar-link class="gl-pl-6!"> - <gl-avatar-labeled - :size="32" - :label="unselectedUser.name" - :sub-label="unselectedUser.username" - :src="unselectedUser.avatarUrl || unselectedUser.avatar" - class="gl-align-items-center" - /> - </gl-avatar-link> - </gl-dropdown-item> - <gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results"> - {{ __('No matching results') }} + </template> + <template #footer> + <gl-dropdown-item> + <sidebar-invite-members v-if="directlyInviteMembers || indirectlyInviteMembers" /> </gl-dropdown-item> </template> - </template> - </multi-select-dropdown> - </template> - </sidebar-editable-item> + </multi-select-dropdown> + </template> + </sidebar-editable-item> + </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue new file mode 100644 index 00000000000..9952c6db582 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -0,0 +1,51 @@ +<script> +import InviteMemberModal from '~/invite_member/components/invite_member_modal.vue'; +import InviteMemberTrigger from '~/invite_member/components/invite_member_trigger.vue'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import { __ } from '~/locale'; + +export default { + displayText: __('Invite members'), + dataTrackLabel: 'edit_assignee', + components: { + InviteMemberTrigger, + InviteMemberModal, + InviteMembersTrigger, + }, + inject: { + projectMembersPath: { + default: '', + }, + directlyInviteMembers: { + default: false, + }, + }, + computed: { + trackEvent() { + return this.directlyInviteMembers ? 'click_invite_members' : 'click_invite_members_version_b'; + }, + }, +}; +</script> + +<template> + <div> + <invite-members-trigger + v-if="directlyInviteMembers" + trigger-element="anchor" + :display-text="$options.displayText" + :event="trackEvent" + :label="$options.dataTrackLabel" + classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + /> + <template v-else> + <invite-member-trigger + :display-text="$options.displayText" + :event="trackEvent" + :label="$options.dataTrackLabel" + class="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + /> + <invite-member-modal :members-path="projectMembersPath" /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue new file mode 100644 index 00000000000..e2a38a100b9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -0,0 +1,39 @@ +<script> +import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; + +export default { + components: { + GlAvatarLabeled, + GlAvatarLink, + }, + props: { + user: { + type: Object, + required: true, + }, + }, + computed: { + userLabel() { + if (!this.user.status) { + return this.user.name; + } + return sprintf(s__('UserAvailability|%{author} (Busy)'), { + author: this.user.name, + }); + }, + }, +}; +</script> + +<template> + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="userLabel" + :sub-label="user.username" + :src="user.avatarUrl || user.avatar || user.avatar_url" + class="gl-align-items-center" + /> + </gl-avatar-link> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index d0da4a9c75a..b7080bb05b8 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -1,4 +1,5 @@ <script> +import { IssuableType } from '~/issue_show/constants'; import { __, sprintf } from '~/locale'; import AssigneeAvatarLink from './assignee_avatar_link.vue'; import UserNameWithStatus from './user_name_with_status.vue'; @@ -58,7 +59,10 @@ export default { this.showLess = !this.showLess; }, userAvailability(u) { - return u?.availability || ''; + if (this.issuableType === IssuableType.MergeRequest) { + return u?.availability || ''; + } + return u?.status?.availability || ''; }, }, }; 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 a21ac73f131..1fb4bd26533 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -18,8 +18,15 @@ export default { GlSprintf, GlButton, }, - inject: ['fullPath', 'iid'], props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, confidential: { required: true, type: Boolean, @@ -121,7 +128,7 @@ export default { </gl-button> <gl-button category="secondary" - variant="warning" + variant="confirm" :disabled="loading" :loading="loading" data-testid="confidential-toggle" 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 ec5f07f9785..372368707af 100644 --- a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -27,8 +27,20 @@ export default { SidebarConfidentialityContent, SidebarConfidentialityForm, }, - inject: ['fullPath', 'iid'], + inject: { + isClassicSidebar: { + default: false, + }, + }, props: { + iid: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, issuableType: { required: true, type: String, @@ -126,6 +138,7 @@ export default { v-if="!isLoading" :confidential="confidential" :issuable-type="issuableType" + :class="{ 'gl-mt-3': !isClassicSidebar }" @expandSidebar="expandSidebar" /> </div> @@ -133,6 +146,8 @@ export default { <template #default> <sidebar-confidentiality-content :confidential="confidential" :issuable-type="issuableType" /> <sidebar-confidentiality-form + :iid="iid" + :full-path="fullPath" :confidential="confidential" :issuable-type="issuableType" @closeForm="closeForm" diff --git a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue index 8c8241cf6a4..0d8cb8cb2b6 100644 --- a/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue +++ b/app/assets/javascripts/sidebar/components/copy_email_to_clipboard.vue @@ -1,43 +1,24 @@ <script> -import { s__, __, sprintf } from '~/locale'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '../../vue_shared/components/sidebar/copyable_field.vue'; export default { - i18n: { - copyEmail: __('Copy email address'), - }, components: { - ClipboardButton, + CopyableField, }, props: { - copyText: { + issueEmailAddress: { type: String, required: true, }, }, - computed: { - emailText() { - return sprintf(s__('RightSidebar|Issue email: %{copyText}'), { copyText: this.copyText }); - }, - }, }; </script> <template> - <div + <copyable-field data-qa-selector="copy-forward-email" - class="copy-email-address gl-display-flex gl-align-items-center gl-justify-content-space-between" - > - <span - class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap hide-collapsed gl-w-85p" - >{{ emailText }}</span - > - <clipboard-button - class="copy-email-button gl-bg-none!" - category="tertiary" - :title="$options.i18n.copyEmail" - :text="copyText" - tooltip-placement="left" - /> - </div> + :name="s__('RightSidebar|Issue email')" + :clipboard-tooltip-text="s__('RightSidebar|Copy email address')" + :value="issueEmailAddress" + /> </template> diff --git a/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue new file mode 100644 index 00000000000..141c2b3aae9 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/due_date/sidebar_due_date_widget.vue @@ -0,0 +1,203 @@ +<script> +import { GlButton, GlIcon, GlDatepicker, GlTooltipDirective } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { dueDateQueries } from '~/sidebar/constants'; + +const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { + bubbles: true, +}); + +export default { + tracking: { + event: 'click_edit_button', + label: 'right_sidebar', + property: 'dueDate', + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + GlIcon, + GlDatepicker, + SidebarEditableItem, + }, + inject: ['fullPath', 'iid', 'canUpdate'], + props: { + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + dueDate: null, + loading: false, + }; + }, + apollo: { + dueDate: { + query() { + return dueDateQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable?.dueDate || null; + }, + result({ data }) { + this.$emit('dueDateUpdated', data.workspace?.issuable?.dueDate); + }, + error() { + createFlash({ + message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), { + issuableType: this.issuableType, + }), + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.dueDate.loading || this.loading; + }, + hasDueDate() { + return this.dueDate !== null; + }, + parsedDueDate() { + if (!this.hasDueDate) { + return null; + } + + return parsePikadayDate(this.dueDate); + }, + formattedDueDate() { + if (!this.hasDueDate) { + return this.$options.i18n.noDueDate; + } + + return dateInWords(this.parsedDueDate, true); + }, + workspacePath() { + return this.issuableType === IssuableType.Issue + ? { + projectPath: this.fullPath, + } + : { + groupPath: this.fullPath, + }; + }, + }, + methods: { + closeForm() { + this.$refs.editable.collapse(); + this.$el.dispatchEvent(hideDropdownEvent); + this.$emit('closeForm'); + }, + openDatePicker() { + this.$refs.datePicker.calendar.show(); + }, + setDueDate(date) { + this.loading = true; + this.$refs.editable.collapse(); + this.$apollo + .mutate({ + mutation: dueDateQueries[this.issuableType].mutation, + variables: { + input: { + ...this.workspacePath, + iid: this.iid, + dueDate: date ? formatDate(date, 'yyyy-mm-dd') : null, + }, + }, + }) + .then( + ({ + data: { + issuableSetDueDate: { errors }, + }, + }) => { + if (errors.length) { + createFlash({ + message: errors[0], + }); + } else { + this.$emit('closeForm'); + } + }, + ) + .catch(() => { + createFlash({ + message: sprintf(__('Something went wrong while setting %{issuableType} due date.'), { + issuableType: this.issuableType, + }), + }); + }) + .finally(() => { + this.loading = false; + }); + }, + }, + i18n: { + dueDate: __('Due date'), + noDueDate: __('None'), + removeDueDate: __('remove due date'), + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="$options.i18n.dueDate" + :tracking="$options.tracking" + :loading="isLoading" + class="block" + data-testid="due-date" + @open="openDatePicker" + > + <template #collapsed> + <div v-gl-tooltip :title="$options.i18n.dueDate" class="sidebar-collapsed-icon"> + <gl-icon :size="16" name="calendar" /> + <span class="collapse-truncated-title">{{ formattedDueDate }}</span> + </div> + <div class="gl-display-flex gl-align-items-center hide-collapsed"> + <span + :class="hasDueDate ? 'gl-text-gray-900 gl-font-weight-bold' : 'gl-text-gray-500'" + data-testid="sidebar-duedate-value" + > + {{ formattedDueDate }} + </span> + <div v-if="hasDueDate && canUpdate" class="gl-display-flex"> + <span class="gl-px-2">-</span> + <gl-button + variant="link" + class="gl-text-gray-500!" + data-testid="reset-button" + :disabled="isLoading" + @click="setDueDate(null)" + > + {{ $options.i18n.removeDueDate }} + </gl-button> + </div> + </div> + </template> + <template #default> + <gl-datepicker + ref="datePicker" + :value="parsedDueDate" + show-clear-button + @input="setDueDate" + @clear="setDueDate(null)" + /> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue index 567c921b74e..d07c6e0cbd2 100644 --- a/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue +++ b/app/assets/javascripts/sidebar/components/reference/sidebar_reference_widget.vue @@ -1,17 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { referenceQueries } from '~/sidebar/constants'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; export default { - i18n: { - copyReference: __('Copy reference'), - text: __('Reference'), - }, components: { - ClipboardButton, - GlLoadingIcon, + CopyableField, }, inject: ['fullPath', 'iid'], props: { @@ -56,29 +50,10 @@ export default { </script> <template> - <div class="sub-block"> - <clipboard-button - v-if="!isLoading" - :title="$options.i18n.copyReference" - :text="reference" - category="tertiary" - css-class="sidebar-collapsed-icon dont-change-state" - tooltip-placement="left" - /> - <div class="gl-display-flex gl-align-items-center gl-justify-between gl-mb-2 hide-collapsed"> - <span class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap"> - {{ $options.i18n.text }}: {{ reference }} - <gl-loading-icon v-if="isLoading" inline :label="$options.i18n.text" /> - </span> - <clipboard-button - v-if="!isLoading" - :title="$options.i18n.copyReference" - :text="reference" - size="small" - category="tertiary" - css-class="gl-mr-1" - tooltip-placement="left" - /> - </div> - </div> + <copyable-field + class="sub-block" + :is-loading="isLoading" + :name="__('Reference')" + :value="reference" + /> </template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index dd1d54d67f2..c6fef86c6ff 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -1,12 +1,15 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import { sprintf, s__ } from '~/locale'; +import { __, sprintf, s__ } from '~/locale'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; const LOADING_STATE = 'loading'; const SUCCESS_STATE = 'success'; export default { + i18n: { + reRequestReview: __('Re-request review'), + }, components: { GlButton, GlIcon, @@ -109,7 +112,8 @@ export default { <gl-button v-else-if="user.can_update_merge_request && user.reviewed" v-gl-tooltip.left - :title="__('Re-request review')" + :title="$options.i18n.reRequestReview" + :aria-label="$options.i18n.reRequestReview" :loading="loadingStates[user.id] === $options.LOADING_STATE" class="float-right gl-text-gray-500!" size="small" diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 4ab4606ac1c..caf1c92c28a 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { components: { GlButton, GlLoadingIcon }, @@ -20,6 +21,16 @@ export default { required: false, default: false, }, + initialLoading: { + type: Boolean, + required: false, + default: false, + }, + isDirty: { + type: Boolean, + required: false, + default: false, + }, tracking: { type: Object, required: false, @@ -35,6 +46,11 @@ export default { edit: false, }; }, + computed: { + editButtonText() { + return this.isDirty ? __('Apply') : __('Edit'); + }, + }, destroyed() { window.removeEventListener('click', this.collapseWhenOffClick); window.removeEventListener('keyup', this.collapseOnEscape); @@ -86,15 +102,15 @@ export default { <template> <div> <div class="gl-display-flex gl-align-items-center" @click.self="collapse"> - <span class="hide-collapsed" data-testid="title">{{ title }}</span> - <gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" /> + <span class="hide-collapsed" data-testid="title" @click="collapse">{{ title }}</span> + <gl-loading-icon v-if="loading || initialLoading" inline class="gl-ml-2 hide-collapsed" /> <gl-loading-icon v-if="loading && isClassicSidebar" inline class="gl-mx-auto gl-my-0 hide-expanded" /> <gl-button - v-if="canUpdate" + v-if="canUpdate && !initialLoading" variant="link" class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed" data-testid="edit-button" @@ -105,14 +121,16 @@ export default { @keyup.esc="toggle" @click="toggle" > - {{ __('Edit') }} + {{ editButtonText }} </gl-button> </div> - <div v-show="!edit" data-testid="collapsed-content"> - <slot name="collapsed">{{ __('None') }}</slot> - </div> - <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> - <slot :edit="edit"></slot> - </div> + <template v-if="!initialLoading"> + <div v-show="!edit" data-testid="collapsed-content"> + <slot name="collapsed">{{ __('None') }}</slot> + </div> + <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> + <slot :edit="edit"></slot> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index a0e636488f4..80e07d556bf 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,10 +1,12 @@ import { IssuableType } from '~/issue_show/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.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 mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql'; +import updateIssueDueDateMutation from '~/sidebar/queries/update_issue_due_date.mutation.graphql'; import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql'; import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; @@ -42,3 +44,10 @@ export const referenceQueries = { query: mergeRequestReferenceQuery, }, }; + +export const dueDateQueries = { + [IssuableType.Issue]: { + query: issueDueDateQuery, + mutation: updateIssueDueDateMutation, + }, +}; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 312c0c89f29..1304e84814b 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -10,7 +10,10 @@ 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/due_date/sidebar_due_date_widget.vue'; import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; import { apolloProvider } from '~/sidebar/graphql'; import Translate from '../vue_shared/translate'; @@ -32,15 +35,6 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op return JSON.parse(sidebarOptEl.innerHTML); } -/** - * Extracts the list of assignees with availability information from a hidden input - * field and converts to a key:value pair for use in the sidebar assignees component. - * The assignee username is used as the key and their busy status is the value - * - * e.g { root: 'busy', admin: '' } - * - * @returns {Object} - */ function getSidebarAssigneeAvailabilityData() { const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input'); return Array.from(sidebarAssigneeEl) @@ -54,7 +48,7 @@ function getSidebarAssigneeAvailabilityData() { ); } -function mountAssigneesComponent(mediator) { +function mountAssigneesComponentDeprecated(mediator) { const el = document.getElementById('js-vue-sidebar-assignees'); if (!el) return; @@ -86,6 +80,51 @@ function mountAssigneesComponent(mediator) { }); } +function mountAssigneesComponent() { + const el = document.getElementById('js-vue-sidebar-assignees'); + + if (!el) return; + + const { iid, fullPath, editable, projectMembersPath } = getSidebarOptions(); + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + SidebarAssigneesWidget, + }, + provide: { + canUpdate: editable, + projectMembersPath, + directlyInviteMembers: el.hasAttribute('data-directly-invite-members'), + indirectlyInviteMembers: el.hasAttribute('data-indirectly-invite-members'), + }, + render: (createElement) => + createElement('sidebar-assignees-widget', { + props: { + iid: String(iid), + fullPath, + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() + ? IssuableType.Issue + : IssuableType.MergeRequest, + multipleAssignees: !el.dataset.maxAssignees, + }, + scopedSlots: { + collapsed: ({ users, onClick }) => + createElement(CollapsedAssigneeList, { + props: { + users, + }, + nativeOn: { + click: onClick, + }, + }), + }, + }), + }); +} + function mountReviewersComponent(mediator) { const el = document.getElementById('js-vue-sidebar-reviewers'); @@ -151,14 +190,14 @@ function mountConfidentialComponent() { SidebarConfidentialityWidget, }, provide: { - iid: String(iid), - fullPath, canUpdate: initialData.is_editable, }, render: (createElement) => createElement('sidebar-confidentiality-widget', { props: { + iid: String(iid), + fullPath, issuableType: isInIssuePage() || isInIncidentPage() || isInDesignPage() ? IssuableType.Issue @@ -168,6 +207,36 @@ function mountConfidentialComponent() { }); } +function mountDueDateComponent() { + const el = document.getElementById('js-due-date-entry-point'); + if (!el) { + return; + } + + const { fullPath, iid, editable } = getSidebarOptions(); + + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + SidebarDueDateWidget, + }, + provide: { + iid: String(iid), + fullPath, + canUpdate: editable, + }, + + render: (createElement) => + createElement('sidebar-due-date-widget', { + props: { + issuableType: IssuableType.Issue, + }, + }), + }); +} + function mountReferenceComponent() { const el = document.getElementById('js-reference-entry-point'); if (!el) { @@ -337,14 +406,22 @@ function mountCopyEmailComponent() { new Vue({ el, render: (createElement) => - createElement(CopyEmailToClipboard, { props: { copyText: createNoteEmail } }), + createElement(CopyEmailToClipboard, { props: { issueEmailAddress: createNoteEmail } }), }); } +const isAssigneesWidgetShown = + (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; + export function mountSidebar(mediator) { - mountAssigneesComponent(mediator); + if (isAssigneesWidgetShown) { + mountAssigneesComponent(); + } else { + mountAssigneesComponentDeprecated(mediator); + } mountReviewersComponent(mediator); mountConfidentialComponent(mediator); + mountDueDateComponent(mediator); mountReferenceComponent(mediator); mountLockComponent(); mountParticipantsComponent(mediator); diff --git a/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql new file mode 100644 index 00000000000..6d3f782bd0a --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issue_due_date.query.graphql @@ -0,0 +1,10 @@ +query issueDueDate($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + dueDate + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql new file mode 100644 index 00000000000..f2b806102f4 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_epic_subscription.mutation.graphql @@ -0,0 +1,8 @@ +mutation epicSetSubscription($input: EpicSetSubscriptionInput!) { + updateIssuableSubscription: epicSetSubscription(input: $input) { + epic { + subscribed + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql new file mode 100644 index 00000000000..317b48c142d --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_epic_title.mutation.graphql @@ -0,0 +1,8 @@ +mutation updateEpic($input: UpdateEpicInput!) { + updateIssuableTitle: updateEpic(input: $input) { + epic { + title + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql new file mode 100644 index 00000000000..cf7eccd61c7 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_issue_due_date.mutation.graphql @@ -0,0 +1,9 @@ +mutation updateIssueDueDate($input: UpdateIssueInput!) { + issuableSetDueDate: updateIssue(input: $input) { + issuable: issue { + id + dueDate + } + errors + } +} diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index bee9d7b8c2a..c53d0575752 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -33,7 +33,6 @@ export default { SnippetBlobActionsEdit, TitleField, FormFooterActions, - CaptchaModal: () => import('~/captcha/captcha_modal.vue'), GlButton, GlLoadingIcon, }, @@ -68,10 +67,6 @@ export default { description: '', visibilityLevel: this.selectedLevel, }, - captchaResponse: '', - needsCaptchaResponse: false, - captchaSiteKey: '', - spamLogId: '', }; }, computed: { @@ -103,8 +98,6 @@ export default { description: this.snippet.description, visibilityLevel: this.snippet.visibilityLevel, blobActions: this.actions, - ...(this.spamLogId && { spamLogId: this.spamLogId }), - ...(this.captchaResponse && { captchaResponse: this.captchaResponse }), }; }, saveButtonLabel() { @@ -171,20 +164,14 @@ export default { }, handleFormSubmit() { this.isUpdating = true; + this.$apollo .mutate(this.newSnippet ? this.createMutation() : this.updateMutation()) .then(({ data }) => { const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet; - if (baseObj.needsCaptchaResponse) { - // If we need a captcha response, start process for receiving captcha response. - // We will resubmit after the response is obtained. - this.requestCaptchaResponse(baseObj.captchaSiteKey, baseObj.spamLogId); - return; - } - const errors = baseObj?.errors; - if (errors.length) { + if (errors?.length) { this.flashAPIFailure(errors[0]); } else { redirectTo(baseObj.snippet.webUrl); @@ -200,38 +187,6 @@ export default { updateActions(actions) { this.actions = actions; }, - /** - * Start process for getting captcha response from user - * - * @param captchaSiteKey Stored in data and used to display the captcha. - * @param spamLogId Stored in data and included when the form is re-submitted. - */ - requestCaptchaResponse(captchaSiteKey, spamLogId) { - this.captchaSiteKey = captchaSiteKey; - this.spamLogId = spamLogId; - this.needsCaptchaResponse = true; - }, - /** - * Handle the captcha response from the user - * - * @param captchaResponse The captchaResponse value emitted from the modal. - */ - receivedCaptchaResponse(captchaResponse) { - this.needsCaptchaResponse = false; - this.captchaResponse = captchaResponse; - - if (this.captchaResponse) { - // If the user solved the captcha, resubmit the form. - // NOTE: we do not need to clear out the captchaResponse and spamLogId - // data values after submit, because this component always does a full page reload. - // Otherwise, we would need to. - this.handleFormSubmit(); - } else { - // If the user didn't solve the captcha (e.g. they just closed the modal), - // finish the update and allow them to continue editing or manually resubmit the form. - this.isUpdating = false; - } - }, }, }; </script> @@ -249,11 +204,6 @@ export default { class="loading-animation prepend-top-20 gl-mb-6" /> <template v-else> - <captcha-modal - :captcha-site-key="captchaSiteKey" - :needs-captcha-response="needsCaptchaResponse" - @receivedCaptchaResponse="receivedCaptchaResponse" - /> <title-field id="snippet-title" v-model="snippet.title" diff --git a/app/assets/javascripts/snippets/components/embed_dropdown.vue b/app/assets/javascripts/snippets/components/embed_dropdown.vue index f6c9c569b5f..ad1b08a5a07 100644 --- a/app/assets/javascripts/snippets/components/embed_dropdown.vue +++ b/app/assets/javascripts/snippets/components/embed_dropdown.vue @@ -65,6 +65,7 @@ export default { <gl-button v-gl-tooltip.hover :title="$options.MSG_COPY" + :aria-label="$options.MSG_COPY" :data-clipboard-text="value" icon="copy-to-clipboard" data-qa-selector="copy_button" diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql index 64d5d7c30fa..f688868d1b9 100644 --- a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql @@ -4,7 +4,5 @@ mutation CreateSnippet($input: CreateSnippetInput!) { snippet { webUrl } - needsCaptchaResponse - captchaSiteKey } } diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql index 0a72f71b7c9..548725f7357 100644 --- a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql +++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql @@ -4,8 +4,5 @@ mutation UpdateSnippet($input: UpdateSnippetInput!) { snippet { webUrl } - needsCaptchaResponse - captchaSiteKey - spamLogId } } diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js index 23f800517c9..2ae2baddbcc 100644 --- a/app/assets/javascripts/static_site_editor/graphql/index.js +++ b/app/assets/javascripts/static_site_editor/graphql/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import appDataQuery from './queries/app_data.query.graphql'; import fileResolver from './resolvers/file'; import hasSubmittedChangesResolver from './resolvers/has_submitted_changes'; import submitContentChangesResolver from './resolvers/submit_content_changes'; @@ -28,7 +29,8 @@ const createApolloProvider = (appData) => { // eslint-disable-next-line @gitlab/require-i18n-strings const mounts = appData.mounts.map((mount) => ({ __typename: 'Mount', ...mount })); - defaultClient.cache.writeData({ + defaultClient.cache.writeQuery({ + query: appDataQuery, data: { appData: { __typename: 'AppData', diff --git a/app/assets/javascripts/tags/components/sort_dropdown.vue b/app/assets/javascripts/tags/components/sort_dropdown.vue new file mode 100644 index 00000000000..036ce2cca78 --- /dev/null +++ b/app/assets/javascripts/tags/components/sort_dropdown.vue @@ -0,0 +1,77 @@ +<script> +import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui'; +import { mergeUrlParams, visitUrl, getParameterValues } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; + +export default { + i18n: { + searchPlaceholder: s__('TagsPage|Filter by tag name'), + }, + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByClick, + }, + inject: ['sortOptions', 'filterTagsPath'], + data() { + return { + selectedKey: 'updated_desc', + searchTerm: '', + }; + }, + computed: { + selectedSortMethod() { + return this.sortOptions[this.selectedKey]; + }, + }, + created() { + const sortValue = getParameterValues('sort'); + const searchValue = getParameterValues('search'); + + if (sortValue.length > 0) { + [this.selectedKey] = sortValue; + } + + if (searchValue.length > 0) { + [this.searchTerm] = searchValue; + } + }, + methods: { + isSortMethodSelected(sortKey) { + return sortKey === this.selectedKey; + }, + visitUrlFromOption(sortKey) { + this.selectedKey = sortKey; + const urlParams = {}; + + urlParams.search = this.searchTerm.length > 0 ? this.searchTerm : null; + urlParams.sort = sortKey; + + const newUrl = mergeUrlParams(urlParams, this.filterTagsPath); + visitUrl(newUrl); + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-pr-3"> + <gl-search-box-by-click + v-model="searchTerm" + :placeholder="$options.i18n.searchPlaceholder" + class="gl-pr-3" + data-testid="tag-search" + @submit="visitUrlFromOption(selectedKey)" + /> + <gl-dropdown :text="selectedSortMethod" right data-testid="tags-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> + </div> +</template> diff --git a/app/assets/javascripts/tags/index.js b/app/assets/javascripts/tags/index.js new file mode 100644 index 00000000000..68510f3fe3a --- /dev/null +++ b/app/assets/javascripts/tags/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import SortDropdown from './components/sort_dropdown.vue'; + +const mountDropdownApp = (el) => { + const { sortOptions, filterTagsPath } = el.dataset; + + return new Vue({ + el, + name: 'SortTagsDropdownApp', + components: { + SortDropdown, + }, + provide: { + sortOptions: JSON.parse(sortOptions), + filterTagsPath, + }, + render: (createElement) => createElement(SortDropdown), + }); +}; + +export default () => { + const el = document.getElementById('js-tags-sort-dropdown'); + return el ? mountDropdownApp(el) : null; +}; diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js index f60c0759c72..49a43b120e0 100644 --- a/app/assets/javascripts/tooltips/index.js +++ b/app/assets/javascripts/tooltips/index.js @@ -44,10 +44,7 @@ const addTooltips = (elements, config) => { const handleTooltipEvent = (rootTarget, e, selector, config = {}) => { for (let { target } = e; target && target !== rootTarget; target = target.parentNode) { if (isTooltip(target, selector)) { - addTooltips([target], { - show: true, - ...config, - }); + addTooltips([target], config); break; } } diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 01de034417e..cdfecceb78a 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -27,22 +27,33 @@ const DEFAULT_SNOWPLOW_OPTIONS = { pageUnloadTimer: 10, }; +const addExperimentContext = (opts) => { + const { experiment, ...options } = opts; + if (experiment) { + const data = getExperimentData(experiment); + if (data) { + const context = { schema: TRACKING_CONTEXT_SCHEMA, data }; + return { ...options, context }; + } + } + return options; +}; + const createEventPayload = (el, { suffix = '' } = {}) => { - const action = el.dataset.trackEvent + (suffix || ''); + const action = (el.dataset.trackAction || el.dataset.trackEvent) + (suffix || ''); let value = el.dataset.trackValue || el.value || undefined; if (el.type === 'checkbox' && !el.checked) value = false; - let context = el.dataset.trackContext; - if (el.dataset.trackExperiment) { - const data = getExperimentData(el.dataset.trackExperiment); - if (data) context = { schema: TRACKING_CONTEXT_SCHEMA, data }; - } + const context = addExperimentContext({ + experiment: el.dataset.trackExperiment, + context: el.dataset.trackContext, + }); const data = { label: el.dataset.trackLabel, property: el.dataset.trackProperty, value, - context, + ...context, }; return { @@ -52,7 +63,7 @@ const createEventPayload = (el, { suffix = '' } = {}) => { }; const eventHandler = (e, func, opts = {}) => { - const el = e.target.closest('[data-track-event]'); + const el = e.target.closest('[data-track-event], [data-track-action]'); if (!el) return; @@ -130,7 +141,9 @@ export default class Tracking { static trackLoadEvents(category = document.body.dataset.page, parent = document) { if (!this.enabled()) return []; - const loadEvents = parent.querySelectorAll('[data-track-event="render"]'); + const loadEvents = parent.querySelectorAll( + '[data-track-action="render"], [data-track-event="render"]', + ); loadEvents.forEach((element) => { const { action, data } = createEventPayload(element); @@ -148,7 +161,8 @@ export default class Tracking { return localCategory || opts.category; }, trackingOptions() { - return { ...opts, ...this.tracking }; + const options = addExperimentContext(opts); + return { ...options, ...this.tracking }; }, }, methods: { diff --git a/app/assets/javascripts/user_lists/components/user_list.vue b/app/assets/javascripts/user_lists/components/user_list.vue index e33a4b3ffb4..4cf3f3010b9 100644 --- a/app/assets/javascripts/user_lists/components/user_list.vue +++ b/app/assets/javascripts/user_lists/components/user_list.vue @@ -126,6 +126,7 @@ export default { category="secondary" variant="danger" icon="remove" + :aria-label="__('Remove user')" data-testid="delete-user-id" @click="removeUserId(id)" /> diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index e1a4a74b982..7c17ce85cc6 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -11,7 +11,6 @@ import { import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { isUserBusy } from '~/set_status_modal/utils'; import { fixTitle, dispose } from '~/tooltips'; -import ModalStore from '../boards/stores/modal_store'; import axios from '../lib/utils/axios_utils'; import { parseBoolean, spriteIcon } from '../lib/utils/common_utils'; import { loadCSSFile } from '../lib/utils/css_utils'; @@ -258,7 +257,11 @@ function UsersSelect(currentUser, els, options = {}) { deprecatedJQueryDropdown.options.processData(term, users, callback); }); }, - processData(term, data, callback) { + processData(term, dataArg, callback) { + // Sometimes the `dataArg` can contain special dropdown items like + // dividers which we don't want to consider here. + const data = dataArg.filter((x) => !x.type); + let users = data; // Only show assigned user list when there is no search term @@ -504,9 +507,7 @@ function UsersSelect(currentUser, els, options = {}) { } return; } - if ($el.closest('.add-issues-modal').length) { - ModalStore.store.filter[$dropdown.data('fieldName')] = user.id; - } else if (handleClick) { + if (handleClick) { e.preventDefault(); handleClick(user, isMarking); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue index cc3efae565a..b25c0cc0d96 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_action_button.vue @@ -68,6 +68,7 @@ export default { category="primary" size="small" :title="buttonTitle" + :aria-label="buttonTitle" :loading="isLoading" :disabled="isActionInProgress" :class="`inline gl-ml-2 ${containerClasses}`" 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 3419abd4738..1248a891ed9 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 @@ -14,6 +14,7 @@ import { s__, n__ } from '~/locale'; import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import { MT_MERGE_STRATEGY } from '../constants'; @@ -28,6 +29,7 @@ export default { GlTooltip, PipelineArtifacts, PipelineMiniGraph, + TimeAgoTooltip, TooltipOnTruncate, LinkedPipelinesMiniList: () => import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), @@ -114,6 +116,9 @@ export default { showSourceBranch() { return Boolean(this.pipeline.ref.branch); }, + finishedAt() { + return this.pipeline?.details?.finished_at; + }, coverageDeltaClass() { const delta = this.pipelineCoverageDelta; if (delta && parseFloat(delta) > 0) { @@ -127,10 +132,20 @@ export default { pipelineCoverageJobNumberText() { return n__('from %d job', 'from %d jobs', this.buildsWithCoverage.length); }, + pipelineCoverageTooltipDeltaDescription() { + const delta = parseFloat(this.pipelineCoverageDelta) || 0; + if (delta > 0) { + return s__('Pipeline|This change will increase the overall test coverage if merged.'); + } + if (delta < 0) { + return s__('Pipeline|This change will decrease the overall test coverage if merged.'); + } + return s__('Pipeline|This change will not change the overall test coverage if merged.'); + }, pipelineCoverageTooltipDescription() { return n__( - 'Coverage value for this pipeline was calculated by the coverage value of %d job.', - 'Coverage value for this pipeline was calculated by averaging the resulting coverage values of %d jobs.', + 'Test coverage value for this pipeline was calculated by the coverage value of %d job.', + 'Test coverage value for this pipeline was calculated by averaging the resulting coverage values of %d jobs.', this.buildsWithCoverage.length, ); }, @@ -216,15 +231,24 @@ export default { class="label-branch label-truncate gl-font-weight-normal" /> </template> + <template v-if="finishedAt"> + <time-ago-tooltip + :time="finishedAt" + tooltip-placement="bottom" + data-testid="finished-at" + /> + </template> </div> <div v-if="pipeline.coverage" class="coverage" data-testid="pipeline-coverage"> - {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}% + {{ s__('Pipeline|Test coverage') }} {{ pipeline.coverage }}% <span v-if="pipelineCoverageDelta" + ref="pipelineCoverageDelta" :class="coverageDeltaClass" data-testid="pipeline-coverage-delta" - >({{ pipelineCoverageDelta }}%)</span > + ({{ pipelineCoverageDelta }}%) + </span> {{ pipelineCoverageJobNumberText }} <span ref="pipelineCoverageQuestion"> <gl-icon name="question" :size="12" /> @@ -242,6 +266,12 @@ export default { {{ build.name }} ({{ build.coverage }}%) </div> </gl-tooltip> + <gl-tooltip + :target="() => $refs.pipelineCoverageDelta" + data-testid="pipeline-coverage-delta-tooltip" + > + {{ pipelineCoverageTooltipDeltaDescription }} + </gl-tooltip> </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 84a21a25552..6d68c15cf2d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -71,11 +71,11 @@ export default { return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).targetBranch; }, shouldRemoveSourceBranch() { - if (this.glFeatures.mergeRequestWidgetGraphql) { - return this.state.shouldRemoveSourceBranch || this.state.forceRemoveSourceBranch; - } + if (!this.glFeatures.mergeRequestWidgetGraphql) return this.mr.shouldRemoveSourceBranch; + + if (!this.state.shouldRemoveSourceBranch) return false; - return this.mr.shouldRemoveSourceBranch; + return this.state.shouldRemoveSourceBranch || this.state.forceRemoveSourceBranch; }, autoMergeStrategy() { return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).autoMergeStrategy; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 23f415c3116..ee90d734ecb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -1,6 +1,5 @@ <script> -import { GlButton, GlModalDirective, GlSkeletonLoader, GlPopover, GlLink } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlButton, GlModalDirective, GlSkeletonLoader } from '@gitlab/ui'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import userPermissionsQuery from '../../queries/permissions.query.graphql'; @@ -13,8 +12,6 @@ export default { GlSkeletonLoader, StatusIcon, GlButton, - GlPopover, - GlLink, }, directives: { GlModalDirective, @@ -93,24 +90,12 @@ export default { return this.mr.sourceBranchProtected; }, - popoverTitle() { - return s__( - 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.', - ); - }, showResolveButton() { - return this.mr.conflictResolutionPath && this.canPushToSourceBranch; - }, - showPopover() { - return this.showResolveButton && this.sourceBranchProtected; + return ( + this.mr.conflictResolutionPath && this.canPushToSourceBranch && !this.sourceBranchProtected + ); }, }, - i18n: { - title: s__( - 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.', - ), - linkText: s__('mrWidget|Learn more about resolving conflicts'), - }, }; </script> <template> @@ -141,33 +126,13 @@ export default { }} </span> </span> - <span v-if="showResolveButton" ref="popover"> - <gl-button - :href="mr.conflictResolutionPath" - :disabled="sourceBranchProtected" - data-testid="resolve-conflicts-button" - > - {{ s__('mrWidget|Resolve conflicts') }} - </gl-button> - <gl-popover - v-if="showPopover" - :target="() => $refs.popover" - placement="top" - triggers="hover focus" - > - <template #title> - <div class="gl-font-weight-normal gl-font-base"> - {{ $options.i18n.title }} - </div> - </template> - - <div class="gl-text-center"> - <gl-link :href="mr.conflictsDocsPath" target="_blank" rel="noopener noreferrer"> - {{ $options.i18n.linkText }} - </gl-link> - </div> - </gl-popover> - </span> + <gl-button + v-if="showResolveButton" + :href="mr.conflictResolutionPath" + data-testid="resolve-conflicts-button" + > + {{ s__('mrWidget|Resolve conflicts') }} + </gl-button> <gl-button v-if="canMerge" v-gl-modal-directive="'modal-merge-info'" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 043d14e32a2..9da3bea9362 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -130,6 +130,7 @@ export default { size="small" category="secondary" variant="warning" + data-qa-selector="revert_button" @click="openRevertModal" > {{ revertLabel }} @@ -151,6 +152,7 @@ export default { v-gl-tooltip.hover :title="cherryPickTitle" size="small" + data-qa-selector="cherry_pick_button" @click="openCherryPickModal" > {{ cherryPickLabel }} 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 62c5cd90035..751f8082e1a 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 @@ -15,10 +15,12 @@ import { isEmpty } from 'lodash'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; +import createFlash from '~/flash'; +import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import simplePoll from '~/lib/utils/simple_poll'; import { __ } from '~/locale'; +import SmartInterval from '~/smart_interval'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { deprecatedCreateFlash as Flash } from '../../../flash'; import MergeRequest from '../../../merge_request'; import { AUTO_MERGE_STRATEGIES, DANGER, INFO, WARNING } from '../../constants'; import eventHub from '../../event_hub'; @@ -52,20 +54,27 @@ export default { }, manual: true, result({ data }) { + if (Object.keys(this.state).length === 0) { + this.removeSourceBranch = + data.project.mergeRequest.shouldRemoveSourceBranch || + data.project.mergeRequest.forceRemoveSourceBranch || + false; + this.commitMessage = data.project.mergeRequest.defaultMergeCommitMessage; + this.squashBeforeMerge = data.project.mergeRequest.squashOnMerge; + this.isSquashReadOnly = data.project.squashReadOnly; + this.squashCommitMessage = data.project.mergeRequest.defaultSquashCommitMessage; + } + this.state = { ...data.project.mergeRequest, mergeRequestsFfOnlyEnabled: data.project.mergeRequestsFfOnlyEnabled, onlyAllowMergeIfPipelineSucceeds: data.project.onlyAllowMergeIfPipelineSucceeds, }; - this.removeSourceBranch = - data.project.mergeRequest.shouldRemoveSourceBranch || - data.project.mergeRequest.forceRemoveSourceBranch || - false; - this.commitMessage = data.project.mergeRequest.defaultMergeCommitMessage; - this.squashBeforeMerge = data.project.mergeRequest.squashOnMerge; - this.isSquashReadOnly = data.project.squashReadOnly; - this.squashCommitMessage = data.project.mergeRequest.defaultSquashCommitMessage; this.loading = false; + + if (this.state.mergeTrainsCount !== null && this.state.mergeTrainsCount !== undefined) { + this.initPolling(); + } }, }, }, @@ -124,7 +133,7 @@ export default { }, pipeline() { if (this.glFeatures.mergeRequestWidgetGraphql) { - return this.state.pipelines?.nodes?.[0]; + return this.state.headPipeline; } return this.mr.pipeline; @@ -291,8 +300,23 @@ export default { if (this.glFeatures.mergeRequestWidgetGraphql) { eventHub.$off('ApprovalUpdated', this.updateGraphqlState); } + + if (this.pollingInterval) { + this.pollingInterval.destroy(); + } }, methods: { + initPolling() { + const startingPollInterval = secondsToMilliseconds(5); + + this.pollingInterval = new SmartInterval({ + callback: () => this.$apollo.queries.state.refetch(), + startingInterval: startingPollInterval, + maxInterval: startingPollInterval + secondsToMilliseconds(4 * 60), + hiddenInterval: secondsToMilliseconds(6 * 60), + incrementByFactorOf: 2, + }); + }, updateGraphqlState() { return this.$apollo.queries.state.refetch(); }, @@ -351,7 +375,9 @@ export default { }) .catch(() => { this.isMakingRequest = false; - new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line + createFlash({ + message: __('Something went wrong. Please try again.'), + }); }); }, handleMergeImmediatelyButtonClick() { @@ -402,7 +428,9 @@ export default { } }) .catch(() => { - new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line + createFlash({ + message: __('Something went wrong while merging this merge request. Please try again.'), + }); stopPolling(); }); }, @@ -432,7 +460,9 @@ export default { } }) .catch(() => { - new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line + createFlash({ + message: __('Something went wrong while deleting the source branch. Please try again.'), + }); }); }, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index 6388b817e46..41b5983ae0c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -44,7 +44,8 @@ export default { :checked="value" :disabled="isDisabled" name="squash" - class="qa-squash-checkbox js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center" + class="js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center" + data-qa-selector="squash_checkbox" :title="tooltipTitle" @change="(checked) => $emit('input', checked)" > 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 89b095fbfc1..264ea36137f 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 @@ -480,6 +480,7 @@ export default { v-if="mr.testResultsPath" class="js-reports-container" :endpoint="mr.testResultsPath" + :head-blob-path="mr.headBlobPath" :pipeline-path="mr.pipeline.path" /> @@ -513,7 +514,7 @@ export default { > {{ s__( - 'mrWidget|Fork merge requests do not create merge request pipelines which validate a post merge result', + 'mrWidget|If the last pipeline ran in the fork project, it may be inaccurate. Before merge, we advise running a pipeline in this project.', ) }} </mr-widget-alert-message> diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql index 13ea07884b1..871aa880b36 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -11,11 +11,10 @@ query getState($projectPath: ID!, $iid: String!) { mergeError mergeStatus mergeableDiscussionsState - pipelines(first: 1) { - nodes { - status - warnings - } + headPipeline { + id + status + warnings } shouldBeRebased sourceBranchExists 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 8ee45b05431..367b9ad1cdf 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 @@ -30,13 +30,11 @@ fragment ReadyToMerge on Project { message } } - pipelines(first: 1) { - nodes { - id - status - path - active - } + 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 7ccbd771379..f57b638dd81 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 @@ -60,6 +60,7 @@ export default class MergeRequestStore { this.rebaseInProgress = data.rebase_in_progress; this.mergeRequestDiffsPath = data.diffs_path; this.approvalsWidgetType = data.approvals_widget_type; + this.mergeRequestWidgetPath = data.merge_request_widget_path; if (data.issues_links) { const links = data.issues_links; @@ -163,7 +164,7 @@ export default class MergeRequestStore { setGraphqlData(project) { const { mergeRequest } = project; - const pipeline = mergeRequest.pipelines?.nodes?.[0]; + const pipeline = mergeRequest.headPipeline; this.projectArchived = project.archived; this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds; 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 f7b49a85b83..3905ce2596c 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 @@ -21,7 +21,7 @@ import Tracking from '~/tracking'; import initUserPopovers from '~/user_popovers'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import { SEVERITY_LEVELS } from '../constants'; +import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants'; import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql'; import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql'; import alertQuery from '../graphql/queries/alert_details.query.graphql'; @@ -92,6 +92,9 @@ export default { projectIssuesPath: { default: '', }, + statuses: { + default: PAGE_CONFIG.OPERATIONS.STATUSES, + }, trackAlertsDetailsViewsOptions: { default: null, }, @@ -367,7 +370,7 @@ export default { > {{ alert.runbook }} </alert-summary-row> - <alert-details-table :alert="alert" :loading="loading" /> + <alert-details-table :alert="alert" :loading="loading" :statuses="statuses" /> </gl-tab> <gl-tab v-if="!isThreatMonitoringPage" diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue index a01bd462196..554c7a573fe 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue @@ -19,10 +19,6 @@ export default { projectId: { default: '', }, - // TODO remove this limitation in https://gitlab.com/gitlab-org/gitlab/-/issues/296717 - isThreatMonitoringPage: { - default: false, - }, }, props: { alert: { @@ -66,7 +62,6 @@ export default { @alert-error="$emit('alert-error', $event)" /> <sidebar-status - v-if="!isThreatMonitoringPage" :project-path="projectPath" :alert="alert" @toggle-sidebar="$emit('toggle-sidebar')" 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 8d5eb24ed1d..672761af1cf 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 @@ -3,6 +3,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; +import { PAGE_CONFIG } from '../constants'; export default { i18n: { @@ -11,11 +12,6 @@ export default { ), UPDATE_ALERT_STATUS_INSTRUCTION: s__('AlertManagement|Please try again.'), }, - statuses: { - TRIGGERED: s__('AlertManagement|Triggered'), - ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), - RESOLVED: s__('AlertManagement|Resolved'), - }, components: { GlDropdown, GlDropdownItem, @@ -42,6 +38,11 @@ export default { type: Boolean, required: true, }, + statuses: { + type: Object, + required: false, + default: () => PAGE_CONFIG.OPERATIONS.STATUSES, + }, }, computed: { dropdownClass() { @@ -57,13 +58,13 @@ export default { mutation: updateAlertStatusMutation, variables: { iid: this.alert.iid, - status: status.toUpperCase(), + status, projectPath: this.projectPath, }, }) .then((resp) => { if (this.trackAlertStatusUpdateOptions) { - this.trackStatusUpdate(status); + this.trackStatusUpdate(this.statuses[status]); } const errors = resp.data?.updateAlertStatus?.errors || []; @@ -99,7 +100,7 @@ export default { <gl-dropdown ref="dropdown" right - :text="$options.statuses[alert.status]" + :text="statuses[alert.status]" class="w-100" toggle-class="dropdown-menu-toggle" @keydown.esc.native="$emit('hide-dropdown')" @@ -110,12 +111,12 @@ export default { </p> <div class="dropdown-content dropdown-body"> <gl-dropdown-item - v-for="(label, field) in $options.statuses" + v-for="(label, field) in statuses" :key="field" data-testid="statusDropdownItem" - :active="label.toUpperCase() === alert.status" + :active="field === alert.status" :active-class="'is-active'" - @click="updateAlertStatus(label)" + @click="updateAlertStatus(field)" > {{ label }} </gl-dropdown-item> diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue index 0a2bad5510b..3822b9153a4 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue @@ -1,14 +1,9 @@ <script> import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { PAGE_CONFIG } from '../../constants'; import AlertStatus from '../alert_status.vue'; export default { - statuses: { - TRIGGERED: s__('AlertManagement|Triggered'), - ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), - RESOLVED: s__('AlertManagement|Resolved'), - }, components: { GlIcon, GlLoadingIcon, @@ -16,6 +11,11 @@ export default { GlSprintf, AlertStatus, }, + inject: { + statuses: { + default: PAGE_CONFIG.OPERATIONS.STATUSES, + }, + }, props: { projectPath: { type: String, @@ -94,6 +94,7 @@ export default { :project-path="projectPath" :is-dropdown-showing="isDropdownShowing" :is-sidebar="true" + :statuses="statuses" @alert-error="$emit('alert-error', $event)" @hide-dropdown="hideDropdown" @handle-updating="handleUpdating" @@ -103,14 +104,11 @@ export default { <p v-else-if="!isDropdownShowing" class="value gl-m-0" - :class="{ 'no-value': !$options.statuses[alert.status] }" + :class="{ 'no-value': !statuses[alert.status] }" > - <span - v-if="$options.statuses[alert.status]" - class="gl-text-gray-500" - data-testid="status" - >{{ $options.statuses[alert.status] }}</span - > + <span v-if="statuses[alert.status]" class="gl-text-gray-500" data-testid="status"> + {{ statuses[alert.status] }} + </span> <span v-else> {{ s__('AlertManagement|None') }} </span> diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js index 2ab5160534c..6cc70739eaa 100644 --- a/app/assets/javascripts/vue_shared/alert_details/constants.js +++ b/app/assets/javascripts/vue_shared/alert_details/constants.js @@ -13,6 +13,11 @@ export const SEVERITY_LEVELS = { export const PAGE_CONFIG = { OPERATIONS: { TITLE: 'OPERATIONS', + STATUSES: { + TRIGGERED: s__('AlertManagement|Triggered'), + ACKNOWLEDGED: s__('AlertManagement|Acknowledged'), + RESOLVED: s__('AlertManagement|Resolved'), + }, // Tracks snowplow event when user views alert details TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: { category: 'Alert Management', @@ -27,5 +32,11 @@ export const PAGE_CONFIG = { }, THREAT_MONITORING: { TITLE: 'THREAT_MONITORING', + STATUSES: { + TRIGGERED: s__('ThreatMonitoring|Unreviewed'), + ACKNOWLEDGED: s__('ThreatMonitoring|In review'), + RESOLVED: s__('ThreatMonitoring|Resolved'), + IGNORED: s__('ThreatMonitoring|Dismissed'), + }, }, }; diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js index 50f2e63702b..fda405c0fa5 100644 --- a/app/assets/javascripts/vue_shared/alert_details/index.js +++ b/app/assets/javascripts/vue_shared/alert_details/index.js @@ -42,7 +42,8 @@ export default (selector) => { }), }); - apolloProvider.clients.defaultClient.cache.writeData({ + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: sidebarStatusQuery, data: { sidebarStatus: false, }, @@ -54,6 +55,7 @@ export default (selector) => { page, projectIssuesPath, projectId, + statuses: PAGE_CONFIG[page].STATUSES, }; if (page === PAGE_CONFIG.OPERATIONS.TITLE) { diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue index 3d49a1cb1c5..a74e9d97143 100644 --- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue +++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue @@ -7,6 +7,7 @@ import { splitCamelCase, } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; +import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants'; const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!'; const tdClass = 'gl-border-gray-100! gl-p-5!'; @@ -42,6 +43,11 @@ export default { type: Boolean, required: true, }, + statuses: { + type: Object, + required: false, + default: () => PAGE_CONFIG.OPERATIONS.STATUSES, + }, }, fields: [ { @@ -71,6 +77,8 @@ export default { let value; if (fieldName === 'environment') { value = fieldValue?.name; + } else if (fieldName === 'status') { + value = this.statuses[fieldValue] || fieldValue; } else { value = fieldValue; } diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 82b3545117f..08d3e163257 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -44,6 +44,16 @@ export default { required: false, default: () => [], }, + selectedClass: { + type: String, + required: false, + default: 'selected', + }, + }, + data() { + return { + isMenuOpen: false, + }; }, computed: { groupedDefaultAwards() { @@ -68,7 +78,7 @@ export default { methods: { getAwardClassBindings(awardList) { return { - selected: this.hasReactionByCurrentUser(awardList), + [this.selectedClass]: this.hasReactionByCurrentUser(awardList), disabled: this.currentUserId === NO_USER_ID, }; }, @@ -147,6 +157,11 @@ export default { const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName; this.$emit('award', parsedName); + + if (document.activeElement) document.activeElement.blur(); + }, + setIsMenuOpen(menuOpen) { + this.isMenuOpen = menuOpen; }, }, }; @@ -172,8 +187,10 @@ export default { <div v-if="canAwardEmoji" class="award-menu-holder"> <emoji-picker v-if="glFeatures.improvedEmojiPicker" - toggle-class="add-reaction-button gl-relative!" + :toggle-class="['add-reaction-button gl-relative!', { 'is-active': isMenuOpen }]" @click="handleAward" + @shown="setIsMenuOpen(true)" + @hidden="setIsMenuOpen(false)" > <template #button-content> <span class="reaction-control-icon reaction-control-icon-neutral"> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js index db61d0f6b05..9c2ed5abf04 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js @@ -11,6 +11,16 @@ export default { type: String, required: true, }, + isRawContent: { + type: Boolean, + default: false, + required: false, + }, + fileName: { + type: String, + required: false, + default: '', + }, }, mounted() { eventHub.$emit(SNIPPET_MEASURE_BLOBS_CONTENT); 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 5bb31f55e6c..f477610ff1d 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,14 +1,17 @@ <script> /* eslint-disable vue/no-v-html */ import { GlIcon } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { HIGHLIGHT_CLASS_NAME } from './constants'; import ViewerMixin from './mixins'; export default { components: { GlIcon, + EditorLite: () => + import(/* webpackChunkName: 'EditorLite' */ '~/vue_shared/components/editor_lite.vue'), }, - mixins: [ViewerMixin], + mixins: [ViewerMixin, glFeatureFlagsMixin()], inject: ['blobHash'], data() { return { @@ -19,6 +22,9 @@ export default { lineNumbers() { return this.content.split('\n').length; }, + refactorBlobViewerEnabled() { + return this.glFeatures.refactorBlobViewer; + }, }, mounted() { const { hash } = window.location; @@ -45,27 +51,31 @@ export default { }; </script> <template> - <div - class="file-content code js-syntax-highlight" - data-qa-selector="file_content" - :class="$options.userColorScheme" - > - <div class="line-numbers"> - <a - v-for="line in lineNumbers" - :id="`L${line}`" - :key="line" - class="diff-line-num js-line-number" - :href="`#LC${line}`" - :data-line-number="line" - @click="scrollToLine(`#LC${line}`)" - > - <gl-icon :size="12" name="link" /> - {{ line }} - </a> - </div> - <div class="blob-content"> - <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre> + <div> + <editor-lite + v-if="isRawContent && refactorBlobViewerEnabled" + :value="content" + :file-name="fileName" + :editor-options="{ readOnly: true }" + /> + <div v-else class="file-content code js-syntax-highlight" :class="$options.userColorScheme"> + <div class="line-numbers"> + <a + v-for="line in lineNumbers" + :id="`L${line}`" + :key="line" + class="diff-line-num js-line-number" + :href="`#LC${line}`" + :data-line-number="line" + @click="scrollToLine(`#LC${line}`)" + > + <gl-icon :size="12" name="link" /> + {{ line }} + </a> + </div> + <div class="blob-content"> + <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue index cd5f63afc79..f14e1992901 100644 --- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue @@ -56,6 +56,7 @@ export default { <gl-button v-gl-tooltip.hover :title="$options.copyURLTooltip" + :aria-label="$options.copyURLTooltip" :data-clipboard-text="sshLink" data-qa-selector="copy_ssh_url_button" icon="copy-to-clipboard" @@ -75,6 +76,7 @@ export default { <gl-button v-gl-tooltip.hover :title="$options.copyURLTooltip" + :aria-label="$options.copyURLTooltip" :data-clipboard-text="httpLink" data-qa-selector="copy_http_url_button" icon="copy-to-clipboard" diff --git a/app/assets/javascripts/vue_shared/components/delete_label_modal.vue b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue new file mode 100644 index 00000000000..1ff0938d086 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/delete_label_modal.vue @@ -0,0 +1,81 @@ +<script> +import { GlModal, GlSprintf, GlButton } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; + +export default { + components: { + GlModal, + GlSprintf, + GlButton, + }, + props: { + selector: { + type: String, + required: true, + }, + }, + data() { + return { + labelName: '', + subjectName: '', + destroyPath: '', + modalId: uniqueId('modal-delete-label-'), + }; + }, + mounted() { + document.querySelectorAll(this.selector).forEach((button) => { + button.addEventListener('click', (e) => { + e.preventDefault(); + + const { labelName, subjectName, destroyPath } = button.dataset; + this.labelName = labelName; + this.subjectName = subjectName; + this.destroyPath = destroyPath; + this.openModal(); + }); + }); + }, + methods: { + openModal() { + this.$refs.modal.show(); + }, + closeModal() { + this.$refs.modal.hide(); + }, + }, +}; +</script> + +<template> + <gl-modal ref="modal" :modal-id="modalId"> + <template #modal-title> + <gl-sprintf :message="__('Delete label: %{labelName}')"> + <template #labelName> + {{ labelName }} + </template> + </gl-sprintf> + </template> + <gl-sprintf + :message=" + __( + `%{strongStart}${labelName}%{strongEnd} will be permanently deleted from ${subjectName}. This cannot be undone.`, + ) + " + > + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + <template #modal-footer> + <gl-button category="secondary" @click="closeModal">{{ __('Cancel') }}</gl-button> + <gl-button + category="primary" + variant="danger" + :href="destroyPath" + data-method="delete" + data-testid="delete-button" + >{{ __('Delete label') }}</gl-button + > + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue deleted file mode 100644 index 3f55f43edbb..00000000000 --- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue +++ /dev/null @@ -1,146 +0,0 @@ -<script> -/* eslint-disable vue/require-default-prop */ -import { __ } from '~/locale'; - -export default { - name: 'DeprecatedModal', // use GlModal instead - - props: { - id: { - type: String, - required: false, - }, - title: { - type: String, - required: false, - }, - text: { - type: String, - required: false, - }, - hideFooter: { - type: Boolean, - required: false, - default: false, - }, - kind: { - type: String, - required: false, - default: 'primary', - }, - modalDialogClass: { - type: String, - required: false, - default: '', - }, - closeKind: { - type: String, - required: false, - default: 'default', - }, - closeButtonLabel: { - type: String, - required: false, - default: __('Cancel'), - }, - primaryButtonLabel: { - type: String, - required: false, - default: '', - }, - secondaryButtonLabel: { - type: String, - required: false, - default: '', - }, - submitDisabled: { - type: Boolean, - required: false, - default: false, - }, - }, - - computed: { - btnKindClass() { - return { - [`btn-${this.kind}`]: true, - }; - }, - btnCancelKindClass() { - return { - [`btn-${this.closeKind}`]: true, - }; - }, - }, - - methods: { - emitCancel(event) { - this.$emit('cancel', event); - }, - emitSubmit(event) { - this.$emit('submit', event); - }, - }, -}; -</script> - -<template> - <div class="modal-open"> - <div :id="id" :class="id ? '' : 'd-block'" class="modal" role="dialog" tabindex="-1"> - <div :class="modalDialogClass" class="modal-dialog" role="document"> - <div class="modal-content"> - <div class="modal-header"> - <slot name="header"> - <h4 class="modal-title float-left">{{ title }}</h4> - <button - type="button" - class="close float-right" - data-dismiss="modal" - :aria-label="__('Close')" - @click="emitCancel($event)" - > - <span aria-hidden="true">×</span> - </button> - </slot> - </div> - <div class="modal-body"> - <slot :text="text" name="body"> - <p>{{ text }}</p> - </slot> - </div> - <div v-if="!hideFooter" class="modal-footer"> - <button - :class="btnCancelKindClass" - type="button" - class="btn" - data-dismiss="modal" - @click="emitCancel($event)" - > - {{ closeButtonLabel }} - </button> - - <slot v-if="secondaryButtonLabel" name="secondary-button"> - <button v-if="secondaryButtonLabel" type="button" class="btn" data-dismiss="modal"> - {{ secondaryButtonLabel }} - </button> - </slot> - - <button - v-if="primaryButtonLabel" - :disabled="submitDisabled" - :class="btnKindClass" - type="button" - class="btn js-primary-button" - data-dismiss="modal" - data-qa-selector="save_changes_button" - @click="emitSubmit($event)" - > - {{ primaryButtonLabel }} - </button> - </div> - </div> - </div> - </div> - <div v-if="!id" class="modal-backdrop fade show"></div> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue index 4ec54b33bce..fbadb202d51 100644 --- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue +++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue @@ -3,6 +3,7 @@ import { GlIcon } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import Mousetrap from 'mousetrap'; import VirtualList from 'vue-virtual-scroll-list'; +import { keysFor, MR_GO_TO_FILE } from '~/behaviors/shortcuts/keybindings'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; import Item from './item.vue'; @@ -128,7 +129,7 @@ export default { this.focusedIndex = 0; } - Mousetrap.bind(['t', 'mod+p'], (e) => { + Mousetrap.bind(keysFor(MR_GO_TO_FILE), (e) => { if (e.preventDefault) { e.preventDefault(); } diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index f7cfb59be01..e622b505570 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -128,6 +128,7 @@ const fileExtensionIcons = { c: 'c', m: 'c', h: 'h', + 'c++': 'cpp', cc: 'cpp', cpp: 'cpp', mm: 'cpp', @@ -402,14 +403,15 @@ const fileNameIcons = { 'gradle.properties': 'gradle', gradlew: 'gradle', 'gradle-wrapper.properties': 'gradle', - license: 'certificate', - 'license.md': 'certificate', - 'license.md.rendered': 'certificate', - 'license.txt': 'certificate', - licence: 'certificate', - 'licence.md': 'certificate', - 'licence.md.rendered': 'certificate', - 'licence.txt': 'certificate', + COPYING: 'certificate', + 'COPYING.LESSER': 'certificate', + LICENSE: 'certificate', + LICENCE: 'certificate', + 'LICENSE.md': 'certificate', + 'LICENCE.md': 'certificate', + 'LICENSE.txt': 'certificate', + 'LICENCE.txt': 'certificate', + '.gitlab-license': 'certificate', dockerfile: 'docker', 'docker-compose.yml': 'docker', '.mailmap': 'email', 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 97a8f681faf..107ced550c1 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 @@ -58,7 +58,7 @@ export default { type: String, required: false, default: '', - validator: (value) => value === '' || /(_desc)|(_asc)/g.test(value), + validator: (value) => value === '' || /(_desc)|(_asc)/gi.test(value), }, showCheckbox: { type: Boolean, @@ -363,6 +363,7 @@ export default { <gl-button v-gl-tooltip :title="sortDirectionTooltip" + :aria-label="sortDirectionTooltip" :icon="sortDirectionIcon" class="flex-shrink-1" @click="handleSortDirectionClick" 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/author_token.vue index d53c829a48e..aeb698a3adb 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/author_token.vue @@ -45,6 +45,9 @@ export default { activeAuthor() { return this.authors.find((author) => author.username.toLowerCase() === this.currentValue); }, + activeAuthorAvatar() { + return this.avatarUrl(this.activeAuthor); + }, }, watch: { active: { @@ -74,6 +77,9 @@ export default { this.loading = false; }); }, + avatarUrl(author) { + return author.avatarUrl || author.avatar_url; + }, searchAuthors: debounce(function debouncedSearch({ data }) { this.fetchAuthorBySearchTerm(data); }, DEBOUNCE_DELAY), @@ -92,7 +98,7 @@ export default { <gl-avatar v-if="activeAuthor" :size="16" - :src="activeAuthor.avatar_url" + :src="activeAuthorAvatar" shape="circle" class="gl-mr-2" /> @@ -115,7 +121,7 @@ export default { :value="author.username" > <div class="d-flex"> - <gl-avatar :size="32" :src="author.avatar_url" /> + <gl-avatar :size="32" :src="avatarUrl(author)" /> <div> <div>{{ author.name }}</div> <div>@{{ author.username }}</div> 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 new file mode 100644 index 00000000000..98190d716c9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue @@ -0,0 +1,105 @@ +<script> +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { __ } from '~/locale'; + +import { DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY, DEBOUNCE_DELAY } from '../constants'; +import { stripQuotes } from '../filtered_search_utils'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + emojis: this.config.initialEmojis || [], + defaultEmojis: this.config.defaultEmojis || [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY], + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeEmoji() { + return this.emojis.find( + (emoji) => emoji.name.toLowerCase() === stripQuotes(this.currentValue), + ); + }, + }, + methods: { + fetchEmojiBySearchTerm(searchTerm) { + this.loading = true; + this.config + .fetchEmojis(searchTerm) + .then((res) => { + this.emojis = Array.isArray(res) ? res : res.data; + }) + .catch(() => createFlash(__('There was a problem fetching emojis.'))) + .finally(() => { + this.loading = false; + }); + }, + searchEmojis: debounce(function debouncedSearch({ data }) { + this.fetchEmojiBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchEmojis" + > + <template #view="{ inputValue }"> + <gl-emoji v-if="activeEmoji" :data-name="activeEmoji.name" /> + <span v-else>{{ inputValue }}</span> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="emoji in defaultEmojis" + :key="emoji.value" + :value="emoji.value" + > + {{ emoji.value }} + </gl-filtered-search-suggestion> + <gl-dropdown-divider v-if="defaultEmojis.length" /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="emoji in emojis" + :key="emoji.name" + :value="emoji.name" + > + <div class="gl-display-flex"> + <gl-emoji :data-name="emoji.name" /> + <span class="gl-ml-3">{{ emoji.name }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue new file mode 100644 index 00000000000..101c7150c55 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/epic_token.vue @@ -0,0 +1,133 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import createFlash from '~/flash'; +import { isNumeric } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; +import { DEBOUNCE_DELAY } from '../constants'; +import { stripQuotes } from '../filtered_search_utils'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + epics: this.config.initialEpics || [], + loading: true, + }; + }, + computed: { + currentValue() { + /* + * When the URL contains the epic_iid, we'd get: '123' + */ + if (isNumeric(this.value.data)) { + return parseInt(this.value.data, 10); + } + + /* + * When the token is added in current session it'd be: 'Foo::&123' + */ + const id = this.value.data.split('::&')[1]; + + if (id) { + return parseInt(id, 10); + } + + return this.value.data; + }, + activeEpic() { + const currentValueIsString = typeof this.currentValue === 'string'; + return this.epics.find( + (epic) => epic[currentValueIsString ? 'title' : 'iid'] === this.currentValue, + ); + }, + }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.epics.length) { + this.searchEpics({ data: this.currentValue }); + } + }, + }, + }, + methods: { + fetchEpicsBySearchTerm(searchTerm = '') { + this.loading = true; + this.config + .fetchEpics(searchTerm) + .then(({ data }) => { + this.epics = data; + }) + .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) + .finally(() => { + this.loading = false; + }); + }, + fetchSingleEpic(iid) { + this.loading = true; + this.config + .fetchSingleEpic(iid) + .then(({ data }) => { + this.epics = [data]; + }) + .catch(() => createFlash({ message: __('There was a problem fetching epics.') })) + .finally(() => { + this.loading = false; + }); + }, + searchEpics: debounce(function debouncedSearch({ data }) { + if (isNumeric(data)) { + return this.fetchSingleEpic(data); + } + return this.fetchEpicsBySearchTerm(data); + }, DEBOUNCE_DELAY), + + getEpicValue(epic) { + return `${epic.title}::&${epic.iid}`; + }, + }, + stripQuotes, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchEpics" + > + <template #view="{ inputValue }"> + <span>{{ activeEpic ? getEpicValue(activeEpic) : $options.stripQuotes(inputValue) }}</span> + </template> + <template #suggestions> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="epic in epics" + :key="epic.id" + :value="getEpicValue(epic)" + > + <div>{{ epic.title }}</div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> 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 9c2a644b7a9..76b005772ec 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 @@ -46,7 +46,7 @@ export default { }, activeLabel() { return this.labels.find( - (label) => label.title.toLowerCase() === stripQuotes(this.currentValue), + (label) => this.getLabelName(label).toLowerCase() === stripQuotes(this.currentValue), ); }, containerStyle() { @@ -69,6 +69,21 @@ export default { }, }, methods: { + /** + * There's an inconsistency between private and public API + * for labels where label name is included in a different + * property; + * + * Private API => `label.title` + * Public API => `label.name` + * + * This method allows compatibility as there may be instances + * where `config.fetchLabels` provided externally may still be + * using either of the two APIs. + */ + getLabelName(label) { + return label.name || label.title; + }, fetchLabelBySearchTerm(searchTerm) { this.loading = true; this.config @@ -85,7 +100,7 @@ export default { }); }, searchLabels: debounce(function debouncedSearch({ data }) { - this.fetchLabelBySearchTerm(data); + if (!this.loading) this.fetchLabelBySearchTerm(data); }, DEBOUNCE_DELAY), }, }; @@ -100,7 +115,7 @@ export default { > <template #view-token="{ inputValue, cssClasses, listeners }"> <gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners" - >~{{ activeLabel ? activeLabel.title : inputValue }}</gl-token + >~{{ activeLabel ? getLabelName(activeLabel) : inputValue }}</gl-token > </template> <template #suggestions> @@ -114,13 +129,17 @@ export default { <gl-dropdown-divider v-if="defaultLabels.length" /> <gl-loading-icon v-if="loading" /> <template v-else> - <gl-filtered-search-suggestion v-for="label in labels" :key="label.id" :value="label.title"> - <div class="gl-display-flex"> + <gl-filtered-search-suggestion + v-for="label in labels" + :key="label.id" + :value="getLabelName(label)" + > + <div class="gl-display-flex gl-align-items-center"> <span :style="{ backgroundColor: label.color }" class="gl-display-inline-block mr-2 p-2" ></span> - <div>{{ label.title }}</div> + <div>{{ getLabelName(label) }}</div> </div> </gl-filtered-search-suggestion> </template> diff --git a/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue deleted file mode 100644 index b649dac029a..00000000000 --- a/app/assets/javascripts/vue_shared/components/gl_toggle_vuex.vue +++ /dev/null @@ -1,49 +0,0 @@ -<script> -import { GlToggle } from '@gitlab/ui'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; - -export default { - name: 'GlToggleVuex', - components: { - GlToggle, - }, - props: { - stateProperty: { - type: String, - required: true, - }, - storeModule: { - type: String, - required: false, - default: null, - }, - setAction: { - type: String, - required: false, - default() { - return `set${capitalizeFirstCharacter(this.stateProperty)}`; - }, - }, - }, - computed: { - value: { - get() { - const { state } = this.$store; - const { stateProperty, storeModule } = this; - return storeModule ? state[storeModule][stateProperty] : state[stateProperty]; - }, - set(value) { - const { stateProperty, storeModule, setAction } = this; - const action = storeModule ? `${storeModule}/${setAction}` : setAction; - this.$store.dispatch(action, { key: stateProperty, value }); - }, - }, - }, -}; -</script> - -<template> - <gl-toggle v-model="value"> - <slot v-bind="{ value }"></slot> - </gl-toggle> -</template> 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 b4cac13168a..f169921d8a6 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -121,13 +121,7 @@ export default { :title="user.email" class="js-user-link commit-committer-link" > - <user-avatar-image - :img-src="avatarUrl" - :img-alt="userAvatarAltText" - :tooltip-text="user.name" - :img-size="24" - /> - + <user-avatar-image :img-src="avatarUrl" :img-alt="userAvatarAltText" :size="24" /> {{ user.name }} </gl-link> <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]"> diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue index 051c65bae70..f36b9107a6e 100644 --- a/app/assets/javascripts/vue_shared/components/help_popover.vue +++ b/app/assets/javascripts/vue_shared/components/help_popover.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlPopover } from '@gitlab/ui'; +import { GlButton, GlPopover, GlSafeHtmlDirective } from '@gitlab/ui'; /** * Render a button with a question mark icon @@ -11,6 +11,9 @@ export default { GlButton, GlPopover, }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, props: { options: { type: Object, @@ -22,15 +25,13 @@ export default { </script> <template> <span> - <gl-button ref="popoverTrigger" variant="link" icon="question" tabindex="0" /> - <gl-popover triggers="hover focus" :target="() => $refs.popoverTrigger.$el" v-bind="options"> - <template #title> - <!-- eslint-disable-next-line vue/no-v-html --> - <span v-html="options.title"></span> + <gl-button ref="popoverTrigger" variant="link" icon="question" :aria-label="__('Help')" /> + <gl-popover :target="() => $refs.popoverTrigger.$el" v-bind="options"> + <template v-if="options.title" #title> + <span v-safe-html="options.title"></span> </template> <template #default> - <!-- eslint-disable-next-line vue/no-v-html --> - <div v-html="options.content"></div> + <div v-safe-html="options.content"></div> </template> </gl-popover> </span> diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js new file mode 100644 index 00000000000..b115b1fb34b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/lib/utils/props_utils.js @@ -0,0 +1,35 @@ +/** + * Return the union of the given components' props options. Required props take + * precendence over non-required props of the same name. + * + * This makes two assumptions: + * - All given components define their props in verbose object format. + * - The components all agree on the `type` of a common prop. + * + * @param {object[]} components The components to derive the union from. + * @returns {object} The union of the props of the given components. + */ +export const propsUnion = (components) => + components.reduce((acc, component) => { + Object.entries(component.props ?? {}).forEach(([propName, propOptions]) => { + if (process.env.NODE_ENV !== 'production') { + if (typeof propOptions !== 'object' || !('type' in propOptions)) { + throw new Error( + `Cannot create props union: expected verbose prop options for prop "${propName}"`, + ); + } + + if (propName in acc && acc[propName]?.type !== propOptions?.type) { + throw new Error( + `Cannot create props union: incompatible prop types for prop "${propName}"`, + ); + } + } + + if (!(propName in acc) || propOptions.required) { + acc[propName] = propOptions; + } + }); + + return acc; + }, {}); 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 10887aee689..90ac20fe748 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue @@ -34,6 +34,7 @@ export default { boundary="window" right menu-class="gl-w-full!" + data-qa-selector="apply_suggestion_button" @shown="$refs.commitMessage.$el.focus()" > <gl-dropdown-form class="gl-px-4! gl-m-0!"> @@ -44,12 +45,14 @@ export default { v-model="message" :placeholder="defaultCommitMessage" submit-on-enter + data-qa-selector="commit_message_textbox" @submit="onApply" /> <gl-button class="gl-w-auto! gl-mt-3 gl-text-center! gl-hover-text-white! gl-transition-medium! float-right" category="primary" variant="success" + data-qa-selector="commit_with_custom_message_button" @click="onApply" > {{ __('Apply') }} diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 25d01dc550f..80b7a9b7d05 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -62,6 +62,11 @@ export default { required: false, default: true, }, + uploadsPath: { + type: String, + required: false, + default: '', + }, enableAutocomplete: { type: Boolean, required: false, @@ -72,6 +77,11 @@ export default { required: false, default: null, }, + lines: { + type: Array, + required: false, + default: () => [], + }, note: { type: Object, required: false, @@ -110,6 +120,20 @@ export default { return this.referencedUsers.length >= referencedUsersThreshold; }, lineContent() { + if (this.lines.length) { + return this.lines + .map((line) => { + const { rich_text: richText, text } = line; + + if (text) { + return text; + } + + return unescape(stripHtml(richText).replace(/\n/g, '')); + }) + .join('\\n'); + } + if (this.line) { const { rich_text: richText, text } = this.line; @@ -144,6 +168,9 @@ export default { false, ); }, + suggestionsStartIndex() { + return Math.max(this.lines.length - 1, 0); + }, }, watch: { isSubmitting(isSubmitting) { @@ -229,12 +256,14 @@ export default { ref="gl-form" :class="{ 'gl-mt-3 gl-mb-3': addSpacingClasses }" class="js-vue-markdown-field md-area position-relative gfm-form" + :data-uploads-path="uploadsPath" > <markdown-header :preview-markdown="previewMarkdown" :line-content="lineContent" :can-suggest="canSuggest" :show-suggest-popover="showSuggestPopover" + :suggestion-start-index="suggestionsStartIndex" @preview-markdown="showPreviewTab" @write-markdown="showWriteTab" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 5bc1786d692..01cf0beea3a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -1,6 +1,7 @@ <script> import { GlPopover, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import $ from 'jquery'; +import { keysFor, BOLD_TEXT, ITALIC_TEXT, LINK_TEXT } from '~/behaviors/shortcuts/keybindings'; import { getSelectedFragment } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm'; @@ -36,6 +37,11 @@ export default { required: false, default: false, }, + suggestionStartIndex: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -53,7 +59,9 @@ export default { ].join('\n'); }, mdSuggestion() { - return ['```suggestion:-0+0', `{text}`, '```'].join('\n'); + return [['```', `suggestion:-${this.suggestionStartIndex}+0`].join(''), `{text}`, '```'].join( + '\n', + ); }, isMac() { // Accessing properties using ?. to allow tests to use @@ -116,6 +124,11 @@ export default { .catch(() => {}); }, }, + shortcuts: { + bold: keysFor(BOLD_TEXT), + italic: keysFor(ITALIC_TEXT), + link: keysFor(LINK_TEXT), + }, }; </script> @@ -143,7 +156,7 @@ export default { :button-title=" sprintf(s__('MarkdownEditor|Add bold text (%{modifierKey}B)'), { modifierKey }) " - shortcuts="mod+b" + :shortcuts="$options.shortcuts.bold" icon="bold" /> <toolbar-button @@ -151,7 +164,7 @@ export default { :button-title=" sprintf(s__('MarkdownEditor|Add italic text (%{modifierKey}I)'), { modifierKey }) " - shortcuts="mod+i" + :shortcuts="$options.shortcuts.italic" icon="italic" /> <toolbar-button @@ -208,7 +221,7 @@ export default { :button-title=" sprintf(s__('MarkdownEditor|Add a link (%{modifierKey}K)'), { modifierKey }) " - shortcuts="mod+k" + :shortcuts="$options.shortcuts.link" icon="link" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 7c28e74e256..83b8a6ae562 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -1,13 +1,11 @@ <script> import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ApplySuggestion from './apply_suggestion.vue'; export default { components: { GlIcon, GlButton, GlLoadingIcon, ApplySuggestion }, directives: { 'gl-tooltip': GlTooltipDirective }, - mixins: [glFeatureFlagsMixin()], props: { batchSuggestionsCount: { type: Number, @@ -59,9 +57,6 @@ export default { }; }, computed: { - canBeBatched() { - return Boolean(this.glFeatures.batchSuggestions); - }, isApplying() { return this.isApplyingSingle || this.isApplyingBatch; }, @@ -118,7 +113,7 @@ export default { <gl-loading-icon class="d-flex-center mr-2" /> <span>{{ applyingSuggestionsMessage }}</span> </div> - <div v-else-if="canApply && canBeBatched && isBatched" class="d-flex align-items-center"> + <div v-else-if="canApply && isBatched" class="d-flex align-items-center"> <gl-button class="btn-inverted js-remove-from-batch-btn btn-grouped" :disabled="isApplying" @@ -142,7 +137,7 @@ export default { </div> <div v-else class="d-flex align-items-center"> <gl-button - v-if="suggestionsCount > 1 && canBeBatched && !isDisableButton" + v-if="suggestionsCount > 1 && !isDisableButton" class="btn-inverted js-add-to-batch-btn btn-grouped" data-qa-selector="add_suggestion_batch_button" :disabled="isDisableButton" @@ -152,6 +147,7 @@ export default { </gl-button> <apply-suggestion v-if="isLoggedIn" + v-gl-tooltip.viewport="tooltipMessage" :disabled="isDisableButton" :default-commit-message="defaultCommitMessage" class="gl-ml-3" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 387b100a04f..7393a8791b7 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,13 +1,18 @@ <script> import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui'; +import { isExperimentVariant } from '~/experimentation/utils'; +import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; +import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants'; export default { + inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT, components: { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon, + InviteMembersTrigger, }, props: { markdownDocsPath: { @@ -29,6 +34,9 @@ export default { hasQuickActionsDocsPath() { return this.quickActionsDocsPath !== ''; }, + inviteCommentEnabled() { + return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link'); + }, }, }; </script> @@ -37,9 +45,9 @@ export default { <div class="comment-toolbar clearfix"> <div class="toolbar-text"> <template v-if="!hasQuickActionsDocsPath && markdownDocsPath"> - <gl-link :href="markdownDocsPath" target="_blank">{{ - __('Markdown is supported') - }}</gl-link> + <gl-link :href="markdownDocsPath" target="_blank"> + {{ __('Markdown is supported') }} + </gl-link> </template> <template v-if="hasQuickActionsDocsPath && markdownDocsPath"> <gl-sprintf @@ -59,6 +67,16 @@ export default { </template> </div> <span v-if="canAttachFile" class="uploading-container"> + <invite-members-trigger + v-if="inviteCommentEnabled" + classes="gl-mr-3 gl-vertical-align-text-bottom" + :display-text="s__('InviteMember|Invite Member')" + icon="assignee" + variant="link" + :track-experiment="$options.inviteMembersInComment" + :trigger-source="$options.inviteMembersInComment" + data-track-event="comment_invite_click" + /> <span class="uploading-progress-container hide"> <gl-icon name="media" /> <span class="attaching-file-message"></span> diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index 7b36d57dfbf..38afd56bae6 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -101,6 +101,7 @@ export default { :data-clipboard-target="target" :data-clipboard-text="text" :title="title" + :aria-label="title" :category="category" icon="copy-to-clipboard" /> 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 50972a8c32c..149909d263e 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -28,6 +28,7 @@ import { import $ from 'jquery'; import { mapGetters, mapActions, mapState } from 'vuex'; import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; +import { __ } from '~/locale'; import initMRPopovers from '~/mr_popover/'; import noteHeader from '~/notes/components/note_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -37,6 +38,9 @@ import TimelineEntryItem from './timeline_entry_item.vue'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; export default { + i18n: { + deleteButtonLabel: __('Remove description history'), + }, name: 'SystemNote', components: { GlIcon, @@ -139,7 +143,8 @@ export default { <gl-button v-if="displayDeleteButton" v-gl-tooltip - :title="__('Remove description history')" + :title="$options.i18n.deleteButtonLabel" + :aria-label="$options.i18n.deleteButtonLabel" variant="default" category="tertiary" icon="remove" diff --git a/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue new file mode 100644 index 00000000000..ff2847624c5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/oncall_schedules_list.vue @@ -0,0 +1,70 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; + +export default { + components: { + GlSprintf, + GlLink, + }, + props: { + schedules: { + type: Array, + required: true, + }, + userName: { + type: String, + required: false, + default: null, + }, + isCurrentUser: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + title() { + return this.isCurrentUser + ? s__('OnCallSchedules|You are currently a part of:') + : sprintf(s__('OnCallSchedules|User %{name} is currently part of:'), { + name: this.userName, + }); + }, + footer() { + return this.isCurrentUser + ? s__( + 'OnCallSchedules|Removing yourself may put your on-call team at risk of missing a notification.', + ) + : s__( + 'OnCallSchedules|Removing this user may put their on-call team at risk of missing a notification.', + ); + }, + }, +}; +</script> + +<template> + <div> + <p data-testid="title">{{ title }}</p> + + <ul data-testid="schedules-list"> + <li v-for="(schedule, index) in schedules" :key="`${schedule.name}-${index}`"> + <gl-sprintf + :message="s__('OnCallSchedules|On-call schedule %{schedule} in Project %{project}')" + > + <template #schedule> + <gl-link :href="schedule.scheduleUrl" target="_blank">{{ schedule.name }}</gl-link> + </template> + <template #project> + <gl-link :href="schedule.projectUrl" target="_blank">{{ + schedule.projectName + }}</gl-link> + </template> + </gl-sprintf> + </li> + </ul> + + <p data-testid="footer">{{ footer }}</p> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js deleted file mode 100644 index e193883b6e9..00000000000 --- a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js +++ /dev/null @@ -1,21 +0,0 @@ -import createEventHub from '~/helpers/event_hub_factory'; - -// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml -export const callbackName = 'recaptchaDialogCallback'; - -export const eventHub = createEventHub(); - -const throwDuplicateCallbackError = () => { - throw new Error(`${callbackName} is already defined!`); -}; - -if (window[callbackName]) { - throwDuplicateCallbackError(); -} - -const callback = () => eventHub.$emit('submit'); - -Object.defineProperty(window, callbackName, { - get: () => callback, - set: throwDuplicateCallbackError, -}); diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue deleted file mode 100644 index fc1f3675a3d..00000000000 --- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue +++ /dev/null @@ -1,90 +0,0 @@ -<script> -/* eslint-disable vue/no-v-html */ -import DeprecatedModal from './deprecated_modal.vue'; -import { eventHub } from './recaptcha_eventhub'; - -export default { - name: 'RecaptchaModal', - - components: { - DeprecatedModal, - }, - - props: { - html: { - type: String, - required: false, - default: '', - }, - }, - - data() { - return { - script: {}, - scriptSrc: 'https://www.recaptcha.net/recaptcha/api.js', - }; - }, - - watch: { - html() { - this.appendRecaptchaScript(); - }, - }, - - mounted() { - eventHub.$on('submit', this.submit); - - if (this.html) { - this.appendRecaptchaScript(); - } - }, - - beforeDestroy() { - eventHub.$off('submit', this.submit); - }, - - methods: { - appendRecaptchaScript() { - this.removeRecaptchaScript(); - - const script = document.createElement('script'); - script.src = this.scriptSrc; - script.classList.add('js-recaptcha-script'); - script.async = true; - script.defer = true; - - this.script = script; - - document.body.appendChild(script); - }, - - removeRecaptchaScript() { - if (this.script instanceof Element) this.script.remove(); - }, - - close() { - this.removeRecaptchaScript(); - this.$emit('close'); - }, - - submit() { - this.$el.querySelector('form').submit(); - }, - }, -}; -</script> - -<template> - <deprecated-modal - :hide-footer="true" - :title="__('Please solve the reCAPTCHA')" - kind="warning" - class="recaptcha-modal js-recaptcha-modal" - @cancel="close" - > - <div slot="body"> - <p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p> - <div ref="recaptcha" v-html="html"></div> - </div> - </deprecated-modal> -</template> 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 62453a25f62..0825c3a76ea 100644 --- a/app/assets/javascripts/vue_shared/components/registry/registry_search.vue +++ b/app/assets/javascripts/vue_shared/components/registry/registry_search.vue @@ -1,5 +1,6 @@ <script> import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; const ASCENDING_ORDER = 'asc'; const DESCENDING_ORDER = 'desc'; @@ -45,18 +46,60 @@ export default { isSortAscending() { return this.sorting.sort === ASCENDING_ORDER; }, + baselineQueryStringFilters() { + return this.tokens.reduce((acc, curr) => { + acc[curr.type] = ''; + return acc; + }, {}); + }, }, methods: { + generateQueryData({ sorting = {}, filter = [] } = {}) { + // Ensure that we clean up the query when we remove a token from the search + const result = { ...this.baselineQueryStringFilters, ...sorting, search: [] }; + + filter.forEach((f) => { + if (f.type === FILTERED_SEARCH_TERM) { + result.search.push(f.value.data); + } else { + result[f.type] = f.value.data; + } + }); + return result; + }, onDirectionChange() { const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER; + const newQueryString = this.generateQueryData({ + sorting: { ...this.sorting, sort }, + filter: this.filter, + }); this.$emit('sorting:changed', { sort }); + this.$emit('query:changed', newQueryString); }, onSortItemClick(item) { + const newQueryString = this.generateQueryData({ + sorting: { ...this.sorting, orderBy: item }, + filter: this.filter, + }); this.$emit('sorting:changed', { orderBy: item }); + this.$emit('query:changed', newQueryString); + }, + submitSearch() { + const newQueryString = this.generateQueryData({ + sorting: this.sorting, + filter: this.filter, + }); + this.$emit('filter:submit'); + this.$emit('query:changed', newQueryString); }, clearSearch() { + const newQueryString = this.generateQueryData({ + sorting: this.sorting, + }); + this.$emit('filter:changed', []); this.$emit('filter:submit'); + this.$emit('query:changed', newQueryString); }, }, }; @@ -69,7 +112,7 @@ export default { class="gl-mr-4 gl-flex-fill-1" :placeholder="__('Filter results')" :available-tokens="tokens" - @submit="$emit('filter:submit')" + @submit="submitSearch" @clear="clearSearch" /> <gl-sorting diff --git a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue index 88d1b15aee3..dff3a6a8c3f 100644 --- a/app/assets/javascripts/vue_shared/components/remove_member_modal.vue +++ b/app/assets/javascripts/vue_shared/components/remove_member_modal.vue @@ -1,8 +1,10 @@ <script> import { GlFormCheckbox, GlModal } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { parseBoolean } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; -import { __ } from '~/locale'; +import { s__, __ } from '~/locale'; +import OncallSchedulesList from '~/vue_shared/components/oncall_schedules_list.vue'; export default { actionCancel: { @@ -12,6 +14,7 @@ export default { components: { GlFormCheckbox, GlModal, + OncallSchedulesList, }, data() { return { @@ -22,8 +25,20 @@ export default { isAccessRequest() { return parseBoolean(this.modalData.isAccessRequest); }, + isInvite() { + return parseBoolean(this.modalData.isInvite); + }, + isGroupMember() { + return this.modalData.memberType === 'GroupMember'; + }, actionText() { - return this.isAccessRequest ? __('Deny access request') : __('Remove member'); + if (this.isAccessRequest) { + return __('Deny access request'); + } else if (this.isInvite) { + return s__('Member|Revoke invite'); + } + + return __('Remove member'); }, actionPrimary() { return { @@ -33,6 +48,21 @@ export default { }, }; }, + showUnassignIssuablesCheckbox() { + return !this.isAccessRequest && !this.isInvite; + }, + isPartOfOncallSchedules() { + return !this.isAccessRequest && this.oncallSchedules.schedules?.length; + }, + oncallSchedules() { + let schedules = {}; + try { + schedules = JSON.parse(this.modalData.oncallSchedules); + } catch (e) { + Sentry.captureException(e); + } + return schedules; + }, }, mounted() { document.addEventListener('click', this.handleClick); @@ -68,9 +98,18 @@ export default { <form ref="form" :action="modalData.memberPath" method="post"> <p data-testid="modal-message">{{ modalData.message }}</p> + <oncall-schedules-list + v-if="isPartOfOncallSchedules" + :schedules="oncallSchedules.schedules" + :user-name="oncallSchedules.name" + /> + <input ref="method" type="hidden" name="_method" value="delete" /> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - <gl-form-checkbox v-if="!isAccessRequest" name="unassign_issuables"> + <gl-form-checkbox v-if="isGroupMember" name="remove_sub_memberships"> + {{ __('Also remove direct user membership from subgroups and projects') }} + </gl-form-checkbox> + <gl-form-checkbox v-if="showUnassignIssuablesCheckbox" name="unassign_issuables"> {{ __('Also unassign this user from related issues and merge requests') }} </gl-form-checkbox> </form> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue index 4271f6053ed..85a67c087bb 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue @@ -21,7 +21,11 @@ export default { }; </script> <template> - <button v-gl-tooltip="{ title: tooltip }" class="p-0 gl-display-flex toolbar-button"> + <button + v-gl-tooltip="{ title: tooltip }" + :aria-label="tooltip" + class="p-0 gl-display-flex toolbar-button" + > <gl-icon class="gl-mx-auto gl-align-self-center" :name="icon" /> </button> </template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql index ff0626167a9..76f152e5453 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql @@ -1,4 +1,4 @@ -query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) { +query getRunnerPlatforms { runnerPlatforms { nodes { name @@ -11,10 +11,4 @@ query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) { } } } - project(fullPath: $projectPath) { - id - } - group(fullPath: $groupPath) { - id - } } diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql index 643c1991807..c0248a35e3f 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql @@ -1,15 +1,5 @@ -query runnerSetupInstructions( - $platform: String! - $architecture: String! - $projectId: ID! - $groupId: ID! -) { - runnerSetup( - platform: $platform - architecture: $architecture - projectId: $projectId - groupId: $groupId - ) { +query runnerSetupInstructions($platform: String!, $architecture: String!) { + runnerSetup(platform: $platform, architecture: $architecture) { installInstructions registerInstructions } diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue index 1d6db576942..d886a67fff7 100644 --- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue @@ -1,155 +1,31 @@ <script> -import { - GlAlert, - GlButton, - GlModal, - GlModalDirective, - GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlIcon, -} from '@gitlab/ui'; -import { __, s__ } from '~/locale'; -import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; -import { - PLATFORMS_WITHOUT_ARCHITECTURES, - INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES, -} from './constants'; -import getRunnerPlatforms from './graphql/queries/get_runner_platforms.query.graphql'; -import getRunnerSetupInstructions from './graphql/queries/get_runner_setup.query.graphql'; +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import RunnerInstructionsModal from './runner_instructions_modal.vue'; export default { components: { - GlAlert, GlButton, - GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlModal, - GlIcon, - ModalCopyButton, + RunnerInstructionsModal, }, directives: { GlModalDirective, }, - inject: { - projectPath: { - default: '', - }, - groupPath: { - default: '', - }, - }, - apollo: { - runnerPlatforms: { - query: getRunnerPlatforms, - variables() { - return { - projectPath: this.projectPath, - groupPath: this.groupPath, - }; - }, - error() { - this.showAlert = true; - }, - result({ data }) { - this.project = data?.project; - this.group = data?.group; - - this.selectPlatform(this.platforms[0].name); - }, - }, + modalId: 'runner-instructions-modal', + i18n: { + buttonText: s__('Runners|Show Runner installation instructions'), }, data() { return { - showAlert: false, - selectedPlatformArchitectures: [], - selectedPlatform: { - name: '', - }, - selectedArchitecture: {}, - runnerPlatforms: {}, - instructions: {}, - project: {}, - group: {}, + opened: false, }; }, - computed: { - isPlatformSelected() { - return Object.keys(this.selectedPlatform).length > 0; - }, - instructionsEmpty() { - return Object.keys(this.instructions).length === 0; - }, - groupId() { - return this.group?.id ?? ''; - }, - projectId() { - return this.project?.id ?? ''; - }, - platforms() { - return this.runnerPlatforms?.nodes; - }, - hasArchitecureList() { - return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatform?.name); - }, - instructionsWithoutArchitecture() { - return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.instructions; - }, - runnerInstallationLink() { - return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatform.name]?.link; - }, - }, methods: { - selectPlatform(name) { - this.selectedPlatform = this.platforms.find((platform) => platform.name === name); - if (this.hasArchitecureList) { - this.selectedPlatformArchitectures = this.selectedPlatform?.architectures?.nodes; - [this.selectedArchitecture] = this.selectedPlatformArchitectures; - this.selectArchitecture(this.selectedArchitecture); - } - }, - selectArchitecture(architecture) { - this.selectedArchitecture = architecture; - - this.$apollo.addSmartQuery('instructions', { - variables() { - return { - platform: this.selectedPlatform.name, - architecture: this.selectedArchitecture.name, - projectId: this.projectId, - groupId: this.groupId, - }; - }, - query: getRunnerSetupInstructions, - update(data) { - return data?.runnerSetup; - }, - error() { - this.showAlert = true; - }, - }); - }, - toggleAlert(state) { - this.showAlert = state; + onClick() { + // lazily mount modal to prevent premature instructions requests + this.opened = true; }, }, - modalId: 'installation-instructions-modal', - i18n: { - installARunner: s__('Runners|Install a Runner'), - architecture: s__('Runners|Architecture'), - downloadInstallBinary: s__('Runners|Download and Install Binary'), - downloadLatestBinary: s__('Runners|Download Latest Binary'), - registerRunner: s__('Runners|Register Runner'), - method: __('Method'), - fetchError: s__('Runners|An error has occurred fetching instructions'), - instructions: s__('Runners|Show Runner installation instructions'), - copyInstructions: s__('Runners|Copy instructions'), - }, - closeButton: { - text: __('Close'), - attributes: [{ variant: 'default' }], - }, }; </script> <template> @@ -158,104 +34,10 @@ export default { v-gl-modal-directive="$options.modalId" class="gl-mt-4" data-testid="show-modal-button" + @click="onClick" > - {{ $options.i18n.instructions }} + {{ $options.i18n.buttonText }} </gl-button> - <gl-modal - :modal-id="$options.modalId" - :title="$options.i18n.installARunner" - :action-secondary="$options.closeButton" - > - <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)"> - {{ $options.i18n.fetchError }} - </gl-alert> - <h5>{{ __('Environment') }}</h5> - <gl-button-group class="gl-mb-5"> - <gl-button - v-for="platform in platforms" - :key="platform.name" - data-testid="platform-button" - @click="selectPlatform(platform.name)" - > - {{ platform.humanReadableName }} - </gl-button> - </gl-button-group> - <template v-if="hasArchitecureList"> - <template v-if="isPlatformSelected"> - <h5> - {{ $options.i18n.architecture }} - </h5> - <gl-dropdown class="gl-mb-5" :text="selectedArchitecture.name"> - <gl-dropdown-item - v-for="architecture in selectedPlatformArchitectures" - :key="architecture.name" - data-testid="architecture-dropdown-item" - @click="selectArchitecture(architecture)" - > - {{ architecture.name }} - </gl-dropdown-item> - </gl-dropdown> - <div class="gl-display-flex gl-align-items-center gl-mb-5"> - <h5>{{ $options.i18n.downloadInstallBinary }}</h5> - <gl-button - class="gl-ml-auto" - :href="selectedArchitecture.downloadLocation" - download - data-testid="binary-download-button" - > - {{ $options.i18n.downloadLatestBinary }} - </gl-button> - </div> - </template> - <template v-if="!instructionsEmpty"> - <div class="gl-display-flex"> - <pre - class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line" - data-testid="binary-instructions" - > - - {{ instructions.installInstructions }} - </pre - > - <modal-copy-button - :title="$options.i18n.copyInstructions" - :text="instructions.installInstructions" - :modal-id="$options.modalId" - css-classes="gl-align-self-start gl-ml-2 gl-mt-2" - category="tertiary" - /> - </div> - - <hr /> - <h5 class="gl-mb-5">{{ $options.i18n.registerRunner }}</h5> - <h5 class="gl-mb-5">{{ $options.i18n.method }}</h5> - <div class="gl-display-flex"> - <pre - class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line" - data-testid="runner-instructions" - > - {{ instructions.registerInstructions }} - </pre - > - <modal-copy-button - :title="$options.i18n.copyInstructions" - :text="instructions.registerInstructions" - :modal-id="$options.modalId" - css-classes="gl-align-self-start gl-ml-2 gl-mt-2" - category="tertiary" - /> - </div> - </template> - </template> - <template v-else> - <div> - <p>{{ instructionsWithoutArchitecture }}</p> - <gl-button :href="runnerInstallationLink"> - <gl-icon name="external-link" /> - {{ s__('Runners|View installation instructions') }} - </gl-button> - </div> - </template> - </gl-modal> + <runner-instructions-modal v-if="opened" :modal-id="$options.modalId" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue new file mode 100644 index 00000000000..795b4f58ac5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions_modal.vue @@ -0,0 +1,249 @@ +<script> +import { + GlAlert, + GlButton, + GlModal, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlIcon, + GlLoadingIcon, + GlSkeletonLoader, +} from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { __, s__ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { + PLATFORMS_WITHOUT_ARCHITECTURES, + INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES, +} from './constants'; +import getRunnerPlatformsQuery from './graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructionsQuery from './graphql/queries/get_runner_setup.query.graphql'; + +export default { + components: { + GlAlert, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlModal, + GlIcon, + GlLoadingIcon, + GlSkeletonLoader, + ModalCopyButton, + }, + props: { + modalId: { + type: String, + required: true, + }, + }, + apollo: { + platforms: { + query: getRunnerPlatformsQuery, + update(data) { + return data?.runnerPlatforms?.nodes.map(({ name, humanReadableName, architectures }) => { + return { + name, + humanReadableName, + architectures: architectures?.nodes || [], + }; + }); + }, + result() { + // Select first platform by default + if (this.platforms?.[0]) { + this.selectPlatform(this.platforms[0]); + } + }, + error() { + this.toggleAlert(true); + }, + }, + instructions: { + query: getRunnerSetupInstructionsQuery, + skip() { + return !this.selectedPlatform; + }, + variables() { + return { + platform: this.selectedPlatformName, + architecture: this.selectedArchitectureName || '', + }; + }, + update(data) { + return data?.runnerSetup; + }, + error() { + this.toggleAlert(true); + }, + }, + }, + data() { + return { + platforms: [], + selectedPlatform: null, + selectedArchitecture: null, + showAlert: false, + instructions: {}, + }; + }, + computed: { + platformsEmpty() { + return isEmpty(this.platforms); + }, + instructionsEmpty() { + return isEmpty(this.instructions); + }, + selectedPlatformName() { + return this.selectedPlatform?.name; + }, + selectedArchitectureName() { + return this.selectedArchitecture?.name; + }, + hasArchitecureList() { + return !PLATFORMS_WITHOUT_ARCHITECTURES.includes(this.selectedPlatformName); + }, + instructionsWithoutArchitecture() { + return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.instructions; + }, + runnerInstallationLink() { + return INSTRUCTIONS_PLATFORMS_WITHOUT_ARCHITECTURES[this.selectedPlatformName]?.link; + }, + }, + methods: { + selectPlatform(platform) { + this.selectedPlatform = platform; + + if (!platform.architectures?.some(({ name }) => name === this.selectedArchitectureName)) { + // Select first architecture when current value is not available + this.selectArchitecture(platform.architectures[0]); + } + }, + selectArchitecture(architecture) { + this.selectedArchitecture = architecture; + }, + toggleAlert(state) { + this.showAlert = state; + }, + }, + i18n: { + installARunner: s__('Runners|Install a runner'), + architecture: s__('Runners|Architecture'), + downloadInstallBinary: s__('Runners|Download and install binary'), + downloadLatestBinary: s__('Runners|Download latest binary'), + registerRunnerCommand: s__('Runners|Command to register runner'), + fetchError: s__('Runners|An error has occurred fetching instructions'), + copyInstructions: s__('Runners|Copy instructions'), + }, + closeButton: { + text: __('Close'), + attributes: [{ variant: 'default' }], + }, +}; +</script> +<template> + <gl-modal + :modal-id="modalId" + :title="$options.i18n.installARunner" + :action-secondary="$options.closeButton" + > + <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)"> + {{ $options.i18n.fetchError }} + </gl-alert> + + <gl-skeleton-loader v-if="platformsEmpty && $apollo.loading" /> + + <template v-if="!platformsEmpty"> + <h5> + {{ __('Environment') }} + </h5> + <gl-button-group class="gl-mb-3"> + <gl-button + v-for="platform in platforms" + :key="platform.name" + :selected="selectedPlatform && selectedPlatform.name === platform.name" + data-testid="platform-button" + @click="selectPlatform(platform)" + > + {{ platform.humanReadableName }} + </gl-button> + </gl-button-group> + </template> + <template v-if="hasArchitecureList"> + <template v-if="selectedPlatform"> + <h5> + {{ $options.i18n.architecture }} + <gl-loading-icon v-if="$apollo.loading" inline /> + </h5> + + <gl-dropdown class="gl-mb-3" :text="selectedArchitectureName"> + <gl-dropdown-item + v-for="architecture in selectedPlatform.architectures" + :key="architecture.name" + :is-check-item="true" + :is-checked="selectedArchitectureName === architecture.name" + data-testid="architecture-dropdown-item" + @click="selectArchitecture(architecture)" + > + {{ architecture.name }} + </gl-dropdown-item> + </gl-dropdown> + <div class="gl-display-flex gl-align-items-center gl-mb-3"> + <h5>{{ $options.i18n.downloadInstallBinary }}</h5> + <gl-button + class="gl-ml-auto" + :href="selectedArchitecture.downloadLocation" + download + icon="download" + data-testid="binary-download-button" + > + {{ $options.i18n.downloadLatestBinary }} + </gl-button> + </div> + </template> + <template v-if="!instructionsEmpty"> + <div class="gl-display-flex"> + <pre + class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line" + data-testid="binary-instructions" + >{{ instructions.installInstructions }}</pre + > + <modal-copy-button + :title="$options.i18n.copyInstructions" + :text="instructions.installInstructions" + :modal-id="$options.modalId" + css-classes="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + /> + </div> + + <h5 class="gl-mb-3">{{ $options.i18n.registerRunnerCommand }}</h5> + <div class="gl-display-flex"> + <pre + class="gl-bg-gray gl-flex-fill-1 gl-white-space-pre-line" + data-testid="register-command" + >{{ instructions.registerInstructions }}</pre + > + <modal-copy-button + :title="$options.i18n.copyInstructions" + :text="instructions.registerInstructions" + :modal-id="$options.modalId" + css-classes="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + /> + </div> + </template> + </template> + <template v-else> + <div> + <p>{{ instructionsWithoutArchitecture }}</p> + <gl-button :href="runnerInstallationLink"> + <gl-icon name="external-link" /> + {{ s__('Runners|View installation instructions') }} + </gl-button> + </div> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue new file mode 100644 index 00000000000..bbc7e6e7a6e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/copyable_field.vue @@ -0,0 +1,88 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +/** + * Renders an inline field, whose value can be copied to the clipboard, + * for use in the GitLab sidebar (issues, MRs, etc.). + */ +export default { + name: 'CopyableField', + components: { + GlLoadingIcon, + ClipboardButton, + }, + props: { + value: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + clipboardTooltipText: { + type: String, + required: false, + default: undefined, + }, + }, + computed: { + clipboardProps() { + return { + category: 'tertiary', + tooltipBoundary: 'viewport', + tooltipPlacement: 'left', + text: this.value, + title: + this.clipboardTooltipText || + sprintf(this.$options.i18n.clipboardTooltip, { name: this.name }), + }; + }, + loadingIconLabel() { + return sprintf(this.$options.i18n.loadingIconLabel, { name: this.name }); + }, + templateText() { + return sprintf(this.$options.i18n.templateText, { + name: this.name, + value: this.value, + }); + }, + }, + i18n: { + loadingIconLabel: __('Loading %{name}'), + clipboardTooltip: __('Copy %{name}'), + templateText: s__('Sidebar|%{name}: %{value}'), + }, +}; +</script> + +<template> + <div> + <clipboard-button + v-if="!isLoading" + css-class="sidebar-collapsed-icon dont-change-state gl-rounded-0! gl-hover-bg-transparent" + v-bind="clipboardProps" + /> + + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between hide-collapsed" + > + <span + class="gl-overflow-hidden gl-text-overflow-ellipsis gl-white-space-nowrap" + :title="value" + > + {{ templateText }} + </span> + + <gl-loading-icon v-if="isLoading" inline :label="loadingIconLabel" /> + <clipboard-button v-else size="small" v-bind="clipboardProps" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue index 1d3bd312b09..320e2048f1c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue @@ -164,6 +164,7 @@ export default { variant="link" icon="close" class="gl-mr-2 gl-w-auto! gl-p-2!" + :aria-label="__('Close')" @click.prevent="handleDropdownCloseClick" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 426ae430ce7..f547433f322 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -172,9 +172,11 @@ export default { after: this.handleVuexActionDispatch, }); + document.addEventListener('mousedown', this.handleDocumentMousedown); document.addEventListener('click', this.handleDocumentClick); }, beforeDestroy() { + document.removeEventListener('mousedown', this.handleDocumentMousedown); document.removeEventListener('click', this.handleDocumentClick); }, methods: { @@ -197,11 +199,36 @@ export default { } }, /** + * This method stores a mousedown event's target. + * Required by the click listener because the click + * event itself has no reference to this element. + */ + handleDocumentMousedown({ target }) { + this.mousedownTarget = target; + }, + /** * This method listens for document-wide click event * and toggle dropdown if user clicks anywhere outside * the dropdown while dropdown is visible. */ handleDocumentClick({ target }) { + // We also perform the toggle exception check for the + // last mousedown event's target to avoid hiding the + // box when the mousedown happened inside the box and + // only the mouseup did not. + if ( + this.showDropdownContents && + !this.preventDropdownToggleOnClick(target) && + !this.preventDropdownToggleOnClick(this.mousedownTarget) + ) { + this.toggleDropdownContents(); + } + }, + /** + * This method checks whether a given click target + * should prevent the dropdown from being toggled. + */ + preventDropdownToggleOnClick(target) { // This approach of element detection is needed // as the dropdown wrapper is not using `GlDropdown` as // it will also require us to use `BDropdownForm` @@ -216,19 +243,20 @@ export default { target?.parentElement?.classList.contains(className), ); - const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some( + const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some( (className) => $(target).parents(className).length, ); - if ( - this.showDropdownContents && - !hadExceptionParent && - !hasExceptionClass && - !this.$refs.dropdownButtonCollapsed?.$el.contains(target) && - !this.$refs.dropdownContents?.$el.contains(target) - ) { - this.toggleDropdownContents(); - } + const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target); + + const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target); + + return ( + hasExceptionClass || + hasExceptionParent || + isInDropdownButtonCollapsed || + isInDropdownContents + ); }, handleDropdownClose(labels) { // Only emit label updates if there are any labels to update diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue index ef5f052527b..17904f20341 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue @@ -30,5 +30,8 @@ export default { <gl-dropdown-form> <slot name="items"></slot> </gl-dropdown-form> + <template #footer> + <slot name="footer"></slot> + </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 459ea27e9cd..3885127fa8e 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" query issueParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { @@ -9,11 +10,13 @@ query issueParticipants($fullPath: ID!, $iid: String!) { participants { nodes { ...User + ...UserAvailability } } assignees { nodes { ...User + ...UserAvailability } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql index 43bd9f17e9a..63482873b69 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" query getMrParticipants($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { @@ -7,11 +8,13 @@ query getMrParticipants($fullPath: ID!, $iid: String!) { participants { nodes { ...User + ...UserAvailability } } assignees { nodes { ...User + ...UserAvailability } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql index 8ee8de2cb5c..3f40c0368d7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { issuableSetAssignees: issueSetAssignees( @@ -9,11 +10,13 @@ mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullP assignees { nodes { ...User + ...UserAvailability } } participants { nodes { ...User + ...UserAvailability } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql index a0f15a07692..77140ea36d8 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql @@ -1,4 +1,5 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { mergeRequestSetAssignees( @@ -9,11 +10,13 @@ mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, assignees { nodes { ...User + ...UserAvailability } } participants { nodes { ...User + ...UserAvailability } } } diff --git a/app/assets/javascripts/vue_shared/components/url_sync.vue b/app/assets/javascripts/vue_shared/components/url_sync.vue index 2844d9e9e94..925c6008836 100644 --- a/app/assets/javascripts/vue_shared/components/url_sync.vue +++ b/app/assets/javascripts/vue_shared/components/url_sync.vue @@ -2,11 +2,18 @@ import { historyPushState } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; +/** + * Renderless component to update the query string, + * the update is done by updating the query property or + * by using updateQuery method in the scoped slot. + * note: do not use both prop and updateQuery method. + */ export default { props: { query: { type: Object, - required: true, + required: false, + default: null, }, }, watch: { @@ -14,12 +21,19 @@ export default { immediate: true, deep: true, handler(newQuery) { - historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true })); + if (newQuery) { + this.updateQuery(newQuery); + } }, }, }, + methods: { + updateQuery(newQuery) { + historyPushState(mergeUrlParams(newQuery, window.location.href, { spreadArrays: true })); + }, + }, render() { - return this.$slots.default; + return this.$scopedSlots.default?.({ updateQuery: this.updateQuery }); }, }; </script> diff --git a/app/assets/javascripts/admin/users/components/user_date.vue b/app/assets/javascripts/vue_shared/components/user_date.vue index 38dddbf72c2..38dddbf72c2 100644 --- a/app/assets/javascripts/admin/users/components/user_date.vue +++ b/app/assets/javascripts/vue_shared/components/user_date.vue 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 dbd8efec948..11f484b2cdf 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 @@ -1,11 +1,6 @@ <script> /* eslint-disable vue/no-v-html */ -import { - GlPopover, - GlLink, - GlDeprecatedSkeletonLoading as GlSkeletonLoading, - GlIcon, -} from '@gitlab/ui'; +import { GlPopover, GlLink, GlSkeletonLoader, GlIcon } from '@gitlab/ui'; import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue'; import { glEmojiTag } from '../../../emoji'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; @@ -19,7 +14,7 @@ export default { GlIcon, GlLink, GlPopover, - GlSkeletonLoading, + GlSkeletonLoader, UserAvatarImage, UserNameWithStatus, }, @@ -60,20 +55,18 @@ export default { <template> <!-- 200ms delay so not every mouseover triggers Popover --> - <gl-popover :target="target" :delay="200" boundary="viewport" triggers="hover" placement="top"> + <gl-popover :target="target" :delay="200" boundary="viewport" placement="top"> <div class="gl-p-3 gl-line-height-normal gl-display-flex" data-testid="user-popover"> <div class="gl-p-2 flex-shrink-1"> <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="gl-mr-3!" /> </div> - <div class="gl-p-2 gl-w-full"> + <div class="gl-p-2 gl-w-full gl-min-w-0"> <template v-if="userIsLoading"> - <!-- `gl-skeleton-loading` does not support equal length lines --> - <!-- This can be migrated to `gl-skeleton-loader` when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/872 is completed --> - <gl-skeleton-loading - v-for="n in $options.maxSkeletonLines" - :key="n" - :lines="1" - class="animation-container-small gl-mb-2" + <gl-skeleton-loader + :lines="$options.maxSkeletonLines" + preserve-aspect-ratio="none" + equal-width-lines + :height="52" /> </template> <template v-else> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 5262a15136b..9a5ad195de9 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -8,6 +8,8 @@ const INTERVALS = { export const FILE_SYMLINK_MODE = '120000'; +export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; + export const timeRanges = [ { label: __('30 minutes'), diff --git a/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js b/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js deleted file mode 100644 index ff1f565e79a..00000000000 --- a/app/assets/javascripts/vue_shared/mixins/recaptcha_modal_implementor.js +++ /dev/null @@ -1,36 +0,0 @@ -import recaptchaModal from '../components/recaptcha_modal.vue'; - -export default { - data() { - return { - showRecaptcha: false, - recaptchaHTML: '', - }; - }, - - components: { - recaptchaModal, - }, - - methods: { - openRecaptcha() { - this.showRecaptcha = true; - }, - - closeRecaptcha() { - this.showRecaptcha = false; - }, - - checkForSpam(data) { - if (!data.recaptcha_html) return data; - - this.recaptchaHTML = data.recaptcha_html; - - const spamError = new Error(data.error_message); - spamError.name = 'SpamError'; - spamError.message = 'SpamError'; - - throw spamError; - }, - }, -}; diff --git a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql index 310d8d88904..4ce13827da2 100644 --- a/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql +++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql @@ -6,6 +6,7 @@ query securityReportDownloadPaths( project(fullPath: $projectPath) { mergeRequest(iid: $iid) { headPipeline { + id jobs(securityReportTypes: $reportTypes) { nodes { name 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 b27dd33835f..1151cffa76f 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 @@ -184,6 +184,7 @@ export default { :has-issues="false" class="mr-widget-border-top mr-report" data-testid="security-mr-widget" + track-action="users_expanding_secure_security_report" > <template v-for="slot in $options.summarySlots" #[slot]> <span :key="slot"> @@ -212,6 +213,7 @@ export default { :has-issues="false" class="mr-widget-border-top mr-report" data-testid="security-mr-widget" + track-action="users_expanding_secure_security_report" > <template #error> {{ $options.i18n.scansHaveRun }} diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index f4ac4f81eac..4a387edbe3f 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -1,13 +1,5 @@ <script> -import { - GlDrawer, - GlInfiniteScroll, - GlResizeObserverDirective, - GlTabs, - GlTab, - GlBadge, - GlLoadingIcon, -} from '@gitlab/ui'; +import { GlDrawer, GlInfiniteScroll, GlResizeObserverDirective } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import Tracking from '~/tracking'; import { getDrawerBodyHeight } from '../utils/get_drawer_body_height'; @@ -20,37 +12,24 @@ export default { components: { GlDrawer, GlInfiniteScroll, - GlTabs, - GlTab, SkeletonLoader, Feature, - GlBadge, - GlLoadingIcon, }, directives: { GlResizeObserver: GlResizeObserverDirective, }, mixins: [trackingMixin], props: { - storageKey: { + versionDigest: { type: String, required: true, }, - versions: { - type: Array, - required: true, - }, - gitlabDotCom: { - type: Boolean, - required: false, - default: false, - }, }, computed: { ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']), }, mounted() { - this.openDrawer(this.storageKey); + this.openDrawer(this.versionDigest); this.fetchItems(); const body = document.querySelector('body'); @@ -70,16 +49,6 @@ export default { const height = getDrawerBodyHeight(this.$refs.drawer.$el); this.setDrawerBodyHeight(height); }, - featuresForVersion(version) { - return this.features.filter((feature) => { - return feature.release === parseFloat(version); - }); - }, - fetchVersion(version) { - if (this.featuresForVersion(version).length === 0) { - this.fetchItems({ version }); - } - }, }, }; </script> @@ -99,7 +68,6 @@ export default { </template> <template v-if="features.length"> <gl-infinite-scroll - v-if="gitlabDotCom" :fetched-items="features.length" :max-list-height="drawerBodyHeight" class="gl-p-0" @@ -109,26 +77,6 @@ export default { <feature v-for="feature in features" :key="feature.title" :feature="feature" /> </template> </gl-infinite-scroll> - <gl-tabs v-else :style="{ height: `${drawerBodyHeight}px` }" class="gl-p-0"> - <gl-tab - v-for="(version, index) in versions" - :key="version" - @click="fetchVersion(version)" - > - <template #title> - <span>{{ version }}</span> - <gl-badge v-if="index === 0">{{ __('Your Version') }}</gl-badge> - </template> - <gl-loading-icon v-if="fetching" size="lg" class="text-center" /> - <template v-else> - <feature - v-for="feature in featuresForVersion(version)" - :key="feature.title" - :feature="feature" - /> - </template> - </gl-tab> - </gl-tabs> </template> <div v-else class="gl-mt-5"> <skeleton-loader /> diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js index 6da141cb19a..3ac3a3a3611 100644 --- a/app/assets/javascripts/whats_new/index.js +++ b/app/assets/javascripts/whats_new/index.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { mapState } from 'vuex'; import App from './components/app.vue'; import store from './store'; -import { getStorageKey, setNotification } from './utils/notification'; +import { getVersionDigest, setNotification } from './utils/notification'; let whatsNewApp; @@ -27,9 +27,7 @@ export default (el) => { render(createElement) { return createElement('app', { props: { - storageKey: getStorageKey(el), - versions: JSON.parse(el.getAttribute('data-versions')), - gitlabDotCom: el.getAttribute('data-gitlab-dot-com'), + versionDigest: getVersionDigest(el), }, }); }, diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js index 4b3cfa55977..1dc92ea2606 100644 --- a/app/assets/javascripts/whats_new/store/actions.js +++ b/app/assets/javascripts/whats_new/store/actions.js @@ -1,19 +1,20 @@ import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { STORAGE_KEY } from '../utils/notification'; import * as types from './mutation_types'; export default { closeDrawer({ commit }) { commit(types.CLOSE_DRAWER); }, - openDrawer({ commit }, storageKey) { + openDrawer({ commit }, versionDigest) { commit(types.OPEN_DRAWER); - if (storageKey) { - localStorage.setItem(storageKey, JSON.stringify(false)); + if (versionDigest) { + localStorage.setItem(STORAGE_KEY, versionDigest); } }, - fetchItems({ commit, state }, { page, version } = { page: null, version: null }) { + fetchItems({ commit, state }, { page } = { page: null }) { if (state.fetching) { return false; } @@ -24,7 +25,6 @@ export default { .get('/-/whats_new', { params: { page, - version, }, }) .then(({ data, headers }) => { diff --git a/app/assets/javascripts/whats_new/utils/notification.js b/app/assets/javascripts/whats_new/utils/notification.js index 52ca8058d1c..3d4326c4b3a 100644 --- a/app/assets/javascripts/whats_new/utils/notification.js +++ b/app/assets/javascripts/whats_new/utils/notification.js @@ -1,11 +1,18 @@ -export const getStorageKey = (appEl) => appEl.getAttribute('data-storage-key'); +export const STORAGE_KEY = 'display-whats-new-notification'; + +export const getVersionDigest = (appEl) => appEl.getAttribute('data-version-digest'); export const setNotification = (appEl) => { - const storageKey = getStorageKey(appEl); + const versionDigest = getVersionDigest(appEl); const notificationEl = document.querySelector('.header-help'); let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count'); - if (JSON.parse(localStorage.getItem(storageKey)) === false) { + const legacyStorageKey = 'display-whats-new-notification-13.10'; + const localStoragePairs = [ + [legacyStorageKey, false], + [STORAGE_KEY, versionDigest], + ]; + if (localStoragePairs.some((pair) => localStorage.getItem(pair[0]) === pair[1].toString())) { notificationEl.classList.remove('with-notifications'); if (notificationCountEl) { notificationCountEl.parentElement.removeChild(notificationCountEl); |