diff options
Diffstat (limited to 'app')
369 files changed, 4347 insertions, 3113 deletions
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 12c0409629f..cf16750dbf8 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -11,7 +11,6 @@ import { GlSprintf, } from '@gitlab/ui'; import { s__, __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import Tracking from '~/tracking'; import { trackAlertIntegrationsViewsOptions, @@ -54,7 +53,6 @@ export default { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, - mixins: [glFeatureFlagsMixin()], props: { integrations: { type: Array, @@ -170,7 +168,7 @@ export default { </template> <template #cell(actions)="{ item }"> - <gl-button-group v-if="glFeatures.httpIntegrationsList" class="gl-ml-3"> + <gl-button-group class="gl-ml-3"> <gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" /> <gl-button v-gl-modal.deleteIntegration diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 3656fc4d7ec..bf2874b6cc7 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -216,8 +216,12 @@ export default { return { name: this.currentIntegration?.name || '', active: this.currentIntegration?.active || false, - token: this.currentIntegration?.token || this.selectedIntegrationType.token, - url: this.currentIntegration?.url || this.selectedIntegrationType.url, + 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 || '', }; }, @@ -246,8 +250,20 @@ export default { canEditPayload() { return this.hasSamplePayload && !this.resetSamplePayloadConfirmed; }, + isResetAuthKeyDisabled() { + return !this.active && !this.integrationForm.token !== ''; + }, isPayloadEditDisabled() { - return !this.active || this.canEditPayload; + return this.glFeatures.multipleHttpIntegrationsCustomMapping + ? !this.active || this.canEditPayload + : !this.active; + }, + isSubmitTestPayloadDisabled() { + return ( + !this.active || + Boolean(this.integrationTestPayload.error) || + this.integrationTestPayload.json === '' + ); }, }, watch: { @@ -257,7 +273,7 @@ export default { } this.selectedIntegration = val.type; this.active = val.active; - if (val.type === typeSet.http) this.getIntegrationMapping(val.id); + if (val.type === typeSet.http && this.showMappingBuilder) this.getIntegrationMapping(val.id); return this.integrationTypeSelect(); }, }, @@ -297,14 +313,8 @@ export default { }); }, submitWithTestPayload() { - return service - .updateTestAlert(this.testAlertPayload) - .then(() => { - this.submit(); - }) - .catch(() => { - this.$emit('test-payload-failure'); - }); + this.$emit('set-test-alert-payload', this.testAlertPayload); + this.submit(); }, submit() { // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 @@ -323,6 +333,7 @@ export default { return this.$emit('update-integration', integrationPayload); } + this.reset(); return this.$emit('create-new-integration', integrationPayload); }, reset() { @@ -539,7 +550,7 @@ export default { </template> </gl-form-input-group> - <gl-button v-gl-modal.authKeyModal :disabled="!active"> + <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled"> {{ $options.i18n.integrationFormSteps.step3.reset }} </gl-button> <gl-modal @@ -642,7 +653,7 @@ export default { <gl-button v-if="!isManagingOpsgenie" data-testid="integration-test-and-submit" - :disabled="Boolean(integrationTestPayload.error)" + :disabled="isSubmitTestPayloadDisabled" category="secondary" variant="success" class="gl-mx-3 js-no-auto-disable" diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue deleted file mode 100644 index 0246315bdc5..00000000000 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue +++ /dev/null @@ -1,494 +0,0 @@ -<script> -import { - GlAlert, - GlButton, - GlForm, - GlFormGroup, - GlFormInput, - GlFormInputGroup, - GlFormTextarea, - GlLink, - GlModal, - GlModalDirective, - GlSprintf, - GlFormSelect, -} from '@gitlab/ui'; -import { debounce } from 'lodash'; -import { doesHashExistInUrl } from '~/lib/utils/url_utility'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import ToggleButton from '~/vue_shared/components/toggle_button.vue'; -import csrf from '~/lib/utils/csrf'; -import service from '../services'; -import { - i18n, - integrationTypes, - JSON_VALIDATE_DELAY, - targetPrometheusUrlPlaceholder, - targetOpsgenieUrlPlaceholder, - sectionHash, -} from '../constants'; -import createFlash, { FLASH_TYPES } from '~/flash'; - -export default { - i18n, - csrf, - targetOpsgenieUrlPlaceholder, - targetPrometheusUrlPlaceholder, - components: { - GlAlert, - GlButton, - GlForm, - GlFormGroup, - GlFormInput, - GlFormInputGroup, - GlFormSelect, - GlFormTextarea, - GlLink, - GlModal, - GlSprintf, - ClipboardButton, - ToggleButton, - }, - directives: { - 'gl-modal': GlModalDirective, - }, - inject: ['prometheus', 'generic', 'opsgenie'], - data() { - return { - loading: false, - selectedIntegration: integrationTypes[0].value, - options: integrationTypes, - active: false, - token: '', - targetUrl: '', - feedback: { - variant: 'danger', - feedbackMessage: '', - isFeedbackDismissed: false, - }, - testAlert: { - json: null, - error: null, - }, - canSaveForm: false, - serverError: null, - }; - }, - computed: { - sections() { - return [ - { - text: this.$options.i18n.usageSection, - url: this.generic.alertsUsageUrl, - }, - { - text: this.$options.i18n.setupSection, - url: this.generic.alertsSetupUrl, - }, - ]; - }, - isPrometheus() { - return this.selectedIntegration === 'PROMETHEUS'; - }, - isOpsgenie() { - return this.selectedIntegration === 'OPSGENIE'; - }, - selectedIntegrationType() { - switch (this.selectedIntegration) { - case 'HTTP': { - return { - url: this.generic.url, - token: this.generic.token, - active: this.generic.active, - resetKey: this.resetKey.bind(this), - }; - } - case 'PROMETHEUS': { - return { - url: this.prometheus.url, - token: this.prometheus.token, - active: this.prometheus.active, - resetKey: this.resetKey.bind(this, 'PROMETHEUS'), - targetUrl: this.prometheus.prometheusApiUrl, - }; - } - case 'OPSGENIE': { - return { - targetUrl: this.opsgenie.opsgenieMvcTargetUrl, - active: this.opsgenie.active, - }; - } - default: { - return {}; - } - } - }, - showFeedbackMsg() { - return this.feedback.feedbackMessage && !this.isFeedbackDismissed; - }, - showAlertSave() { - return ( - this.feedback.feedbackMessage === this.$options.i18n.testAlertFailed && - !this.isFeedbackDismissed - ); - }, - prometheusInfo() { - return this.isPrometheus ? this.$options.i18n.prometheusInfo : ''; - }, - jsonIsValid() { - return this.testAlert.error === null; - }, - canTestAlert() { - return this.active && this.testAlert.json !== null; - }, - canSaveConfig() { - return !this.loading && this.canSaveForm; - }, - baseUrlPlaceholder() { - return this.isOpsgenie - ? this.$options.targetOpsgenieUrlPlaceholder - : this.$options.targetPrometheusUrlPlaceholder; - }, - }, - watch: { - 'testAlert.json': debounce(function debouncedJsonValidate() { - this.validateJson(); - }, JSON_VALIDATE_DELAY), - targetUrl(oldVal, newVal) { - if (newVal && oldVal !== this.selectedIntegrationType.targetUrl) { - this.canSaveForm = true; - } - }, - }, - mounted() { - if (this.prometheus.active || this.generic.active || !this.opsgenie.opsgenieMvcIsAvailable) { - this.removeOpsGenieOption(); - } else if (this.opsgenie.active) { - this.setOpsgenieAsDefault(); - } - this.active = this.selectedIntegrationType.active; - this.token = this.selectedIntegrationType.token ?? ''; - }, - methods: { - createUserErrorMessage(errors = {}) { - const error = Object.entries(errors)?.[0]; - if (error) { - const [field, [msg]] = error; - this.serverError = `${field} ${msg}`; - } - }, - setOpsgenieAsDefault() { - this.options = this.options.map(el => { - if (el.value !== 'OPSGENIE') { - return { ...el, disabled: true }; - } - return { ...el, disabled: false }; - }); - this.selectedIntegration = this.options.find(({ value }) => value === 'OPSGENIE').value; - if (this.targetUrl === null) { - this.targetUrl = this.selectedIntegrationType.targetUrl; - } - }, - removeOpsGenieOption() { - this.options = this.options.map(el => { - if (el.value !== 'OPSGENIE') { - return { ...el, disabled: false }; - } - return { ...el, disabled: true }; - }); - }, - resetFormValues() { - this.testAlert.json = null; - this.targetUrl = this.selectedIntegrationType.targetUrl; - this.active = this.selectedIntegrationType.active; - }, - dismissFeedback() { - this.serverError = null; - this.feedback = { ...this.feedback, feedbackMessage: null }; - this.isFeedbackDismissed = false; - }, - resetKey(key) { - const fn = key === 'PROMETHEUS' ? this.resetPrometheusKey() : this.resetGenericKey(); - - return fn - .then(({ data: { token } }) => { - this.token = token; - this.setFeedback({ feedbackMessage: this.$options.i18n.tokenRest, variant: 'success' }); - }) - .catch(() => { - this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); - }); - }, - resetGenericKey() { - this.dismissFeedback(); - return service.updateGenericKey({ - endpoint: this.generic.formPath, - params: { service: { token: '' } }, - }); - }, - resetPrometheusKey() { - return service.updatePrometheusKey({ endpoint: this.prometheus.prometheusResetKeyPath }); - }, - toggleService(value) { - this.canSaveForm = true; - this.active = value; - }, - toggle(value) { - return this.isPrometheus ? this.togglePrometheusActive(value) : this.toggleActivated(value); - }, - toggleActivated(value) { - this.loading = true; - const path = this.isOpsgenie ? this.opsgenie.formPath : this.generic.formPath; - return service - .updateGenericActive({ - endpoint: path, - params: this.isOpsgenie - ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } } - : { service: { active: value } }, - }) - .then(() => this.notifySuccessAndReload()) - .catch(({ response: { data: { errors } = {} } = {} }) => { - this.createUserErrorMessage(errors); - this.setFeedback({ - feedbackMessage: this.$options.i18n.errorMsg, - variant: 'danger', - }); - }) - .finally(() => { - this.loading = false; - this.canSaveForm = false; - }); - }, - reload() { - if (!doesHashExistInUrl(sectionHash)) { - window.location.hash = sectionHash; - } - window.location.reload(); - }, - togglePrometheusActive(value) { - this.loading = true; - return service - .updatePrometheusActive({ - endpoint: this.prometheus.prometheusFormPath, - params: { - token: this.$options.csrf.token, - config: value, - url: this.targetUrl, - redirect: window.location, - }, - }) - .then(() => this.notifySuccessAndReload()) - .catch(({ response: { data: { errors } = {} } = {} }) => { - this.createUserErrorMessage(errors); - this.setFeedback({ - feedbackMessage: this.$options.i18n.errorMsg, - variant: 'danger', - }); - }) - .finally(() => { - this.loading = false; - this.canSaveForm = false; - }); - }, - notifySuccessAndReload() { - createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.NOTICE }); - setTimeout(() => this.reload(), 1000); - }, - setFeedback({ feedbackMessage, variant }) { - this.feedback = { feedbackMessage, variant }; - }, - validateJson() { - this.testAlert.error = null; - try { - JSON.parse(this.testAlert.json); - } catch (e) { - this.testAlert.error = JSON.stringify(e.message); - } - }, - validateTestAlert() { - this.loading = true; - this.dismissFeedback(); - this.validateJson(); - return service - .updateTestAlert({ - endpoint: this.selectedIntegrationType.url, - data: this.testAlert.json, - token: this.selectedIntegrationType.token, - }) - .then(() => { - this.setFeedback({ - feedbackMessage: this.$options.i18n.testAlertSuccess, - variant: 'success', - }); - }) - .catch(() => { - this.setFeedback({ - feedbackMessage: this.$options.i18n.testAlertFailed, - variant: 'danger', - }); - }) - .finally(() => { - this.loading = false; - }); - }, - onSubmit() { - this.dismissFeedback(); - this.toggle(this.active); - }, - onReset() { - this.testAlert.json = null; - this.dismissFeedback(); - this.targetUrl = this.selectedIntegrationType.targetUrl; - - if (this.canSaveForm) { - this.canSaveForm = false; - this.active = this.selectedIntegrationType.active; - } - }, - }, -}; -</script> - -<template> - <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> - <h5 class="gl-font-lg gl-my-5">{{ $options.i18n.integrationsLabel }}</h5> - - <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback"> - {{ feedback.feedbackMessage }} - <br /> - <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i> - <gl-button - v-if="showAlertSave" - variant="danger" - category="primary" - class="gl-display-block gl-mt-3" - @click="toggle(active)" - > - {{ __('Save anyway') }} - </gl-button> - </gl-alert> - - <div data-testid="alert-settings-description"> - <p v-for="section in sections" :key="section.text"> - <gl-sprintf :message="section.text"> - <template #link="{ content }"> - <gl-link :href="section.url" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - </div> - - <gl-form-group label-for="integration-type" :label="$options.i18n.integration"> - <gl-form-select - id="integration-type" - v-model="selectedIntegration" - :options="options" - data-testid="alert-settings-select" - @change="resetFormValues" - /> - <span class="gl-text-gray-500"> - <gl-sprintf :message="$options.i18n.integrationsInfo"> - <template #link="{ content }"> - <gl-link - class="gl-display-inline-block" - href="https://gitlab.com/groups/gitlab-org/-/epics/4390" - target="_blank" - >{{ content }}</gl-link - > - </template> - </gl-sprintf> - </span> - </gl-form-group> - <gl-form-group :label="$options.i18n.activeLabel" label-for="active"> - <toggle-button - id="active" - :disabled-input="loading" - :is-loading="loading" - :value="active" - @change="toggleService" - /> - </gl-form-group> - <gl-form-group - v-if="isOpsgenie || isPrometheus" - :label="$options.i18n.apiBaseUrlLabel" - label-for="api-url" - > - <gl-form-input - id="api-url" - v-model="targetUrl" - type="url" - :placeholder="baseUrlPlaceholder" - :disabled="!active" - /> - <span class="gl-text-gray-500"> - {{ $options.i18n.apiBaseUrlHelpText }} - </span> - </gl-form-group> - <template v-if="!isOpsgenie"> - <gl-form-group :label="$options.i18n.urlLabel" label-for="url"> - <gl-form-input-group id="url" readonly :value="selectedIntegrationType.url"> - <template #append> - <clipboard-button - :text="selectedIntegrationType.url" - :title="$options.i18n.copyToClipboard" - class="gl-m-0!" - /> - </template> - </gl-form-input-group> - <span class="gl-text-gray-500"> - {{ prometheusInfo }} - </span> - </gl-form-group> - <gl-form-group :label="$options.i18n.tokenLabel" label-for="authorization-key"> - <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="token"> - <template #append> - <clipboard-button - :text="token" - :title="$options.i18n.copyToClipboard" - class="gl-m-0!" - /> - </template> - </gl-form-input-group> - <gl-button v-gl-modal.tokenModal :disabled="!active" class="gl-mt-3">{{ - $options.i18n.resetKey - }}</gl-button> - <gl-modal - modal-id="tokenModal" - :title="$options.i18n.resetKey" - :ok-title="$options.i18n.resetKey" - ok-variant="danger" - @ok="selectedIntegrationType.resetKey" - > - {{ $options.i18n.restKeyInfo }} - </gl-modal> - </gl-form-group> - <gl-form-group - :label="$options.i18n.alertJson" - label-for="alert-json" - :invalid-feedback="testAlert.error" - > - <gl-form-textarea - id="alert-json" - v-model.trim="testAlert.json" - :disabled="!active" - :state="jsonIsValid" - :placeholder="$options.i18n.alertJsonPlaceholder" - rows="6" - max-rows="10" - /> - </gl-form-group> - - <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ - $options.i18n.testAlertInfo - }}</gl-button> - </template> - <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"> - <gl-button variant="success" category="primary" :disabled="!canSaveConfig" @click="onSubmit"> - {{ __('Save changes') }} - </gl-button> - <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset"> - {{ __('Cancel') }} - </gl-button> - </div> - </gl-form> -</template> 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 1ffc2f80148..1ac63fd04a1 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -1,7 +1,6 @@ <script> import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { fetchPolicies } from '~/lib/graphql'; import createFlash, { FLASH_TYPES } from '~/flash'; import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql'; @@ -15,8 +14,8 @@ import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutati import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql'; import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql'; import IntegrationsList from './alerts_integrations_list.vue'; -import SettingsFormOld from './alerts_settings_form_old.vue'; -import SettingsFormNew from './alerts_settings_form_new.vue'; +import AlertSettingsForm from './alerts_settings_form.vue'; +import service from '../services'; import { typeSet } from '../constants'; import { updateStoreAfterIntegrationDelete, @@ -37,6 +36,9 @@ export default { '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.', + ), }, components: { // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 @@ -44,10 +46,8 @@ export default { GlLink, GlSprintf, IntegrationsList, - SettingsFormOld, - SettingsFormNew, + AlertSettingsForm, }, - mixins: [glFeatureFlagsMixin()], inject: { generic: { default: {}, @@ -93,6 +93,7 @@ export default { data() { return { isUpdating: false, + testAlertPayload: null, integrations: {}, currentIntegration: null, }; @@ -101,20 +102,6 @@ export default { loading() { return this.$apollo.queries.integrations.loading; }, - integrationsOptionsOld() { - return [ - { - name: s__('AlertSettings|HTTP endpoint'), - type: s__('AlertsIntegrations|HTTP endpoint'), - active: this.generic.active, - }, - { - name: s__('AlertSettings|External Prometheus'), - type: s__('AlertsIntegrations|Prometheus'), - active: this.prometheus.active, - }, - ]; - }, canAddIntegration() { return this.multiIntegrations || this.integrations?.list?.length < 2; }, @@ -149,6 +136,19 @@ export default { if (error) { return createFlash({ message: error }); } + + if (this.testAlertPayload) { + const integration = + httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration; + + const payload = { + ...this.testAlertPayload, + endpoint: integration.url, + token: integration.token, + }; + return this.validateAlertPayload(payload); + } + return createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.SUCCESS, @@ -179,6 +179,13 @@ export default { if (error) { return createFlash({ message: error }); } + + if (this.testAlertPayload) { + return this.validateAlertPayload(); + } + + this.clearCurrentIntegration(); + return createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.SUCCESS, @@ -189,6 +196,7 @@ export default { }) .finally(() => { this.isUpdating = false; + this.testAlertPayload = null; }); }, resetToken({ type, variables }) { @@ -212,7 +220,13 @@ export default { const integration = httpIntegrationResetToken?.integration || prometheusIntegrationResetToken?.integration; - this.currentIntegration = integration; + + this.$apollo.mutate({ + mutation: updateCurrentIntergrationMutation, + variables: { + ...integration, + }, + }); return createFlash({ message: this.$options.i18n.changesSaved, @@ -280,8 +294,21 @@ export default { variables: {}, }); }, - testPayloadFailure() { - createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + setTestAlertPayload(payload) { + this.testAlertPayload = payload; + }, + validateAlertPayload(payload) { + return service + .updateTestAlert(payload ?? this.testAlertPayload) + .then(() => { + return createFlash({ + message: this.$options.i18n.alertSent, + type: FLASH_TYPES.SUCCESS, + }); + }) + .catch(() => { + createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + }); }, }, }; @@ -310,13 +337,12 @@ export default { </gl-alert> <integrations-list v-else - :integrations="glFeatures.httpIntegrationsList ? integrations.list : integrationsOptionsOld" + :integrations="integrations.list" :loading="loading" @edit-integration="editIntegration" @delete-integration="deleteIntegration" /> - <settings-form-new - v-if="glFeatures.httpIntegrationsList" + <alert-settings-form :loading="isUpdating" :can-add-integration="canAddIntegration" :can-manage-opsgenie="canManageOpsgenie" @@ -324,8 +350,7 @@ export default { @update-integration="updateIntegration" @reset-token="resetToken" @clear-current-integration="clearCurrentIntegration" - @test-payload-failure="testPayloadFailure" + @set-test-alert-payload="setTestAlertPayload" /> - <settings-form-old v-else /> </div> </template> diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js index 1835d6b46aa..e45ea772ddd 100644 --- a/app/assets/javascripts/alerts_settings/services/index.js +++ b/app/assets/javascripts/alerts_settings/services/index.js @@ -2,30 +2,9 @@ import axios from '~/lib/utils/axios_utils'; export default { - // TODO: All this code save updateTestAlert will be deleted as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/255501 - updateGenericKey({ endpoint, params }) { - return axios.put(endpoint, params); - }, - updatePrometheusKey({ endpoint }) { - return axios.post(endpoint); - }, updateGenericActive({ endpoint, params }) { return axios.put(endpoint, params); }, - updatePrometheusActive({ endpoint, params: { token, config, url, redirect } }) { - const data = new FormData(); - data.set('_method', 'put'); - data.set('authenticity_token', token); - data.set('service[manual_configuration]', config); - data.set('service[api_url]', url); - data.set('redirect_to', redirect); - - return axios.post(endpoint, data, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); - }, updateTestAlert({ endpoint, data, token }) { return axios.post(endpoint, data, { headers: { diff --git a/app/assets/javascripts/behaviors/select2.js b/app/assets/javascripts/behaviors/select2.js index 37b75bb5e56..1f222d8c1f6 100644 --- a/app/assets/javascripts/behaviors/select2.js +++ b/app/assets/javascripts/behaviors/select2.js @@ -1,22 +1,29 @@ import $ from 'jquery'; +import { loadCSSFile } from '../lib/utils/css_utils'; export default () => { - if ($('select.select2').length) { + const $select2Elements = $('select.select2'); + if ($select2Elements.length) { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $('select.select2').select2({ - width: 'resolve', - minimumResultsForSearch: 10, - dropdownAutoWidth: true, - }); + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + $select2Elements.select2({ + width: 'resolve', + minimumResultsForSearch: 10, + dropdownAutoWidth: true, + }); - // Close select2 on escape - $('.js-select2').on('select2-close', () => { - setTimeout(() => { - $('.select2-container-active').removeClass('select2-container-active'); - $(':focus').blur(); - }, 1); - }); + // Close select2 on escape + $('.js-select2').on('select2-close', () => { + requestAnimationFrame(() => { + $('.select2-container-active').removeClass('select2-container-active'); + $(':focus').blur(); + }); + }); + }) + .catch(() => {}); }) .catch(() => {}); } diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js index bd39aa2e16f..2532aeea989 100644 --- a/app/assets/javascripts/blob/file_template_selector.js +++ b/app/assets/javascripts/blob/file_template_selector.js @@ -12,7 +12,10 @@ export default class FileTemplateSelector { this.$dropdown = $(cfg.dropdown); this.$wrapper = $(cfg.wrapper); - this.$loadingIcon = this.$wrapper.find('.fa-chevron-down'); + this.$dropdownIcon = this.$wrapper.find('.dropdown-menu-toggle-icon'); + this.$loadingIcon = $( + '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>', + ).insertAfter(this.$dropdownIcon); this.$dropdownToggleText = this.$wrapper.find('.dropdown-toggle-text'); this.initDropdown(); @@ -45,15 +48,13 @@ export default class FileTemplateSelector { } renderLoading() { - this.$loadingIcon - .addClass('gl-spinner gl-spinner-orange gl-spinner-sm') - .removeClass('fa-chevron-down'); + this.$loadingIcon.removeClass('gl-display-none'); + this.$dropdownIcon.addClass('gl-display-none'); } renderLoaded() { - this.$loadingIcon - .addClass('fa-chevron-down') - .removeClass('gl-spinner gl-spinner-orange gl-spinner-sm'); + this.$loadingIcon.addClass('gl-display-none'); + this.$dropdownIcon.removeClass('gl-display-none'); } reportSelection(options) { diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 257458138dc..ae9bb3455f0 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -10,7 +10,10 @@ export default class TemplateSelector { this.dropdown = dropdown; this.$dropdownContainer = wrapper; this.$filenameInput = $input || $('#file_name'); - this.$dropdownIcon = $('.fa-chevron-down', dropdown); + this.$dropdownIcon = $('.dropdown-menu-toggle-icon', dropdown); + this.$loadingIcon = $( + '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>', + ).insertAfter(this.$dropdownIcon); this.initDropdown(dropdown, data); this.listenForFilenameInput(); @@ -92,10 +95,12 @@ export default class TemplateSelector { } startLoadingSpinner() { - this.$dropdownIcon.addClass('spinner').removeClass('fa-chevron-down'); + this.$loadingIcon.removeClass('gl-display-none'); + this.$dropdownIcon.addClass('gl-display-none'); } stopLoadingSpinner() { - this.$dropdownIcon.addClass('fa-chevron-down').removeClass('spinner'); + this.$loadingIcon.addClass('gl-display-none'); + this.$dropdownIcon.removeClass('gl-display-none'); } } diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 6b7b0c2e28d..0142c95e773 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,5 +1,4 @@ import { sortBy } from 'lodash'; -import ListIssue from 'ee_else_ce/boards/models/issue'; import { ListType } from './constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import boardsStore from '~/boards/stores/boards_store'; @@ -21,11 +20,11 @@ export function formatBoardLists(lists) { } export function formatIssue(issue) { - return new ListIssue({ + return { ...issue, labels: issue.labels?.nodes || [], assignees: issue.assignees?.nodes || [], - }); + }; } export function formatListIssues(listIssues) { @@ -44,12 +43,12 @@ export function formatListIssues(listIssues) { [list.id]: sortedIssues.map(i => { const id = getIdFromGraphQLId(i.id); - const listIssue = new ListIssue({ + const listIssue = { ...i, id, labels: i.labels?.nodes || [], assignees: i.assignees?.nodes || [], - }); + }; issues[id] = listIssue; @@ -83,21 +82,30 @@ export function fullLabelId(label) { } export function moveIssueListHelper(issue, fromList, toList) { - if (toList.type === ListType.label) { - issue.addLabel(toList.label); + const updatedIssue = issue; + if ( + toList.type === ListType.label && + !updatedIssue.labels.find(label => label.id === toList.label.id) + ) { + updatedIssue.labels.push(toList.label); } - if (fromList && fromList.type === ListType.label) { - issue.removeLabel(fromList.label); + if (fromList?.label && fromList.type === ListType.label) { + updatedIssue.labels = updatedIssue.labels.filter(label => fromList.label.id !== label.id); } - if (toList.type === ListType.assignee) { - issue.addAssignee(toList.assignee); + if ( + toList.type === ListType.assignee && + !updatedIssue.assignees.find(assignee => assignee.id === toList.assignee.id) + ) { + updatedIssue.assignees.push(toList.assignee); } - if (fromList && fromList.type === ListType.assignee) { - issue.removeAssignee(fromList.assignee); + if (fromList?.assignee && fromList.type === ListType.assignee) { + updatedIssue.assignees = updatedIssue.assignees.filter( + assignee => assignee.id !== fromList.assignee.id, + ); } - return issue; + return updatedIssue; } export default { diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue index c81f171af2b..231cde0d24f 100644 --- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -1,11 +1,13 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import { cloneDeep } from 'lodash'; import { GlDropdownItem, GlDropdownDivider, GlAvatarLabeled, GlAvatarLink, GlSearchBoxByType, + GlLoadingIcon, } from '@gitlab/ui'; import { __, n__ } from '~/locale'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; @@ -32,12 +34,13 @@ export default { GlAvatarLabeled, GlAvatarLink, GlSearchBoxByType, + GlLoadingIcon, }, data() { return { search: '', participants: [], - selected: this.$store.getters.activeIssue.assignees, + selected: [], }; }, apollo: { @@ -89,9 +92,20 @@ export default { isSearchEmpty() { return this.search === ''; }, + currentUser() { + return gon?.current_username; + }, + }, + created() { + this.selected = cloneDeep(this.activeIssue.assignees); }, methods: { ...mapActions(['setAssignees']), + async assignSelf() { + const [currentUserObject] = await this.setAssignees(this.currentUser); + + this.selectAssignee(currentUserObject); + }, clearSelected() { this.selected = []; }, @@ -119,7 +133,7 @@ export default { <template> <board-editable-item :title="assigneeText" @close="saveAssignees"> <template #collapsed> - <issuable-assignees :users="activeIssue.assignees" /> + <issuable-assignees :users="selected" @assign-self="assignSelf" /> </template> <template #default> @@ -132,45 +146,48 @@ export default { <gl-search-box-by-type v-model.trim="search" /> </template> <template #items> - <gl-dropdown-item - :is-checked="selectedIsEmpty" - data-testid="unassign" - class="mt-2" - @click="selectAssignee()" - >{{ $options.i18n.unassigned }}</gl-dropdown-item - > - <gl-dropdown-divider data-testid="unassign-divider" /> - <gl-dropdown-item - v-for="item in selected" - :key="item.id" - :is-checked="isChecked(item.username)" - @click="unselect(item.username)" - > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="item.name" - :sub-label="item.username" - :src="item.avatarUrl || item.avatar" - /> - </gl-avatar-link> - </gl-dropdown-item> - <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" /> - <gl-dropdown-item - v-for="unselectedUser in unSelectedFiltered" - :key="unselectedUser.id" - :data-testid="`item_${unselectedUser.name}`" - @click="selectAssignee(unselectedUser)" - > - <gl-avatar-link> - <gl-avatar-labeled - :size="32" - :label="unselectedUser.name" - :sub-label="unselectedUser.username" - :src="unselectedUser.avatarUrl || unselectedUser.avatar" - /> - </gl-avatar-link> - </gl-dropdown-item> + <gl-loading-icon v-if="$apollo.queries.participants.loading" size="lg" /> + <template v-else> + <gl-dropdown-item + :is-checked="selectedIsEmpty" + data-testid="unassign" + class="mt-2" + @click="selectAssignee()" + >{{ $options.i18n.unassigned }}</gl-dropdown-item + > + <gl-dropdown-divider data-testid="unassign-divider" /> + <gl-dropdown-item + v-for="item in selected" + :key="item.id" + :is-checked="isChecked(item.username)" + @click="unselect(item.username)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="item.name" + :sub-label="item.username" + :src="item.avatarUrl || item.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" /> + <gl-dropdown-item + v-for="unselectedUser in unSelectedFiltered" + :key="unselectedUser.id" + :data-testid="`item_${unselectedUser.name}`" + @click="selectAssignee(unselectedUser)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="unselectedUser.name" + :sub-label="unselectedUser.username" + :src="unselectedUser.avatarUrl || unselectedUser.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + </template> </template> </multi-select-dropdown> </template> diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue index 8a59355eb83..7162423846b 100644 --- a/app/assets/javascripts/boards/components/board_column_new.vue +++ b/app/assets/javascripts/boards/components/board_column_new.vue @@ -85,6 +85,7 @@ export default { :disabled="disabled" :issues="listIssues" :list="list" + :can-admin-list="canAdminList" /> <!-- Will be only available in EE --> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 53989e2d9de..1f87b563e73 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,7 +6,6 @@ import boardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { getBoardSortableDefaultOptions, @@ -25,7 +24,6 @@ export default { boardNewIssue, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], props: { disabled: { type: Boolean, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index d85ba2038a7..722bd20f227 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -190,7 +190,8 @@ export default { :title="chevronTooltip" :icon="chevronIcon" class="board-title-caret no-drag gl-cursor-pointer" - variant="link" + category="tertiary" + size="small" @click="toggleExpanded" /> <!-- The following is only true in EE and if it is a milestone --> diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_new.vue index 99347a4cd4d..447fef4b49c 100644 --- a/app/assets/javascripts/boards/components/board_list_header_new.vue +++ b/app/assets/javascripts/boards/components/board_list_header_new.vue @@ -198,7 +198,8 @@ export default { :title="chevronTooltip" :icon="chevronIcon" class="board-title-caret no-drag gl-cursor-pointer" - variant="link" + category="tertiary" + size="small" @click="toggleExpanded" /> <!-- EE start --> diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue index 396aedcc557..291abc9849b 100644 --- a/app/assets/javascripts/boards/components/board_list_new.vue +++ b/app/assets/javascripts/boards/components/board_list_new.vue @@ -1,12 +1,14 @@ <script> +import Draggable from 'vuedraggable'; import { mapActions, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; +import defaultSortableConfig from '~/sortable/sortable_config'; +import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options'; import BoardNewIssue from './board_new_issue_new.vue'; import BoardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'BoardList', @@ -15,7 +17,6 @@ export default { BoardNewIssue, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], props: { disabled: { type: Boolean, @@ -29,6 +30,11 @@ export default { type: Array, required: true, }, + canAdminList: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -55,12 +61,32 @@ export default { loading() { return this.listsFlags[this.list.id]?.isLoading; }, + listRef() { + // When list is draggable, the reference to the list needs to be accessed differently + return this.canAdminList ? this.$refs.list.$el : this.$refs.list; + }, + treeRootWrapper() { + return this.canAdminList ? Draggable : 'ul'; + }, + treeRootOptions() { + const options = { + ...defaultSortableConfig, + fallbackOnBody: false, + group: 'boards-list', + tag: 'ul', + 'ghost-class': 'board-card-drag-active', + 'data-list-id': this.list.id, + value: this.issues, + }; + + return this.canAdminList ? options : {}; + }, }, watch: { filters: { handler() { this.list.loadingMore = false; - this.$refs.list.scrollTop = 0; + this.listRef.scrollTop = 0; }, deep: true, }, @@ -76,26 +102,26 @@ export default { }, mounted() { // Scroll event on list to load more - this.$refs.list.addEventListener('scroll', this.onScroll); + this.listRef.addEventListener('scroll', this.onScroll); }, beforeDestroy() { eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm); eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop); - this.$refs.list.removeEventListener('scroll', this.onScroll); + this.listRef.removeEventListener('scroll', this.onScroll); }, methods: { - ...mapActions(['fetchIssuesForList']), + ...mapActions(['fetchIssuesForList', 'moveIssue']), listHeight() { - return this.$refs.list.getBoundingClientRect().height; + return this.listRef.getBoundingClientRect().height; }, scrollHeight() { - return this.$refs.list.scrollHeight; + return this.listRef.scrollHeight; }, scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); + return this.listRef.scrollTop + this.listHeight(); }, scrollToTop() { - this.$refs.list.scrollTop = 0; + this.listRef.scrollTop = 0; }, loadNextPage() { const loadingDone = () => { @@ -120,6 +146,52 @@ export default { } }); }, + handleDragOnStart() { + sortableStart(); + }, + handleDragOnEnd(params) { + sortableEnd(); + const { newIndex, oldIndex, from, to, item } = params; + const { issueId, issueIid, issuePath } = item.dataset; + const { children } = to; + let moveBeforeId; + let moveAfterId; + + const getIssueId = el => Number(el.dataset.issueId); + + // If issue is being moved within the same list + if (from === to) { + if (newIndex > oldIndex && children.length > 1) { + // If issue is being moved down we look for the issue that ends up before + moveBeforeId = getIssueId(children[newIndex]); + } else if (newIndex < oldIndex && children.length > 1) { + // If issue is being moved up we look for the issue that ends up after + moveAfterId = getIssueId(children[newIndex]); + } else { + // If issue remains in the same list at the same position we do nothing + return; + } + } else { + // We look for the issue that ends up before the moved issue if it exists + if (children[newIndex - 1]) { + moveBeforeId = getIssueId(children[newIndex - 1]); + } + // We look for the issue that ends up after the moved issue if it exists + if (children[newIndex]) { + moveAfterId = getIssueId(children[newIndex]); + } + } + + this.moveIssue({ + issueId, + issueIid, + issuePath, + fromListId: from.dataset.listId, + toListId: to.dataset.listId, + moveBeforeId, + moveAfterId, + }); + }, }, }; </script> @@ -139,13 +211,18 @@ export default { <gl-loading-icon /> </div> <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" /> - <ul + <component + :is="treeRootWrapper" v-show="!loading" ref="list" + v-bind="treeRootOptions" :data-board="list.id" :data-board-type="list.type" :class="{ 'bg-danger-100': issuesSizeExceedsMax }" class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" + data-testid="tree-root-wrapper" + @start="handleDragOnStart" + @end="handleDragOnEnd" > <board-card v-for="(issue, index) in issues" @@ -161,6 +238,6 @@ export default { <span v-if="issues.length === list.issuesSize">{{ __('Showing all issues') }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> - </ul> + </component> </div> </template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 45ce1e51489..67e8a32dbe2 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -158,9 +158,13 @@ export default { class="confidential-icon gl-mr-2" :aria-label="__('Confidential')" /> - <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{ - issue.title - }}</a> + <a + :href="issue.path || issue.webUrl || ''" + :title="issue.title" + class="js-no-trigger" + @mousemove.stop + >{{ issue.title }}</a + > </h4> </div> <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> @@ -196,7 +200,11 @@ export default { #{{ issue.iid }} </span> <span class="board-info-items gl-mt-3 gl-display-inline-block"> - <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" :closed="issue.closed" /> + <issue-due-date + v-if="issue.dueDate" + :date="issue.dueDate" + :closed="issue.closed || Boolean(issue.closedAt)" + /> <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight v-if="validIssueWeight" 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 5fb7a9b210c..ce267be6d45 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -50,6 +50,13 @@ export default { } window.removeEventListener('click', this.collapseWhenOffClick); }, + toggle({ emitEvent = true } = {}) { + if (this.edit) { + this.collapse({ emitEvent }); + } else { + this.expand(); + } + }, }, }; </script> @@ -64,18 +71,18 @@ export default { <gl-button v-if="canUpdate" variant="link" - class="gl-text-gray-900!" + class="gl-text-gray-900! js-sidebar-dropdown-toggle" data-testid="edit-button" - @click="expand()" + @click="toggle" > {{ __('Edit') }} </gl-button> </div> - <div v-show="!edit" class="gl-text-gray-400" data-testid="collapsed-content"> + <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content"> <slot name="collapsed">{{ __('None') }}</slot> </div> <div v-show="edit" data-testid="expanded-content"> - <slot></slot> + <slot :edit="edit"></slot> </div> </div> </template> 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 6935ead2706..904ceaed1b3 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 @@ -79,7 +79,7 @@ export default { <span class="gl-mx-2">-</span> <gl-button variant="link" - class="gl-text-gray-400!" + class="gl-text-gray-500!" data-testid="reset-button" :disabled="loading" @click="setDueDate(null)" 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 9d537a4ef2c..6a407bd6ba6 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 @@ -92,7 +92,7 @@ export default { @close="removeLabel(label.id)" /> </template> - <template> + <template #default="{ edit }"> <labels-select ref="labelsSelect" :allow-label-edit="false" @@ -105,6 +105,7 @@ export default { :labels-filter-base-path="labelsFilterBasePath" :labels-list-title="__('Select label')" :dropdown-button-text="__('Choose labels')" + :is-editing="edit" variant="embedded" class="gl-display-block labels gl-w-full" @updateSelectedLabels="setLabels" 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 new file mode 100644 index 00000000000..f7b7fd3f61f --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue @@ -0,0 +1,161 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { fetchPolicies } from '~/lib/graphql'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import groupMilestones from '../../queries/group_milestones.query.graphql'; +import createFlash from '~/flash'; +import { __, s__ } from '~/locale'; + +export default { + components: { + BoardEditableItem, + GlDropdown, + GlLoadingIcon, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + GlDropdownDivider, + }, + data() { + return { + milestones: [], + searchTitle: '', + loading: false, + edit: false, + }; + }, + apollo: { + milestones: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: groupMilestones, + debounce: 250, + skip() { + return !this.edit; + }, + variables() { + return { + fullPath: this.groupFullPath, + searchTitle: this.searchTitle, + state: 'active', + includeDescendants: true, + }; + }, + update(data) { + const edges = data?.group?.milestones?.edges ?? []; + return edges.map(item => item.node); + }, + error() { + createFlash({ message: this.$options.i18n.fetchMilestonesError }); + }, + }, + }, + computed: { + ...mapGetters({ issue: 'activeIssue' }), + hasMilestone() { + return this.issue.milestone !== null; + }, + groupFullPath() { + const { referencePath = '' } = this.issue; + return referencePath.slice(0, referencePath.indexOf('/')); + }, + projectPath() { + const { referencePath = '' } = this.issue; + return referencePath.slice(0, referencePath.indexOf('#')); + }, + dropdownText() { + return this.issue.milestone?.title ?? this.$options.i18n.noMilestone; + }, + }, + mounted() { + this.$root.$on('bv::dropdown::hide', () => { + this.$refs.sidebarItem.collapse(); + }); + }, + methods: { + ...mapActions(['setActiveIssueMilestone']), + handleOpen() { + this.edit = true; + this.$refs.dropdown.show(); + }, + async setMilestone(milestoneId) { + this.loading = true; + this.searchTitle = ''; + this.$refs.sidebarItem.collapse(); + + try { + const input = { milestoneId, projectPath: this.projectPath }; + await this.setActiveIssueMilestone(input); + } catch (e) { + createFlash({ message: this.$options.i18n.updateMilestoneError }); + } finally { + this.loading = false; + } + }, + }, + i18n: { + milestone: __('Milestone'), + noMilestone: __('No milestone'), + assignMilestone: __('Assign milestone'), + noMilestonesFound: s__('Milestones|No milestones found'), + fetchMilestonesError: __('There was a problem fetching milestones.'), + updateMilestoneError: __('An error occurred while updating the milestone.'), + }, +}; +</script> + +<template> + <board-editable-item + ref="sidebarItem" + :title="$options.i18n.milestone" + :loading="loading" + @open="handleOpen()" + @close="edit = false" + > + <template v-if="hasMilestone" #collapsed> + <strong class="gl-text-gray-900">{{ issue.milestone.title }}</strong> + </template> + <template> + <gl-dropdown + ref="dropdown" + :text="dropdownText" + :header-text="$options.i18n.assignMilestone" + block + > + <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" /> + <gl-dropdown-item + data-testid="no-milestone-item" + :is-check-item="true" + :is-checked="!issue.milestone" + @click="setMilestone(null)" + > + {{ $options.i18n.noMilestone }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-loading-icon v-if="$apollo.loading" class="gl-py-4" /> + <template v-else-if="milestones.length > 0"> + <gl-dropdown-item + v-for="milestone in milestones" + :key="milestone.id" + :is-check-item="true" + :is-checked="issue.milestone && milestone.id === issue.milestone.id" + data-testid="milestone-item" + @click="setMilestone(milestone.id)" + > + {{ milestone.title }} + </gl-dropdown-item> + </template> + <gl-dropdown-text v-else data-testid="no-milestones-found"> + {{ $options.i18n.noMilestonesFound }} + </gl-dropdown-text> + </gl-dropdown> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/queries/group_milestones.query.graphql b/app/assets/javascripts/boards/queries/group_milestones.query.graphql new file mode 100644 index 00000000000..f2ab12ef4a7 --- /dev/null +++ b/app/assets/javascripts/boards/queries/group_milestones.query.graphql @@ -0,0 +1,17 @@ +query groupMilestones( + $fullPath: ID! + $state: MilestoneStateEnum + $includeDescendants: Boolean + $searchTitle: String +) { + group(fullPath: $fullPath) { + milestones(state: $state, includeDescendants: $includeDescendants, searchTitle: $searchTitle) { + edges { + node { + id + title + } + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/issue.fragment.graphql b/app/assets/javascripts/boards/queries/issue.fragment.graphql index 4b429f875a6..1395bef39ed 100644 --- a/app/assets/javascripts/boards/queries/issue.fragment.graphql +++ b/app/assets/javascripts/boards/queries/issue.fragment.graphql @@ -11,6 +11,10 @@ fragment IssueNode on Issue { webUrl subscribed relativePosition + milestone { + id + title + } assignees { nodes { ...User diff --git a/app/assets/javascripts/boards/queries/issue_set_milestone.mutation.graphql b/app/assets/javascripts/boards/queries/issue_set_milestone.mutation.graphql new file mode 100644 index 00000000000..5dc78a03a06 --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_set_milestone.mutation.graphql @@ -0,0 +1,12 @@ +mutation issueSetMilestone($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + milestone { + id + title + description + } + } + errors + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index dd950a45076..00c64bff74e 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -25,6 +25,7 @@ import issueCreateMutation from '../queries/issue_create.mutation.graphql'; import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql'; import issueSetSubscriptionMutation from '../graphql/mutations/issue_set_subscription.mutation.graphql'; +import issueSetMilestone from '../queries/issue_set_milestone.mutation.graphql'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -325,14 +326,42 @@ export default { }, }) .then(({ data }) => { + const { nodes } = data.issueSetAssignees?.issue?.assignees || []; + commit('UPDATE_ISSUE_BY_ID', { issueId: getters.activeIssue.id, prop: 'assignees', - value: data.issueSetAssignees.issue.assignees.nodes, + value: nodes, }); + + return nodes; }); }, + setActiveIssueMilestone: async ({ commit, getters }, input) => { + const { activeIssue } = getters; + const { data } = await gqlClient.mutate({ + mutation: issueSetMilestone, + variables: { + input: { + iid: String(activeIssue.iid), + milestoneId: getIdFromGraphQLId(input.milestoneId), + projectPath: input.projectPath, + }, + }, + }); + + if (data.updateIssue.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: activeIssue.id, + prop: 'milestone', + value: data.updateIssue.issue.milestone, + }); + }, + createNewIssue: ({ commit, state }, issueInput) => { const input = issueInput; const { boardType, endpoints } = state; diff --git a/app/assets/javascripts/clone_panel.js b/app/assets/javascripts/clone_panel.js new file mode 100644 index 00000000000..362e6c5c5ce --- /dev/null +++ b/app/assets/javascripts/clone_panel.js @@ -0,0 +1,42 @@ +import $ from 'jquery'; + +export default function initClonePanel() { + const $cloneOptions = $('ul.clone-options-dropdown'); + if ($cloneOptions.length) { + const $cloneUrlField = $('#clone_url'); + const $cloneBtnLabel = $('.js-git-clone-holder .js-clone-dropdown-label'); + const mobileCloneField = document.querySelector( + '.js-mobile-git-clone .js-clone-dropdown-label', + ); + + const selectedCloneOption = $cloneBtnLabel.text().trim(); + if (selectedCloneOption.length > 0) { + $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active'); + } + + $('a', $cloneOptions).on('click', e => { + e.preventDefault(); + const $this = $(e.currentTarget); + const url = $this.attr('href'); + const cloneType = $this.data('cloneType'); + + $('.is-active', $cloneOptions).removeClass('is-active'); + $(`a[data-clone-type="${cloneType}"]`).each(function switchProtocol() { + const $el = $(this); + const activeText = $el.find('.dropdown-menu-inner-title').text(); + const $container = $el.closest('.js-git-clone-holder, .js-mobile-git-clone'); + const $label = $container.find('.js-clone-dropdown-label'); + + $el.toggleClass('is-active'); + $label.text(activeText); + }); + + if (mobileCloneField) { + mobileCloneField.dataset.clipboardText = url; + } else { + $cloneUrlField.val(url); + } + $('.js-git-empty .js-clone').text(url); + }); + } +} diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index a75646db162..ba005e98d53 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -52,6 +52,7 @@ export default class Clusters { clusterStatus, clusterStatusReason, helpPath, + helmHelpPath, ingressHelpPath, ingressDnsHelpPath, ingressModSecurityHelpPath, @@ -68,8 +69,9 @@ export default class Clusters { this.clusterBannerDismissedKey = `cluster_${this.clusterId}_banner_dismissed`; this.store = new ClustersStore(); - this.store.setHelpPaths( + this.store.setHelpPaths({ helpPath, + helmHelpPath, ingressHelpPath, ingressDnsHelpPath, ingressModSecurityHelpPath, @@ -78,7 +80,7 @@ export default class Clusters { deployBoardsHelpPath, cloudRunHelpPath, ciliumHelpPath, - ); + }); this.store.setManagePrometheusPath(managePrometheusPath); this.store.updateStatus(clusterStatus); this.store.updateStatusReason(clusterStatusReason); @@ -162,6 +164,7 @@ export default class Clusters { type, applications: this.state.applications, helpPath: this.state.helpPath, + helmHelpPath: this.state.helmHelpPath, ingressHelpPath: this.state.ingressHelpPath, managePrometheusPath: this.state.managePrometheusPath, ingressDnsHelpPath: this.state.ingressDnsHelpPath, diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 271d862afab..412082b648f 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlSprintf, GlLink } from '@gitlab/ui'; import gitlabLogo from 'images/cluster_app_logos/gitlab.png'; +import helmLogo from 'images/cluster_app_logos/helm.png'; import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png'; import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png'; import certManagerLogo from 'images/cluster_app_logos/cert_manager.png'; @@ -46,6 +47,11 @@ export default { required: false, default: '', }, + helmHelpPath: { + type: String, + required: false, + default: '', + }, ingressHelpPath: { type: String, required: false, @@ -150,6 +156,7 @@ export default { }, logos: { gitlabLogo, + helmLogo, jupyterhubLogo, kubernetesLogo, certManagerLogo, @@ -173,6 +180,35 @@ export default { <div class="cluster-application-list gl-mt-3"> <application-row + v-if="applications.helm.installed || applications.helm.uninstalling" + id="helm" + :logo-url="$options.logos.helmLogo" + :title="applications.helm.title" + :status="applications.helm.status" + :status-reason="applications.helm.statusReason" + :request-status="applications.helm.requestStatus" + :request-reason="applications.helm.requestReason" + :installed="applications.helm.installed" + :install-failed="applications.helm.installFailed" + :uninstallable="applications.helm.uninstallable" + :uninstall-successful="applications.helm.uninstallSuccessful" + :uninstall-failed="applications.helm.uninstallFailed" + title-link="https://v2.helm.sh/" + > + <template #description> + <p> + {{ + s__(`ClusterIntegration|Can be safely removed. Prior to GitLab + 13.2, GitLab used a remote Tiller server to manage the + applications. GitLab no longer uses this server. + Uninstalling this server will not affect your other + applications. This row will disappear afterwards.`) + }} + <gl-link :href="helmHelpPath">{{ __('More information') }}</gl-link> + </p> + </template> + </application-row> + <application-row :id="ingressId" :logo-url="$options.logos.kubernetesLogo" :title="applications.ingress.title" diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index cb415d902e8..d80bd6f5b42 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -7,6 +7,7 @@ import { GlSearchBoxByType, GlSprintf, GlButton, + GlAlert, } from '@gitlab/ui'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import { __, s__ } from '~/locale'; @@ -25,6 +26,7 @@ export default { GlDropdownItem, GlSearchBoxByType, GlSprintf, + GlAlert, }, props: { knative: { @@ -106,12 +108,13 @@ export default { <template> <div class="row"> - <div + <gl-alert v-if="knative.updateFailed" - class="bs-callout bs-callout-danger cluster-application-banner col-12 mt-2 mb-2 js-cluster-knative-domain-name-failure-message" + class="gl-mb-5 col-12 js-cluster-knative-domain-name-failure-message" + variant="danger" > {{ s__('ClusterIntegration|Something went wrong while updating Knative domain name.') }} - </div> + </gl-alert> <div :class="{ 'col-md-6': knativeInstalled, 'col-12': !knativeInstalled }" diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index 477dd13db4f..2a197e40b60 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -16,7 +16,7 @@ import { const CUSTOM_APP_WARNING_TEXT = { [HELM]: sprintf( s__( - 'ClusterIntegration|The associated Tiller pod, the %{gitlabManagedAppsNamespace} namespace, and all of its resources will be deleted and cannot be restored.', + 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored. Your other applications will remain unaffected.', ), { gitlabManagedAppsNamespace: '<code>gitlab-managed-apps</code>', diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js index 683b0e18534..1dd815ae44d 100644 --- a/app/assets/javascripts/clusters/services/application_state_machine.js +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -193,6 +193,12 @@ const applicationStateMachine = { uninstallSuccessful: true, }, }, + [NOT_INSTALLABLE]: { + target: NOT_INSTALLABLE, + effects: { + uninstallSuccessful: true, + }, + }, [UNINSTALL_ERRORED]: { target: INSTALLED, effects: { diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 53868b7c02d..88505eac3a9 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -36,6 +36,7 @@ export default class ClusterStore { constructor() { this.state = { helpPath: null, + helmHelpPath: null, ingressHelpPath: null, environmentsHelpPath: null, clustersHelpPath: null, @@ -49,7 +50,7 @@ export default class ClusterStore { applications: { helm: { ...applicationInitialState, - title: s__('ClusterIntegration|Helm Tiller'), + title: s__('ClusterIntegration|Legacy Helm Tiller server'), }, ingress: { ...applicationInitialState, @@ -126,26 +127,10 @@ export default class ClusterStore { }; } - setHelpPaths( - helpPath, - ingressHelpPath, - ingressDnsHelpPath, - ingressModSecurityHelpPath, - environmentsHelpPath, - clustersHelpPath, - deployBoardsHelpPath, - cloudRunHelpPath, - ciliumHelpPath, - ) { - this.state.helpPath = helpPath; - this.state.ingressHelpPath = ingressHelpPath; - this.state.ingressDnsHelpPath = ingressDnsHelpPath; - this.state.ingressModSecurityHelpPath = ingressModSecurityHelpPath; - this.state.environmentsHelpPath = environmentsHelpPath; - this.state.clustersHelpPath = clustersHelpPath; - this.state.deployBoardsHelpPath = deployBoardsHelpPath; - this.state.cloudRunHelpPath = cloudRunHelpPath; - this.state.ciliumHelpPath = ciliumHelpPath; + setHelpPaths(helpPaths) { + Object.assign(this.state, { + ...helpPaths, + }); } setManagePrometheusPath(managePrometheusPath) { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 9d8d184a3f6..09baf16ade9 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -230,9 +230,6 @@ export default { } }, diffViewType() { - if (!this.glFeatures.unifiedDiffLines && (this.needsReload() || this.needsFirstLoad())) { - this.refetchDiffData(); - } this.adjustView(); }, shouldShow() { diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 1b747fb7f20..a548354f257 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -136,7 +136,12 @@ export default { class="d-inline-flex mb-2" /> <gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group"> - <gl-button label class="gl-font-monospace" v-text="commit.short_id" /> + <gl-button + label + class="gl-font-monospace" + data-testid="commit-sha-short-id" + v-text="commit.short_id" + /> <clipboard-button :text="commit.id" :title="__('Copy commit SHA')" diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 401064fb18f..1454728288e 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -87,7 +87,7 @@ export default { return this.getUserData; }, mappedLines() { - if (this.glFeatures.unifiedDiffLines && this.glFeatures.unifiedDiffComponents) { + if (this.glFeatures.unifiedDiffComponents) { return this.diffLines(this.diffFile, true).map(mapParallel(this)) || []; } @@ -95,9 +95,7 @@ export default { if (this.isInlineView) { return this.diffFile.highlighted_diff_lines.map(mapInline(this)); } - return this.glFeatures.unifiedDiffLines - ? this.diffLines(this.diffFile).map(mapParallel(this)) - : this.diffFile.parallel_diff_lines.map(mapParallel(this)) || []; + return this.diffLines(this.diffFile).map(mapParallel(this)); }, }, updated() { @@ -129,9 +127,7 @@ export default { <template> <div class="diff-content"> <div class="diff-viewer"> - <template - v-if="isTextFile && glFeatures.unifiedDiffLines && glFeatures.unifiedDiffComponents" - > + <template v-if="isTextFile && glFeatures.unifiedDiffComponents"> <diff-view :diff-file="diffFile" :diff-lines="mappedLines" diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 4c49dfb5de9..ca0edb448b6 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -4,7 +4,7 @@ import { GlIcon } from '@gitlab/ui'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; +import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, INLINE_DIFF_LINES_KEY } from '../constants'; import * as utils from '../store/utils'; const EXPAND_ALL = 0; @@ -14,7 +14,6 @@ const EXPAND_DOWN = 2; const lineNumberByViewType = (viewType, diffLine) => { const numberGetters = { [INLINE_DIFF_VIEW_TYPE]: line => line?.new_line, - [PARALLEL_DIFF_VIEW_TYPE]: line => (line?.right || line?.left)?.new_line, }; const numberGetter = numberGetters[viewType]; return numberGetter && numberGetter(diffLine); @@ -57,9 +56,6 @@ export default { }, computed: { ...mapState({ - diffViewType(state) { - return this.glFeatures.unifiedDiffLines ? INLINE_DIFF_VIEW_TYPE : state.diffs.diffViewType; - }, diffFiles: state => state.diffs.diffFiles, }), canExpandUp() { @@ -77,16 +73,14 @@ export default { ...mapActions('diffs', ['loadMoreLines']), getPrevLineNumber(oldLineNumber, newLineNumber) { const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash); - const lines = { - [INLINE_DIFF_VIEW_TYPE]: diffFile.highlighted_diff_lines, - [PARALLEL_DIFF_VIEW_TYPE]: diffFile.parallel_diff_lines, - }; - const index = utils.getPreviousLineIndex(this.diffViewType, diffFile, { + const index = utils.getPreviousLineIndex(INLINE_DIFF_VIEW_TYPE, diffFile, { oldLineNumber, newLineNumber, }); - return lineNumberByViewType(this.diffViewType, lines[this.diffViewType][index - 2]) || 0; + return ( + lineNumberByViewType(INLINE_DIFF_VIEW_TYPE, diffFile[INLINE_DIFF_LINES_KEY][index - 2]) || 0 + ); }, callLoadMoreLines( endpoint, @@ -113,7 +107,7 @@ export default { this.isRequesting = true; const endpoint = this.contextLinesPath; const { fileHash } = this; - const view = this.diffViewType; + const view = INLINE_DIFF_VIEW_TYPE; const oldLineNumber = this.line.meta_data.old_pos || 0; const newLineNumber = this.line.meta_data.new_pos || 0; const offset = newLineNumber - oldLineNumber; 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 55f5a736cdf..172a2bdde7d 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -7,7 +7,7 @@ import noteForm from '../../notes/components/note_form.vue'; import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue'; import autosave from '../../notes/mixins/autosave'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import { DIFF_NOTE_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; +import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; import { commentLineOptions, formatLineRange, @@ -102,13 +102,13 @@ export default { }; const getDiffLines = () => { if (this.diffViewType === PARALLEL_DIFF_VIEW_TYPE) { - return (this.glFeatures.unifiedDiffLines - ? this.diffLines(this.diffFile) - : this.diffFile.parallel_diff_lines - ).reduce(combineSides, []); + return this.diffLines(this.diffFile, this.glFeatures.unifiedDiffComponents).reduce( + combineSides, + [], + ); } - return this.diffFile.highlighted_diff_lines; + return this.diffFile[INLINE_DIFF_LINES_KEY]; }; const side = this.line.type === 'new' ? 'right' : 'left'; const lines = getDiffLines(); diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 91c4c51487f..b3cdf138ac9 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -30,13 +30,11 @@ import { OLD_LINE_KEY, NEW_LINE_KEY, TYPE_KEY, - LEFT_LINE_KEY, MAX_RENDERING_DIFF_LINES, MAX_RENDERING_BULK_ROWS, MIN_RENDERING_MS, START_RENDERING_INDEX, INLINE_DIFF_LINES_KEY, - PARALLEL_DIFF_LINES_KEY, DIFFS_PER_PAGE, DIFF_WHITESPACE_COOKIE_NAME, SHOW_WHITESPACE, @@ -77,7 +75,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { const urlParams = { per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1', - view: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType, + view: 'inline', }; commit(types.SET_BATCH_LOADING, true); @@ -140,7 +138,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { export const fetchDiffFilesMeta = ({ commit, state }) => { const worker = new TreeWorker(); const urlParams = { - view: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType, + view: 'inline', }; commit(types.SET_LOADING, true); @@ -401,15 +399,10 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => { export const toggleFileDiscussionWrappers = ({ commit }, diff) => { const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff); const lineCodesWithDiscussions = new Set(); - const { parallel_diff_lines: parallelLines, highlighted_diff_lines: inlineLines } = diff; - const allLines = inlineLines.concat( - parallelLines.map(line => line.left), - parallelLines.map(line => line.right), - ); const lineHasDiscussion = line => Boolean(line?.discussions.length); const registerDiscussionLine = line => lineCodesWithDiscussions.add(line.line_code); - allLines.filter(lineHasDiscussion).forEach(registerDiscussionLine); + diff[INLINE_DIFF_LINES_KEY].filter(lineHasDiscussion).forEach(registerDiscussionLine); if (lineCodesWithDiscussions.size) { Array.from(lineCodesWithDiscussions).forEach(lineCode => { @@ -508,61 +501,26 @@ export const receiveFullDiffError = ({ commit }, filePath) => { createFlash(s__('MergeRequest|Error loading full diff. Please try again.')); }; -export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { - const expandedDiffLines = { - highlighted_diff_lines: convertExpandLines({ - diffLines: file.highlighted_diff_lines, - typeKey: TYPE_KEY, - oldLineKey: OLD_LINE_KEY, - newLineKey: NEW_LINE_KEY, - data, - mapLine: ({ line, oldLine, newLine }) => - Object.assign(line, { - old_line: oldLine, - new_line: newLine, - line_code: `${file.file_hash}_${oldLine}_${newLine}`, - }), - }), - parallel_diff_lines: convertExpandLines({ - diffLines: file.parallel_diff_lines, - typeKey: [LEFT_LINE_KEY, TYPE_KEY], - oldLineKey: [LEFT_LINE_KEY, OLD_LINE_KEY], - newLineKey: [LEFT_LINE_KEY, NEW_LINE_KEY], - data, - mapLine: ({ line, oldLine, newLine }) => ({ - left: { - ...line, - old_line: oldLine, - line_code: `${file.file_hash}_${oldLine}_${newLine}`, - }, - right: { - ...line, - new_line: newLine, - line_code: `${file.file_hash}_${newLine}_${oldLine}`, - }, +export const setExpandedDiffLines = ({ commit }, { file, data }) => { + const expandedDiffLines = convertExpandLines({ + diffLines: file[INLINE_DIFF_LINES_KEY], + typeKey: TYPE_KEY, + oldLineKey: OLD_LINE_KEY, + newLineKey: NEW_LINE_KEY, + data, + mapLine: ({ line, oldLine, newLine }) => + Object.assign(line, { + old_line: oldLine, + new_line: newLine, + line_code: `${file.file_hash}_${oldLine}_${newLine}`, }), - }), - }; - const unifiedDiffLinesEnabled = window.gon?.features?.unifiedDiffLines; - const currentDiffLinesKey = - state.diffViewType === INLINE_DIFF_VIEW_TYPE || unifiedDiffLinesEnabled - ? INLINE_DIFF_LINES_KEY - : PARALLEL_DIFF_LINES_KEY; - const hiddenDiffLinesKey = - state.diffViewType === INLINE_DIFF_VIEW_TYPE ? PARALLEL_DIFF_LINES_KEY : INLINE_DIFF_LINES_KEY; - - if (!unifiedDiffLinesEnabled) { - commit(types.SET_HIDDEN_VIEW_DIFF_FILE_LINES, { - filePath: file.file_path, - lines: expandedDiffLines[hiddenDiffLinesKey], - }); - } + }); - if (expandedDiffLines[currentDiffLinesKey].length > MAX_RENDERING_DIFF_LINES) { + if (expandedDiffLines.length > MAX_RENDERING_DIFF_LINES) { let index = START_RENDERING_INDEX; commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, - lines: expandedDiffLines[currentDiffLinesKey].slice(0, index), + lines: expandedDiffLines.slice(0, index), }); commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path); @@ -571,10 +529,10 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { while ( t.timeRemaining() >= MIN_RENDERING_MS && - index !== expandedDiffLines[currentDiffLinesKey].length && + index !== expandedDiffLines.length && index - startIndex !== MAX_RENDERING_BULK_ROWS ) { - const line = expandedDiffLines[currentDiffLinesKey][index]; + const line = expandedDiffLines[index]; if (line) { commit(types.ADD_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, line }); @@ -582,7 +540,7 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { } } - if (index !== expandedDiffLines[currentDiffLinesKey].length) { + if (index !== expandedDiffLines.length) { idleCallback(idleCb); } else { commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path); @@ -593,7 +551,7 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { } else { commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, - lines: expandedDiffLines[currentDiffLinesKey], + lines: expandedDiffLines, }); } }; @@ -627,7 +585,7 @@ export const toggleFullDiff = ({ dispatch, commit, getters, state }, filePath) = } }; -export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { diffFile }) { +export function switchToFullDiffFromRenamedFile({ commit, dispatch }, { diffFile }) { return axios .get(diffFile.context_lines_path, { params: { @@ -638,7 +596,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d .then(({ data }) => { const lines = data.map((line, index) => prepareLineForRenamedFile({ - diffViewType: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType, + diffViewType: 'inline', line, diffFile, index, diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 9ee73998177..baf54188932 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -1,6 +1,10 @@ import { __, n__ } from '~/locale'; import { parallelizeDiffLines } from './utils'; -import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants'; +import { + PARALLEL_DIFF_VIEW_TYPE, + INLINE_DIFF_VIEW_TYPE, + INLINE_DIFF_LINES_KEY, +} from '../constants'; export * from './getters_versions_dropdowns'; @@ -54,24 +58,10 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => diff => { * @param {Object} diff * @returns {Boolean} */ -export const diffHasExpandedDiscussions = state => diff => { - const lines = { - [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], - [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { - if (line.left) { - acc.push(line.left); - } - - if (line.right) { - acc.push(line.right); - } - - return acc; - }, []), - }; - return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType] - .filter(l => l.discussions.length >= 1) - .some(l => l.discussionsExpanded); +export const diffHasExpandedDiscussions = () => diff => { + return diff[INLINE_DIFF_LINES_KEY].filter(l => l.discussions.length >= 1).some( + l => l.discussionsExpanded, + ); }; /** @@ -79,24 +69,8 @@ export const diffHasExpandedDiscussions = state => diff => { * @param {Boolean} diff * @returns {Boolean} */ -export const diffHasDiscussions = state => diff => { - const lines = { - [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [], - [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => { - if (line.left) { - acc.push(line.left); - } - - if (line.right) { - acc.push(line.right); - } - - return acc; - }, []), - }; - return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType].some( - l => l.discussions.length >= 1, - ); +export const diffHasDiscussions = () => diff => { + return diff[INLINE_DIFF_LINES_KEY].some(l => l.discussions.length >= 1); }; /** diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 19a9e65edc9..3223d61e48b 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -35,7 +35,6 @@ export const RECEIVE_FULL_DIFF_SUCCESS = 'RECEIVE_FULL_DIFF_SUCCESS'; export const RECEIVE_FULL_DIFF_ERROR = 'RECEIVE_FULL_DIFF_ERROR'; export const SET_FILE_COLLAPSED = 'SET_FILE_COLLAPSED'; -export const SET_HIDDEN_VIEW_DIFF_FILE_LINES = 'SET_HIDDEN_VIEW_DIFF_FILE_LINES'; export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINES'; export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES'; export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 096c4f69439..c5bb2b40163 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -1,11 +1,6 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { - DIFF_FILE_MANUAL_COLLAPSE, - DIFF_FILE_AUTOMATIC_COLLAPSE, - INLINE_DIFF_VIEW_TYPE, -} from '../constants'; -import { findDiffFile, addLineReferences, removeMatchLine, @@ -14,6 +9,11 @@ import { isDiscussionApplicableToLine, updateLineInFile, } from './utils'; +import { + DIFF_FILE_MANUAL_COLLAPSE, + DIFF_FILE_AUTOMATIC_COLLAPSE, + INLINE_DIFF_LINES_KEY, +} from '../constants'; import * as types from './mutation_types'; function updateDiffFilesInState(state, files) { @@ -109,25 +109,7 @@ export default { if (!diffFile) return; - if (diffFile.highlighted_diff_lines.length) { - diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm; - } - - if (diffFile.parallel_diff_lines.length) { - const line = diffFile.parallel_diff_lines.find(l => { - const { left, right } = l; - - return (left && left.line_code === lineCode) || (right && right.line_code === lineCode); - }); - - if (line.left && line.left.line_code === lineCode) { - line.left.hasForm = hasForm; - } - - if (line.right && line.right.line_code === lineCode) { - line.right.hasForm = hasForm; - } - } + diffFile[INLINE_DIFF_LINES_KEY].find(l => l.line_code === lineCode).hasForm = hasForm; }, [types.ADD_CONTEXT_LINES](state, options) { @@ -157,11 +139,7 @@ export default { }); addContextLines({ - inlineLines: diffFile.highlighted_diff_lines, - parallelLines: diffFile.parallel_diff_lines, - diffViewType: window.gon?.features?.unifiedDiffLines - ? INLINE_DIFF_VIEW_TYPE - : state.diffViewType, + inlineLines: diffFile[INLINE_DIFF_LINES_KEY], contextLines: lines, bottom, lineNumbers, @@ -219,8 +197,8 @@ export default { state.diffFiles.forEach(file => { if (file.file_hash === fileHash) { - if (file.highlighted_diff_lines.length) { - file.highlighted_diff_lines.forEach(line => { + if (file[INLINE_DIFF_LINES_KEY].length) { + file[INLINE_DIFF_LINES_KEY].forEach(line => { Object.assign( line, setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line), @@ -228,25 +206,7 @@ export default { }); } - if (file.parallel_diff_lines.length) { - file.parallel_diff_lines.forEach(line => { - const left = line.left && lineCheck(line.left); - const right = line.right && lineCheck(line.right); - - if (left || right) { - Object.assign(line, { - left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null, - right: line.right - ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left)) - : null, - }); - } - - return line; - }); - } - - if (!file.parallel_diff_lines.length || !file.highlighted_diff_lines.length) { + if (!file[INLINE_DIFF_LINES_KEY].length) { const newDiscussions = (file.discussions || []) .filter(d => d.id !== discussion.id) .concat(discussion); @@ -369,31 +329,15 @@ export default { renderFile(file); } }, - [types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { - const file = state.diffFiles.find(f => f.file_path === filePath); - const hiddenDiffLinesKey = - state.diffViewType === 'inline' ? 'parallel_diff_lines' : 'highlighted_diff_lines'; - - file[hiddenDiffLinesKey] = lines; - }, [types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { const file = state.diffFiles.find(f => f.file_path === filePath); - let currentDiffLinesKey; - - if (window.gon?.features?.unifiedDiffLines || state.diffViewType === 'inline') { - currentDiffLinesKey = 'highlighted_diff_lines'; - } else { - currentDiffLinesKey = 'parallel_diff_lines'; - } - file[currentDiffLinesKey] = lines; + file[INLINE_DIFF_LINES_KEY] = lines; }, [types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, line }) { const file = state.diffFiles.find(f => f.file_path === filePath); - const currentDiffLinesKey = - state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines'; - file[currentDiffLinesKey].push(line); + file[INLINE_DIFF_LINES_KEY].push(line); }, [types.TOGGLE_DIFF_FILE_RENDERING_MORE](state, filePath) { const file = state.diffFiles.find(f => f.file_path === filePath); diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index f87f57c32c3..509a89d52f6 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -12,8 +12,7 @@ import { MATCH_LINE_TYPE, LINES_TO_BE_RENDERED_DIRECTLY, TREE_TYPE, - INLINE_DIFF_VIEW_TYPE, - PARALLEL_DIFF_VIEW_TYPE, + INLINE_DIFF_LINES_KEY, SHOW_WHITESPACE, NO_SHOW_WHITESPACE, } from '../constants'; @@ -178,43 +177,16 @@ export const findIndexInInlineLines = (lines, lineNumbers) => { ); }; -export const findIndexInParallelLines = (lines, lineNumbers) => { - const { oldLineNumber, newLineNumber } = lineNumbers; - - return lines.findIndex( - line => - line.left && - line.right && - line.left.old_line === oldLineNumber && - line.right.new_line === newLineNumber, - ); -}; - -const indexGettersByViewType = { - [INLINE_DIFF_VIEW_TYPE]: findIndexInInlineLines, - [PARALLEL_DIFF_VIEW_TYPE]: findIndexInParallelLines, -}; - export const getPreviousLineIndex = (diffViewType, file, lineNumbers) => { - const findIndex = indexGettersByViewType[diffViewType]; - const lines = { - [INLINE_DIFF_VIEW_TYPE]: file.highlighted_diff_lines, - [PARALLEL_DIFF_VIEW_TYPE]: file.parallel_diff_lines, - }; - - return findIndex && findIndex(lines[diffViewType], lineNumbers); + return findIndexInInlineLines(file[INLINE_DIFF_LINES_KEY], lineNumbers); }; export function removeMatchLine(diffFile, lineNumbers, bottom) { - const indexForInline = findIndexInInlineLines(diffFile.highlighted_diff_lines, lineNumbers); - const indexForParallel = findIndexInParallelLines(diffFile.parallel_diff_lines, lineNumbers); + const indexForInline = findIndexInInlineLines(diffFile[INLINE_DIFF_LINES_KEY], lineNumbers); const factor = bottom ? 1 : -1; if (indexForInline > -1) { - diffFile.highlighted_diff_lines.splice(indexForInline + factor, 1); - } - if (indexForParallel > -1) { - diffFile.parallel_diff_lines.splice(indexForParallel + factor, 1); + diffFile[INLINE_DIFF_LINES_KEY].splice(indexForInline + factor, 1); } } @@ -257,24 +229,6 @@ export function addLineReferences(lines, lineNumbers, bottom, isExpandDown, next return linesWithNumbers; } -function addParallelContextLines(options) { - const { parallelLines, contextLines, lineNumbers, isExpandDown } = options; - const normalizedParallelLines = contextLines.map(line => ({ - left: line, - right: line, - line_code: line.line_code, - })); - const factor = isExpandDown ? 1 : 0; - - if (!isExpandDown && options.bottom) { - parallelLines.push(...normalizedParallelLines); - } else { - const parallelIndex = findIndexInParallelLines(parallelLines, lineNumbers); - - parallelLines.splice(parallelIndex + factor, 0, ...normalizedParallelLines); - } -} - function addInlineContextLines(options) { const { inlineLines, contextLines, lineNumbers, isExpandDown } = options; const factor = isExpandDown ? 1 : 0; @@ -289,16 +243,7 @@ function addInlineContextLines(options) { } export function addContextLines(options) { - const { diffViewType } = options; - const contextLineHandlers = { - [INLINE_DIFF_VIEW_TYPE]: addInlineContextLines, - [PARALLEL_DIFF_VIEW_TYPE]: addParallelContextLines, - }; - const contextLineHandler = contextLineHandlers[diffViewType]; - - if (contextLineHandler) { - contextLineHandler(options); - } + addInlineContextLines(options); } /** @@ -324,41 +269,29 @@ export function trimFirstCharOfLineContent(line = {}) { return parsedLine; } -function getLineCode({ left, right }, index) { - if (left && left.line_code) { - return left.line_code; - } else if (right && right.line_code) { - return right.line_code; - } - return index; -} - function diffFileUniqueId(file) { return `${file.content_sha}-${file.file_hash}`; } function mergeTwoFiles(target, source) { - const originalInline = target.highlighted_diff_lines; - const originalParallel = target.parallel_diff_lines; + const originalInline = target[INLINE_DIFF_LINES_KEY]; const missingInline = !originalInline.length; - const missingParallel = !originalParallel.length; return { ...target, - highlighted_diff_lines: missingInline ? source.highlighted_diff_lines : originalInline, - parallel_diff_lines: missingParallel ? source.parallel_diff_lines : originalParallel, + [INLINE_DIFF_LINES_KEY]: missingInline ? source[INLINE_DIFF_LINES_KEY] : originalInline, + parallel_diff_lines: null, renderIt: source.renderIt, collapsed: source.collapsed, }; } function ensureBasicDiffFileLines(file) { - const missingInline = !file.highlighted_diff_lines; - const missingParallel = !file.parallel_diff_lines || window.gon?.features?.unifiedDiffLines; + const missingInline = !file[INLINE_DIFF_LINES_KEY]; Object.assign(file, { - highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines, - parallel_diff_lines: missingParallel ? [] : file.parallel_diff_lines, + [INLINE_DIFF_LINES_KEY]: missingInline ? [] : file[INLINE_DIFF_LINES_KEY], + parallel_diff_lines: null, }); return file; @@ -382,7 +315,7 @@ function prepareLine(line, file) { } } -export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index = 0 }) { +export function prepareLineForRenamedFile({ line, diffFile, index = 0 }) { /* Renamed files are a little different than other diffs, which is why this is distinct from `prepareDiffFileLines` below. @@ -407,48 +340,23 @@ export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index prepareLine(cleanLine, diffFile); // WARNING: In-Place Mutations! - if (diffViewType === PARALLEL_DIFF_VIEW_TYPE) { - return { - left: { ...cleanLine }, - right: { ...cleanLine }, - line_code: cleanLine.line_code, - }; - } - return cleanLine; } function prepareDiffFileLines(file) { - const inlineLines = file.highlighted_diff_lines; - const parallelLines = file.parallel_diff_lines; - let parallelLinesCount = 0; + const inlineLines = file[INLINE_DIFF_LINES_KEY]; inlineLines.forEach(line => prepareLine(line, file)); // WARNING: In-Place Mutations! - parallelLines.forEach((line, index) => { - Object.assign(line, { line_code: getLineCode(line, index) }); - - if (line.left) { - parallelLinesCount += 1; - prepareLine(line.left, file); // WARNING: In-Place Mutations! - } - - if (line.right) { - parallelLinesCount += 1; - prepareLine(line.right, file); // WARNING: In-Place Mutations! - } - }); - Object.assign(file, { inlineLinesCount: inlineLines.length, - parallelLinesCount, }); return file; } function getVisibleDiffLines(file) { - return Math.max(file.inlineLinesCount, file.parallelLinesCount); + return file.inlineLinesCount; } function finalizeDiffFile(file) { @@ -490,43 +398,14 @@ export function prepareDiffData(diff, priorFiles = []) { export function getDiffPositionByLineCode(diffFiles) { let lines = []; - const hasInlineDiffs = diffFiles.some(file => file.highlighted_diff_lines.length > 0); - - if (hasInlineDiffs) { - // In either of these cases, we can use `highlighted_diff_lines` because - // that will include all of the parallel diff lines, too - - lines = diffFiles.reduce((acc, diffFile) => { - diffFile.highlighted_diff_lines.forEach(line => { - acc.push({ file: diffFile, line }); - }); - - return acc; - }, []); - } else { - // If we're in single diff view mode and the inline lines haven't been - // loaded yet, we need to parse the parallel lines - - lines = diffFiles.reduce((acc, diffFile) => { - diffFile.parallel_diff_lines.forEach(pair => { - // It's possible for a parallel line to have an opposite line that doesn't exist - // For example: *deleted* lines will have `null` right lines, while - // *added* lines will have `null` left lines. - // So we have to check each line before we push it onto the array so we're not - // pushing null line diffs - - if (pair.left) { - acc.push({ file: diffFile, line: pair.left }); - } - if (pair.right) { - acc.push({ file: diffFile, line: pair.right }); - } - }); + lines = diffFiles.reduce((acc, diffFile) => { + diffFile[INLINE_DIFF_LINES_KEY].forEach(line => { + acc.push({ file: diffFile, line }); + }); - return acc; - }, []); - } + return acc; + }, []); return lines.reduce((acc, { file, line }) => { if (line.line_code) { @@ -739,24 +618,10 @@ export const convertExpandLines = ({ export const idleCallback = cb => requestIdleCallback(cb); function getLinesFromFileByLineCode(file, lineCode) { - const parallelLines = file.parallel_diff_lines; - const inlineLines = file.highlighted_diff_lines; + const inlineLines = file[INLINE_DIFF_LINES_KEY]; const matchesCode = line => line.line_code === lineCode; - return [ - ...parallelLines.reduce((acc, line) => { - if (line.left) { - acc.push(line.left); - } - - if (line.right) { - acc.push(line.right); - } - - return acc; - }, []), - ...inlineLines, - ].filter(matchesCode); + return inlineLines.filter(matchesCode); } export const updateLineInFile = (selectedFile, lineCode, updateFn) => { @@ -771,12 +636,7 @@ export const allDiscussionWrappersExpanded = diff => { } }; - diff.parallel_diff_lines.forEach(line => { - changeExpandedResult(line.left); - changeExpandedResult(line.right); - }); - - diff.highlighted_diff_lines.forEach(line => { + diff[INLINE_DIFF_LINES_KEY].forEach(line => { changeExpandedResult(line); }); diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue index bc35a07fe4a..2192d456861 100644 --- a/app/assets/javascripts/environments/components/environment_actions.vue +++ b/app/assets/javascripts/environments/components/environment_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { formatTime } from '~/lib/utils/datetime_utility'; import eventHub from '../event_hub'; @@ -9,7 +9,8 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - GlButton, + GlDropdown, + GlDropdownItem, GlIcon, GlLoadingIcon, }, @@ -35,7 +36,7 @@ export default { if (action.scheduledAt) { const confirmationMessage = sprintf( s__( - "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.", + 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.', ), { jobName: action.name }, ); @@ -67,40 +68,32 @@ export default { }; </script> <template> - <div class="btn-group" role="group"> - <gl-button - v-gl-tooltip - :title="title" - :aria-label="title" - :disabled="isLoading" - class="dropdown dropdown-new js-environment-actions-dropdown" - data-container="body" - data-toggle="dropdown" - data-testid="environment-actions-button" + <gl-dropdown + v-gl-tooltip + :title="title" + :aria-label="title" + :disabled="isLoading" + right + data-container="body" + data-testid="environment-actions-button" + > + <template #button-content> + <gl-icon name="play" /> + <gl-icon name="chevron-down" /> + <gl-loading-icon v-if="isLoading" /> + </template> + <gl-dropdown-item + v-for="(action, i) in actions" + :key="i" + :disabled="isActionDisabled(action)" + data-testid="manual-action-link" + @click="onClickAction(action)" > - <span> - <gl-icon name="play" /> - <gl-icon name="chevron-down" /> - <gl-loading-icon v-if="isLoading" /> + <span class="gl-flex-fill-1">{{ action.name }}</span> + <span v-if="action.scheduledAt" class="gl-text-gray-500 float-right"> + <gl-icon name="clock" /> + {{ remainingTime(action) }} </span> - </gl-button> - - <ul class="dropdown-menu dropdown-menu-right"> - <li v-for="(action, i) in actions" :key="i" class="gl-display-flex"> - <gl-button - :class="{ disabled: isActionDisabled(action) }" - :disabled="isActionDisabled(action)" - variant="link" - class="js-manual-action-link gl-flex-fill-1" - @click="onClickAction(action)" - > - <span class="gl-flex-fill-1">{{ action.name }}</span> - <span v-if="action.scheduledAt" class="text-secondary float-right"> - <gl-icon name="clock" /> - {{ remainingTime(action) }} - </span> - </gl-button> - </li> - </ul> - </div> + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 6e99b6ad4fa..ef58b93c049 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -74,6 +74,9 @@ export default { visibilityTooltip() { return GROUP_VISIBILITY_TYPE[this.group.visibility]; }, + microdata() { + return this.group.microdata || {}; + }, }, mounted() { if (this.group.name === 'Learn GitLab') { @@ -99,7 +102,15 @@ export default { </script> <template> - <li :id="groupDomId" :class="rowClass" class="group-row" @click.stop="onClickRowGroup"> + <li + :id="groupDomId" + :class="rowClass" + class="group-row" + :itemprop="microdata.itemprop" + :itemtype="microdata.itemtype" + :itemscope="microdata.itemscope" + @click.stop="onClickRowGroup" + > <div :class="{ 'project-row-contents': !isGroup }" class="group-row-contents d-flex align-items-center py-2 pr-3" @@ -118,7 +129,13 @@ export default { class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 " > <a :href="group.relativePath" class="no-expand"> - <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" /> + <img + v-if="hasAvatar" + :src="group.avatarUrl" + data-testid="group-avatar" + class="avatar s40" + :itemprop="microdata.imageItemprop" + /> <identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" /> </a> </div> @@ -127,9 +144,11 @@ export default { <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3"> <a v-gl-tooltip.bottom + data-testid="group-name" :href="group.relativePath" :title="group.fullName" class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!" + :itemprop="microdata.nameItemprop" >{{ // ending bracket must be by closing tag to prevent // link hover text-decoration from over-extending @@ -146,7 +165,12 @@ export default { </span> </div> <div v-if="group.description" class="description"> - <span v-html="group.description"> </span> + <span + :itemprop="microdata.descriptionItemprop" + data-testid="group-description" + v-html="group.description" + > + </span> </div> </div> <div v-if="isGroupPendingRemoval"> diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index 522f1d16df2..e11c3aaf984 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -47,8 +47,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { data() { const { dataset } = dataEl || this.$options.el; const hideProjects = parseBoolean(dataset.hideProjects); + const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup); const service = new GroupsService(endpoint || dataset.endpoint); - const store = new GroupsStore(hideProjects); + const store = new GroupsStore({ hideProjects, showSchemaMarkup }); return { action, diff --git a/app/assets/javascripts/groups/members/components/app.vue b/app/assets/javascripts/groups/members/components/app.vue index 2e6dd4a0bad..8f1bb6e8094 100644 --- a/app/assets/javascripts/groups/members/components/app.vue +++ b/app/assets/javascripts/groups/members/components/app.vue @@ -1,9 +1,9 @@ <script> import { mapState, mapMutations } from 'vuex'; import { GlAlert } from '@gitlab/ui'; -import MembersTable from '~/vue_shared/components/members/table/members_table.vue'; +import MembersTable from '~/members/components/table/members_table.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; -import { HIDE_ERROR } from '~/vuex_shared/modules/members/mutation_types'; +import { HIDE_ERROR } from '~/members/store/mutation_types'; export default { name: 'GroupMembersApp', diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js index cb28fb057c9..68caf6628f6 100644 --- a/app/assets/javascripts/groups/members/index.js +++ b/app/assets/javascripts/groups/members/index.js @@ -3,7 +3,7 @@ import Vuex from 'vuex'; import { GlToast } from '@gitlab/ui'; import { parseDataAttributes } from 'ee_else_ce/groups/members/utils'; import App from './components/app.vue'; -import membersModule from '~/vuex_shared/modules/members'; +import membersStore from '~/members/store'; export const initGroupMembersApp = (el, tableFields, tableAttrs, requestFormatter) => { if (!el) { @@ -13,15 +13,15 @@ export const initGroupMembersApp = (el, tableFields, tableAttrs, requestFormatte Vue.use(Vuex); Vue.use(GlToast); - const store = new Vuex.Store({ - ...membersModule({ + const store = new Vuex.Store( + membersStore({ ...parseDataAttributes(el), currentUserId: gon.current_user_id || null, tableFields, tableAttrs, requestFormatter, }), - }); + ); return new Vue({ el, diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js index 6a1197fa163..b6cea38e87f 100644 --- a/app/assets/javascripts/groups/store/groups_store.js +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -1,11 +1,13 @@ import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils'; +import { getGroupItemMicrodata } from './utils'; export default class GroupsStore { - constructor(hideProjects) { + constructor({ hideProjects = false, showSchemaMarkup = false } = {}) { this.state = {}; this.state.groups = []; this.state.pageInfo = {}; this.hideProjects = hideProjects; + this.showSchemaMarkup = showSchemaMarkup; } setGroups(rawGroups) { @@ -94,6 +96,7 @@ export default class GroupsStore { starCount: rawGroupItem.star_count, updatedAt: rawGroupItem.updated_at, pendingRemoval: rawGroupItem.marked_for_deletion, + microdata: this.showSchemaMarkup ? getGroupItemMicrodata(rawGroupItem) : {}, }; } diff --git a/app/assets/javascripts/groups/store/utils.js b/app/assets/javascripts/groups/store/utils.js new file mode 100644 index 00000000000..371b3aa9d52 --- /dev/null +++ b/app/assets/javascripts/groups/store/utils.js @@ -0,0 +1,27 @@ +export const getGroupItemMicrodata = ({ type }) => { + const defaultMicrodata = { + itemscope: true, + itemtype: 'https://schema.org/Thing', + itemprop: 'owns', + imageItemprop: 'image', + nameItemprop: 'name', + descriptionItemprop: 'description', + }; + + switch (type) { + case 'group': + return { + ...defaultMicrodata, + itemtype: 'https://schema.org/Organization', + itemprop: 'subOrganization', + imageItemprop: 'logo', + }; + case 'project': + return { + ...defaultMicrodata, + itemtype: 'https://schema.org/SoftwareSourceCode', + }; + default: + return defaultMicrodata; + } +}; diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index aac23db8fd6..29af8c77d25 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -4,97 +4,107 @@ import axios from './lib/utils/axios_utils'; import Api from './api'; import { normalizeHeaders } from './lib/utils/common_utils'; import { __ } from '~/locale'; +import { loadCSSFile } from './lib/utils/css_utils'; + +const fetchGroups = params => { + axios[params.type.toLowerCase()](params.url, { + params: params.data, + }) + .then(res => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); + const currentPage = parseInt(headers['X-PAGE'], 10) || 0; + const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; + const more = currentPage < totalPages; + + params.success({ + results, + pagination: { + more, + }, + }); + }) + .catch(params.error); +}; const groupsSelect = () => { - // Needs to be accessible in rspec - window.GROUP_SELECT_PER_PAGE = 20; - $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { - const $select = $(this); - const allAvailable = $select.data('allAvailable'); - const skipGroups = $select.data('skipGroups') || []; - const parentGroupID = $select.data('parentId'); - const groupsPath = parentGroupID - ? Api.subgroupsPath.replace(':id', parentGroupID) - : Api.groupsPath; + loadCSSFile(gon.select2_css_path) + .then(() => { + // Needs to be accessible in rspec + window.GROUP_SELECT_PER_PAGE = 20; - $select.select2({ - placeholder: __('Search for a group'), - allowClear: $select.hasClass('allowClear'), - multiple: $select.hasClass('multiselect'), - minimumInputLength: 0, - ajax: { - url: Api.buildUrl(groupsPath), - dataType: 'json', - quietMillis: 250, - transport(params) { - axios[params.type.toLowerCase()](params.url, { - params: params.data, - }) - .then(res => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const currentPage = parseInt(headers['X-PAGE'], 10) || 0; - const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; - const more = currentPage < totalPages; + $('.ajax-groups-select').each(function setAjaxGroupsSelect2() { + const $select = $(this); + const allAvailable = $select.data('allAvailable'); + const skipGroups = $select.data('skipGroups') || []; + const parentGroupID = $select.data('parentId'); + const groupsPath = parentGroupID + ? Api.subgroupsPath.replace(':id', parentGroupID) + : Api.groupsPath; - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); - }, - data(search, page) { - return { - search, - page, - per_page: window.GROUP_SELECT_PER_PAGE, - all_available: allAvailable, - }; - }, - results(data, page) { - if (data.length) return { results: [] }; + $select.select2({ + placeholder: __('Search for a group'), + allowClear: $select.hasClass('allowClear'), + multiple: $select.hasClass('multiselect'), + minimumInputLength: 0, + ajax: { + url: Api.buildUrl(groupsPath), + dataType: 'json', + quietMillis: 250, + transport(params) { + fetchGroups(params); + }, + data(search, page) { + return { + search, + page, + per_page: window.GROUP_SELECT_PER_PAGE, + all_available: allAvailable, + }; + }, + results(data, page) { + if (data.length) return { results: [] }; - const groups = data.length ? data : data.results || []; - const more = data.pagination ? data.pagination.more : false; - const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); + const groups = data.length ? data : data.results || []; + const more = data.pagination ? data.pagination.more : false; + const results = groups.filter(group => skipGroups.indexOf(group.id) === -1); - return { - results, - page, - more, - }; - }, - }, - // eslint-disable-next-line consistent-return - initSelection(element, callback) { - const id = $(element).val(); - if (id !== '') { - return Api.group(id, callback); - } - }, - formatResult(object) { - return `<div class='group-result'> <div class='group-name'>${escape( - object.full_name, - )}</div> <div class='group-path'>${object.full_path}</div> </div>`; - }, - formatSelection(object) { - return escape(object.full_name); - }, - dropdownCssClass: 'ajax-groups-dropdown select2-infinite', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); + return { + results, + page, + more, + }; + }, + }, + // eslint-disable-next-line consistent-return + initSelection(element, callback) { + const id = $(element).val(); + if (id !== '') { + return Api.group(id, callback); + } + }, + formatResult(object) { + return `<div class='group-result'> <div class='group-name'>${escape( + object.full_name, + )}</div> <div class='group-path'>${object.full_path}</div> </div>`; + }, + formatSelection(object) { + return escape(object.full_name); + }, + dropdownCssClass: 'ajax-groups-dropdown select2-infinite', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, + }); - $select.on('select2-loaded', () => { - const dropdown = document.querySelector('.select2-infinite .select2-results'); - dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; - }); - }); + $select.on('select2-loaded', () => { + const dropdown = document.querySelector('.select2-infinite .select2-results'); + dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`; + }); + }); + }) + .catch(() => {}); }; export default () => { diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue index dec8aa61838..52593aabfea 100644 --- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue +++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue @@ -1,11 +1,12 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { viewerTypes } from '../constants'; export default { components: { - GlButton, + GlDropdown, + GlDropdownItem, }, props: { viewer: { @@ -18,10 +19,21 @@ export default { }, }, computed: { - mergeReviewLine() { - return sprintf(__('Reviewing (merge request !%{mergeRequestId})'), { - mergeRequestId: this.mergeRequestId, - }); + modeDropdownItems() { + return [ + { + viewerType: this.$options.viewerTypes.mr, + title: sprintf(__('Reviewing (merge request !%{mergeRequestId})'), { + mergeRequestId: this.mergeRequestId, + }), + content: __('Compare changes with the merge request target branch'), + }, + { + viewerType: this.$options.viewerTypes.diff, + title: __('Reviewing'), + content: __('Compare changes with the last commit'), + }, + ]; }, }, methods: { @@ -34,39 +46,16 @@ export default { </script> <template> - <div class="dropdown"> - <gl-button variant="link" data-toggle="dropdown">{{ __('Edit') }}</gl-button> - <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left"> - <ul> - <li> - <a - :class="{ - 'is-active': viewer === $options.viewerTypes.mr, - }" - href="#" - @click.prevent="changeMode($options.viewerTypes.mr)" - > - <strong class="dropdown-menu-inner-title"> {{ mergeReviewLine }} </strong> - <span class="dropdown-menu-inner-content"> - {{ __('Compare changes with the merge request target branch') }} - </span> - </a> - </li> - <li> - <a - :class="{ - 'is-active': viewer === $options.viewerTypes.diff, - }" - href="#" - @click.prevent="changeMode($options.viewerTypes.diff)" - > - <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong> - <span class="dropdown-menu-inner-content"> - {{ __('Compare changes with the last commit') }} - </span> - </a> - </li> - </ul> - </div> - </div> + <gl-dropdown :text="__('Edit')" size="small"> + <gl-dropdown-item + v-for="mode in modeDropdownItems" + :key="mode.viewerType" + :is-check-item="true" + :is-checked="viewer === mode.viewerType" + @click="changeMode(mode.viewerType)" + > + <strong class="dropdown-menu-inner-title"> {{ mode.title }} </strong> + <span class="dropdown-menu-inner-content"> {{ mode.content }} </span> + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index e1d2895831a..f1dc855362b 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -5,10 +5,8 @@ import { WEBIDE_MARK_APP_START, WEBIDE_MARK_FILE_FINISH, WEBIDE_MARK_FILE_CLICKED, - WEBIDE_MARK_TREE_FINISH, - WEBIDE_MEASURE_TREE_FROM_REQUEST, - WEBIDE_MEASURE_FILE_FROM_REQUEST, WEBIDE_MEASURE_FILE_AFTER_INTERACTION, + WEBIDE_MEASURE_BEFORE_VUE, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import { modalTypes } from '../constants'; @@ -19,12 +17,6 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { measurePerformance } from '../utils'; -eventHub.$on(WEBIDE_MEASURE_TREE_FROM_REQUEST, () => - measurePerformance(WEBIDE_MARK_TREE_FINISH, WEBIDE_MEASURE_TREE_FROM_REQUEST), -); -eventHub.$on(WEBIDE_MEASURE_FILE_FROM_REQUEST, () => - measurePerformance(WEBIDE_MARK_FILE_FINISH, WEBIDE_MEASURE_FILE_FROM_REQUEST), -); eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () => measurePerformance( WEBIDE_MARK_FILE_FINISH, @@ -84,7 +76,14 @@ export default { document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`); }, beforeCreate() { - performanceMarkAndMeasure({ mark: WEBIDE_MARK_APP_START }); + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_APP_START, + measures: [ + { + name: WEBIDE_MEASURE_BEFORE_VUE, + }, + ], + }); }, methods: { ...mapActions(['toggleFileFinder']), diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index e7e94f5b5da..b67881b14f4 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -2,17 +2,13 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import FileTree from '~/vue_shared/components/file_tree.vue'; -import { - WEBIDE_MARK_TREE_START, - WEBIDE_MEASURE_TREE_FROM_REQUEST, - WEBIDE_MARK_FILE_CLICKED, -} from '~/performance/constants'; +import { WEBIDE_MARK_FILE_CLICKED } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; -import eventHub from '../eventhub'; import IdeFileRow from './ide_file_row.vue'; import NavDropdown from './nav_dropdown.vue'; export default { + name: 'IdeTreeList', components: { GlSkeletonLoading, NavDropdown, @@ -39,14 +35,6 @@ export default { } }, }, - beforeCreate() { - performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START }); - }, - updated() { - if (this.currentTree?.tree?.length) { - eventHub.$emit(WEBIDE_MEASURE_TREE_FROM_REQUEST); - } - }, methods: { ...mapActions(['toggleTreeOpen']), clickedFile() { diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue index 2307efd1d24..a2338c6dec5 100644 --- a/app/assets/javascripts/ide/components/nav_dropdown.vue +++ b/app/assets/javascripts/ide/components/nav_dropdown.vue @@ -45,7 +45,7 @@ export default { </script> <template> - <div ref="dropdown" class="btn-group ide-nav-dropdown dropdown"> + <div ref="dropdown" class="btn-group ide-nav-dropdown dropdown" data-testid="ide-nav-dropdown"> <nav-dropdown-button :show-merge-requests="canReadMergeRequests" /> <div class="dropdown-menu dropdown-menu-left p-0"> <nav-form v-if="isVisibleDropdown" :show-merge-requests="canReadMergeRequests" /> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index c8a825065f1..1f029612c29 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -6,9 +6,10 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { WEBIDE_MARK_FILE_CLICKED, - WEBIDE_MARK_FILE_START, + WEBIDE_MARK_REPO_EDITOR_START, + WEBIDE_MARK_REPO_EDITOR_FINISH, + WEBIDE_MEASURE_REPO_EDITOR, WEBIDE_MEASURE_FILE_AFTER_INTERACTION, - WEBIDE_MEASURE_FILE_FROM_REQUEST, } from '~/performance/constants'; import { performanceMarkAndMeasure } from '~/performance/utils'; import eventHub from '../eventhub'; @@ -28,6 +29,7 @@ import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; export default { + name: 'RepoEditor', components: { ContentViewer, DiffViewer, @@ -175,9 +177,6 @@ export default { } }, }, - beforeCreate() { - performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_START }); - }, beforeDestroy() { this.editor.dispose(); }, @@ -204,6 +203,7 @@ export default { ]), ...mapActions('editor', ['updateFileEditor']), initEditor() { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_REPO_EDITOR_START }); if (this.shouldHideEditor && (this.file.content || this.file.raw)) { return; } @@ -305,7 +305,15 @@ export default { if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) { eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION); } else { - eventHub.$emit(WEBIDE_MEASURE_FILE_FROM_REQUEST); + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_REPO_EDITOR_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_REPO_EDITOR, + start: WEBIDE_MARK_REPO_EDITOR_START, + }, + ], + }); } }, refreshEditorDimensions() { diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue index a8fe9ea6866..0e67a2ab45f 100644 --- a/app/assets/javascripts/ide/components/terminal/session.vue +++ b/app/assets/javascripts/ide/components/terminal/session.vue @@ -1,5 +1,6 @@ <script> import { mapActions, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; import Terminal from './terminal.vue'; import { isEndingStatus } from '../../stores/modules/terminal/utils'; @@ -7,6 +8,7 @@ import { isEndingStatus } from '../../stores/modules/terminal/utils'; export default { components: { Terminal, + GlButton, }, computed: { ...mapState('terminal', ['session']), @@ -14,15 +16,17 @@ export default { if (isEndingStatus(this.session.status)) { return { action: () => this.restartSession(), + variant: 'info', + category: 'primary', text: __('Restart Terminal'), - class: 'btn-primary', }; } return { action: () => this.stopSession(), + variant: 'danger', + category: 'secondary', text: __('Stop Terminal'), - class: 'btn-inverted btn-remove', }; }, }, @@ -37,15 +41,13 @@ export default { <header class="ide-job-header d-flex align-items-center"> <h5>{{ __('Web Terminal') }}</h5> <div class="ml-auto align-self-center"> - <button + <gl-button v-if="actionButton" - type="button" - class="btn btn-sm" - :class="actionButton.class" + :variant="actionButton.variant" + :category="actionButton.category" @click="actionButton.action" + >{{ actionButton.text }}</gl-button > - {{ actionButton.text }} - </button> </div> </header> <terminal :terminal-path="session.terminalPath" :status="session.status" /> diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 396aedbfa10..b9ebacef7e1 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -3,6 +3,12 @@ import IdeRouter from '~/ide/ide_router_extension'; import { joinPaths } from '~/lib/utils/url_utility'; import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { + WEBIDE_MARK_FETCH_PROJECT_DATA_START, + WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, + WEBIDE_MEASURE_FETCH_PROJECT_DATA, +} from '~/performance/constants'; import { syncRouterAndStore } from './sync_router_and_store'; Vue.use(IdeRouter); @@ -69,6 +75,7 @@ export const createRouter = store => { router.beforeEach((to, from, next) => { if (to.params.namespace && to.params.project) { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START }); store .dispatch('getProjectData', { namespace: to.params.namespace, @@ -81,6 +88,15 @@ export const createRouter = store => { const mergeRequestId = to.params.mrid; if (branchId) { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_PROJECT_DATA, + start: WEBIDE_MARK_FETCH_PROJECT_DATA_START, + }, + ], + }); store.dispatch('openBranch', { projectId, branchId, diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 56d48e87c18..62f49ba56b1 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import { mapActions } from 'vuex'; import { identity } from 'lodash'; import Translate from '~/vue_shared/translate'; +import PerformancePlugin from '~/performance/vue_performance_plugin'; import ide from './components/ide.vue'; import { createStore } from './stores'; import { createRouter } from './ide_router'; @@ -11,6 +12,10 @@ import { DEFAULT_THEME } from './lib/themes'; Vue.use(Translate); +Vue.use(PerformancePlugin, { + components: ['FileTree'], +}); + /** * Function that receives the default store and returns an extended one. * @callback extendStoreCallback diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 1496170447d..710256b6377 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -3,6 +3,12 @@ import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import { deprecatedCreateFlash as flash } from '~/flash'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { + WEBIDE_MARK_FETCH_BRANCH_DATA_START, + WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH, + WEBIDE_MEASURE_FETCH_BRANCH_DATA, +} from '~/performance/constants'; import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; import { stageKeys, commitActionTypes } from '../constants'; @@ -245,13 +251,23 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name, dispatch('triggerFilesChange', { type: commitActionTypes.move, path, newPath }); }; -export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => - new Promise((resolve, reject) => { +export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => { + return new Promise((resolve, reject) => { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_BRANCH_DATA_START }); const currentProject = state.projects[projectId]; if (!currentProject || !currentProject.branches[branchId] || force) { service .getBranchData(projectId, branchId) .then(({ data }) => { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_BRANCH_DATA, + start: WEBIDE_MARK_FETCH_BRANCH_DATA_START, + }, + ], + }); const { id } = data.commit; commit(types.SET_BRANCH, { projectPath: projectId, @@ -291,6 +307,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force = resolve(currentProject.branches[branchId]); } }); +}; export * from './actions/tree'; export * from './actions/file'; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 4b9b958ddd6..8b43c7238fd 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,5 +1,11 @@ import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { + WEBIDE_MARK_FETCH_FILE_DATA_START, + WEBIDE_MARK_FETCH_FILE_DATA_FINISH, + WEBIDE_MEASURE_FETCH_FILE_DATA, +} from '~/performance/constants'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; @@ -61,6 +67,7 @@ export const getFileData = ( { state, commit, dispatch, getters }, { path, makeFileActive = true, openFile = makeFileActive, toggleLoading = true }, ) => { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_FILE_DATA_START }); const file = state.entries[path]; const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path); @@ -81,6 +88,15 @@ export const getFileData = ( return service .getFileData(url) .then(({ data }) => { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_FILE_DATA_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_FILE_DATA, + start: WEBIDE_MARK_FETCH_FILE_DATA_START, + }, + ], + }); if (data) commit(types.SET_FILE_DATA, { data, file }); if (openFile) commit(types.TOGGLE_FILE_OPEN, path); @@ -150,6 +166,13 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = export const changeFileContent = ({ commit, state, getters }, { path, content }) => { const file = state.entries[path]; + + // It's possible for monaco to hit a race condition where it tries to update renamed files. + // See issue https://gitlab.com/gitlab-org/gitlab/-/issues/284930 + if (!file) { + return; + } + commit(types.UPDATE_FILE_CONTENT, { path, content, diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 3a7daf30cc4..23a5e26bc1c 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -1,4 +1,10 @@ import { defer } from 'lodash'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { + WEBIDE_MARK_FETCH_FILES_FINISH, + WEBIDE_MEASURE_FETCH_FILES, + WEBIDE_MARK_FETCH_FILES_START, +} from '~/performance/constants'; import { __ } from '../../../locale'; import service from '../../services'; import * as types from '../mutation_types'; @@ -46,8 +52,9 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL }); }; -export const getFiles = ({ state, commit, dispatch }, payload = {}) => - new Promise((resolve, reject) => { +export const getFiles = ({ state, commit, dispatch }, payload = {}) => { + performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_FILES_START }); + return new Promise((resolve, reject) => { const { projectId, branchId, ref = branchId } = payload; if ( @@ -61,6 +68,15 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) => service .getFiles(selectedProject.path_with_namespace, ref) .then(({ data }) => { + performanceMarkAndMeasure({ + mark: WEBIDE_MARK_FETCH_FILES_FINISH, + measures: [ + { + name: WEBIDE_MEASURE_FETCH_FILES, + start: WEBIDE_MARK_FETCH_FILES_START, + }, + ], + }); const { entries, treeList } = decorateFiles({ data }); commit(types.SET_ENTRIES, entries); @@ -85,6 +101,7 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) => resolve(); } }); +}; export const restoreTree = ({ dispatch, commit, state }, path) => { const entry = state.entries[path]; diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 1d0814125e6..14d6f133d27 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -35,12 +35,14 @@ export default class IntegrationSettingsForm { } saveIntegration() { - // Service was marked active so now we check; + // Save Service if not active and check the following if active; // 1) If form contents are valid // 2) If this service can be saved // If both conditions are true, we override form submission // and save the service using provided configuration. - if (this.$form.get(0).checkValidity()) { + const formValid = this.$form.get(0).checkValidity() || this.formActive === false; + + if (formValid) { this.$form.submit(); } else { eventHub.$emit('validateForm'); diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js index e0fb58ef195..12f03873958 100644 --- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js +++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js @@ -1,4 +1,5 @@ import $ from 'jquery'; +import { loadCSSFile } from '../lib/utils/css_utils'; let instanceCount = 0; @@ -13,10 +14,15 @@ class AutoWidthDropdownSelect { const { dropdownClass } = this; import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - this.$selectElement.select2({ - dropdownCssClass: dropdownClass, - ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), - }); + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + this.$selectElement.select2({ + dropdownCssClass: dropdownClass, + ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass), + }); + }) + .catch(() => {}); }) .catch(() => {}); diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js index 6f2bd2da078..2072e41514d 100644 --- a/app/assets/javascripts/issuable_context.js +++ b/app/assets/javascripts/issuable_context.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import UsersSelect from './users_select'; +import { loadCSSFile } from './lib/utils/css_utils'; export default class IssuableContext { constructor(currentUser) { @@ -10,10 +11,15 @@ export default class IssuableContext { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $('select.select2').select2({ - width: 'resolve', - dropdownAutoWidth: true, - }); + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + $('select.select2').select2({ + width: 'resolve', + dropdownAutoWidth: true, + }); + }) + .catch(() => {}); }) .catch(() => {}); diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index ed34e2f5623..791b5fef699 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -7,6 +7,7 @@ import ZenMode from './zen_mode'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; import { queryToObject, objectToQuery } from './lib/utils/url_utility'; +import { loadCSSFile } from './lib/utils/css_utils'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; @@ -184,36 +185,41 @@ export default class IssuableForm { initTargetBranchDropdown() { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - this.$targetBranchSelect.select2({ - ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), - ajax: { - url: this.$targetBranchSelect.data('endpoint'), - dataType: 'JSON', - quietMillis: 250, - data(search) { - return { - search, - }; - }, - results(data) { - return { - // `data` keys are translated so we can't just access them with a string based key - results: data[Object.keys(data)[0]].map(name => ({ - id: name, - text: name, - })), - }; - }, - }, - initSelection(el, callback) { - const val = el.val(); - - callback({ - id: val, - text: val, + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + this.$targetBranchSelect.select2({ + ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'), + ajax: { + url: this.$targetBranchSelect.data('endpoint'), + dataType: 'JSON', + quietMillis: 250, + data(search) { + return { + search, + }; + }, + results(data) { + return { + // `data` keys are translated so we can't just access them with a string based key + results: data[Object.keys(data)[0]].map(name => ({ + id: name, + text: name, + })), + }; + }, + }, + initSelection(el, callback) { + const val = el.val(); + + callback({ + id: val, + text: val, + }); + }, }); - }, - }); + }) + .catch(() => {}); }) .catch(() => {}); } diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 1ee794ab208..583e5cb703d 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -128,7 +128,7 @@ export default { <template> <li class="issue gl-px-5!"> - <div class="issue-box"> + <div class="issuable-info-container"> <div v-if="showCheckbox" class="issue-check"> <gl-form-checkbox class="gl-mr-0" @@ -136,101 +136,99 @@ export default { @input="$emit('checked-input', $event)" /> </div> - <div class="issuable-info-container"> - <div class="issuable-main-info"> - <div data-testid="issuable-title" class="issue-title title"> - <span class="issue-title-text" dir="auto"> - <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps" - >{{ issuable.title - }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" - /></gl-link> - </span> - </div> - <div class="issuable-info"> - <slot v-if="hasSlotContents('reference')" name="reference"></slot> - <span v-else data-testid="issuable-reference" class="issuable-reference" - >{{ issuableSymbol }}{{ issuable.iid }}</span - > - <span class="issuable-authored d-none d-sm-inline-block"> - · - <span - v-gl-tooltip:tooltipcontainer.bottom - data-testid="issuable-created-at" - :title="tooltipTitle(issuable.createdAt)" - >{{ createdAt }}</span - > - {{ __('by') }} - <slot v-if="hasSlotContents('author')" name="author"></slot> - <gl-link - v-else - :data-user-id="authorId" - :data-username="author.username" - :data-name="author.name" - :data-avatar-url="author.avatarUrl" - :href="author.webUrl" - data-testid="issuable-author" - class="author-link js-user-link" - > - <span class="author">{{ author.name }}</span> - </gl-link> - </span> - <slot name="timeframe"></slot> - - <gl-label - v-for="(label, index) in labels" - :key="index" - :background-color="label.color" - :title="labelTitle(label)" - :description="label.description" - :scoped="scopedLabel(label)" - :target="labelTarget(label)" - :class="{ 'gl-ml-2': index }" - size="sm" - /> - </div> + <div class="issuable-main-info"> + <div data-testid="issuable-title" class="issue-title title"> + <span class="issue-title-text" dir="auto"> + <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps" + >{{ issuable.title + }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" + /></gl-link> + </span> </div> - <div class="issuable-meta"> - <ul v-if="showIssuableMeta" class="controls"> - <li v-if="hasSlotContents('status')" class="issuable-status"> - <slot name="status"></slot> - </li> - <li - v-if="showDiscussions" - data-testid="issuable-discussions" - class="issuable-comments gl-display-none gl-display-sm-block" - > - <gl-link - v-gl-tooltip:tooltipcontainer.top - :title="__('Comments')" - :href="`${issuable.webUrl}#notes`" - :class="{ 'no-comments': !issuable.userDiscussionsCount }" - class="gl-reset-color!" - > - <gl-icon name="comments" /> - {{ issuable.userDiscussionsCount }} - </gl-link> - </li> - <li v-if="assignees.length" class="gl-display-flex"> - <issuable-assignees - :assignees="issuable.assignees" - :icon-size="16" - :max-visible="4" - img-css-classes="gl-mr-2!" - class="gl-align-items-center gl-display-flex gl-ml-3" - /> - </li> - </ul> - <div - data-testid="issuable-updated-at" - class="float-right issuable-updated-at d-none d-sm-inline-block" + <div class="issuable-info"> + <slot v-if="hasSlotContents('reference')" name="reference"></slot> + <span v-else data-testid="issuable-reference" class="issuable-reference" + >{{ issuableSymbol }}{{ issuable.iid }}</span > + <span class="issuable-authored d-none d-sm-inline-block"> + · <span v-gl-tooltip:tooltipcontainer.bottom - :title="tooltipTitle(issuable.updatedAt)" - class="issuable-updated-at" - >{{ updatedAt }}</span + data-testid="issuable-created-at" + :title="tooltipTitle(issuable.createdAt)" + >{{ createdAt }}</span + > + {{ __('by') }} + <slot v-if="hasSlotContents('author')" name="author"></slot> + <gl-link + v-else + :data-user-id="authorId" + :data-username="author.username" + :data-name="author.name" + :data-avatar-url="author.avatarUrl" + :href="author.webUrl" + data-testid="issuable-author" + class="author-link js-user-link" + > + <span class="author">{{ author.name }}</span> + </gl-link> + </span> + <slot name="timeframe"></slot> + + <gl-label + v-for="(label, index) in labels" + :key="index" + :background-color="label.color" + :title="labelTitle(label)" + :description="label.description" + :scoped="scopedLabel(label)" + :target="labelTarget(label)" + :class="{ 'gl-ml-2': index }" + size="sm" + /> + </div> + </div> + <div class="issuable-meta"> + <ul v-if="showIssuableMeta" class="controls"> + <li v-if="hasSlotContents('status')" class="issuable-status"> + <slot name="status"></slot> + </li> + <li + v-if="showDiscussions" + data-testid="issuable-discussions" + class="issuable-comments gl-display-none gl-display-sm-block" + > + <gl-link + v-gl-tooltip:tooltipcontainer.top + :title="__('Comments')" + :href="`${issuable.webUrl}#notes`" + :class="{ 'no-comments': !issuable.userDiscussionsCount }" + class="gl-reset-color!" > - </div> + <gl-icon name="comments" /> + {{ issuable.userDiscussionsCount }} + </gl-link> + </li> + <li v-if="assignees.length" class="gl-display-flex"> + <issuable-assignees + :assignees="issuable.assignees" + :icon-size="16" + :max-visible="4" + img-css-classes="gl-mr-2!" + class="gl-align-items-center gl-display-flex gl-ml-3" + /> + </li> + </ul> + <div + data-testid="issuable-updated-at" + class="float-right issuable-updated-at d-none d-sm-inline-block" + > + <span + v-gl-tooltip:tooltipcontainer.bottom + :title="tooltipTitle(issuable.updatedAt)" + class="issuable-updated-at" + >{{ updatedAt }}</span + > </div> </div> </div> diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js index 620974901fb..aacbb6a9c6f 100644 --- a/app/assets/javascripts/issue_show/utils/parse_data.js +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -23,5 +23,3 @@ export const parseIssuableData = () => { return {}; } }; - -export default {}; diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index 6d32ba41eae..7b8b46cb048 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -1,7 +1,3 @@ -<script> -export default {}; -</script> - <template> <div></div> </template> diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js index 28a125b2b8f..122f23a5bb5 100644 --- a/app/assets/javascripts/jobs/utils.js +++ b/app/assets/javascripts/jobs/utils.js @@ -1,4 +1,12 @@ -// capture anything starting with http:// or https:// -// up until a disallowed character or whitespace -export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+)/g; +/** + * capture anything starting with http:// or https:// + * https?:\/\/ + * + * up until a disallowed character or whitespace + * [^"<>\\^`{|}\s]+ + * + * and a disallowed character or whitespace, including non-ending chars .,:;!? + * [^"<>\\^`{|}\s.,:;!?] + */ +export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+[^"<>\\^`{|}\s.,:;!?])/g; export default { linkRegex }; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 42a5de68cfa..ef25fd83db9 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -218,23 +218,36 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; export const contentTop = () => { - const perfBar = $('#js-peek').outerHeight() || 0; - const mrTabsHeight = $('.merge-request-tabs').outerHeight() || 0; - const headerHeight = $('.navbar-gitlab').outerHeight() || 0; - const diffFilesChanged = $('.js-diff-files-changed').outerHeight() || 0; - const isDesktop = breakpointInstance.isDesktop(); - const diffFileTitleBar = - (isDesktop && $('.diff-file .file-title-flex-parent:visible').outerHeight()) || 0; - const compareVersionsHeaderHeight = (isDesktop && $('.mr-version-controls').outerHeight()) || 0; + const heightCalculators = [ + () => $('#js-peek').outerHeight(), + () => $('.navbar-gitlab').outerHeight(), + () => $('.merge-request-tabs').outerHeight(), + () => $('.js-diff-files-changed').outerHeight(), + () => { + const isDesktop = breakpointInstance.isDesktop(); + const diffsTabIsActive = window.mrTabs?.currentAction === 'diffs'; + let size; + + if (isDesktop && diffsTabIsActive) { + size = $('.diff-file .file-title-flex-parent:visible').outerHeight(); + } - return ( - perfBar + - mrTabsHeight + - headerHeight + - diffFilesChanged + - diffFileTitleBar + - compareVersionsHeaderHeight - ); + return size; + }, + () => { + let size; + + if (breakpointInstance.isDesktop()) { + size = $('.mr-version-controls').outerHeight(); + } + + return size; + }, + ]; + + return heightCalculators.reduce((totalHeight, calculator) => { + return totalHeight + (calculator() || 0); + }, 0); }; export const scrollToElement = (element, options = {}) => { diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index 7bba7ba2f45..2f19a0c9b26 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -1,6 +1,14 @@ import { has } from 'lodash'; import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils'; +/** + * Checks whether an element's content exceeds the element's width. + * + * @param element DOM element to check + */ +export const hasHorizontalOverflow = element => + Boolean(element && element.scrollWidth > element.offsetWidth); + export const addClassIfElementExists = (element, className) => { if (element) { element.classList.add(className); diff --git a/app/assets/javascripts/lib/utils/scroll_utils.js b/app/assets/javascripts/lib/utils/scroll_utils.js index b4da1e16f08..01e43fd3b93 100644 --- a/app/assets/javascripts/lib/utils/scroll_utils.js +++ b/app/assets/javascripts/lib/utils/scroll_utils.js @@ -49,5 +49,3 @@ export const toggleDisableButton = ($button, disable) => { if (disable && $button.prop('disabled')) return; $button.prop('disabled', disable); }; - -export default {}; diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js index 8e537a4025f..880f762e225 100644 --- a/app/assets/javascripts/logs/utils.js +++ b/app/assets/javascripts/logs/utils.js @@ -23,5 +23,3 @@ export const getTimeRange = (seconds = 0) => { }; export const formatDate = timestamp => dateFormat(timestamp, dateFormatMask); - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue index 10078d5cd64..10078d5cd64 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue b/app/assets/javascripts/members/components/action_buttons/action_button_group.vue index 8356fdb60b1..8356fdb60b1 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue +++ b/app/assets/javascripts/members/components/action_buttons/action_button_group.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue index e8a53ff173d..e8a53ff173d 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue index 2aebfe80db5..2aebfe80db5 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue index 2b0a75640e2..2b0a75640e2 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue b/app/assets/javascripts/members/components/action_buttons/leave_button.vue index d9976e7181c..443a962e0cf 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/leave_button.vue @@ -2,7 +2,7 @@ import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import LeaveModal from '../modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '../constants'; +import { LEAVE_MODAL_ID } from '../../constants'; export default { name: 'LeaveButton', diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue index 9d89cb40676..9d89cb40676 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue index b0b7ff4ce9a..b0b7ff4ce9a 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue index 1cc3fd17e98..1cc3fd17e98 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue +++ b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue index 484dbb8fef5..f2bc9c7e876 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue @@ -11,7 +11,7 @@ export default { RemoveMemberButton, LeaveButton, LdapOverrideButton: () => - import('ee_component/vue_shared/components/members/ldap/ldap_override_button.vue'), + import('ee_component/members/components/ldap/ldap_override_button.vue'), }, props: { member: { diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue b/app/assets/javascripts/members/components/avatars/group_avatar.vue index 12b748f9ab6..3b176bf2b43 100644 --- a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/group_avatar.vue @@ -1,6 +1,6 @@ <script> import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; -import { AVATAR_SIZE } from '../constants'; +import { AVATAR_SIZE } from '../../constants'; export default { name: 'GroupAvatar', diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue b/app/assets/javascripts/members/components/avatars/invite_avatar.vue index 28654a60860..08e702007bb 100644 --- a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/invite_avatar.vue @@ -1,6 +1,6 @@ <script> import { GlAvatarLabeled } from '@gitlab/ui'; -import { AVATAR_SIZE } from '../constants'; +import { AVATAR_SIZE } from '../../constants'; export default { name: 'InviteAvatar', diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue index e5e7cdf149c..fe45ca769af 100644 --- a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue @@ -5,9 +5,9 @@ import { GlBadge, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; -import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils'; +import { generateBadges } from 'ee_else_ce/members/utils'; import { __ } from '~/locale'; -import { AVATAR_SIZE } from '../constants'; +import { AVATAR_SIZE } from '../../constants'; import { glEmojiTag } from '~/emoji'; export default { diff --git a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue index 9a2ce0d4931..57a5da774e3 100644 --- a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue +++ b/app/assets/javascripts/members/components/modals/leave_modal.vue @@ -3,7 +3,7 @@ import { mapState } from 'vuex'; import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; -import { LEAVE_MODAL_ID } from '../constants'; +import { LEAVE_MODAL_ID } from '../../constants'; export default { name: 'LeaveModal', diff --git a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue index e8890717724..231d014a4ec 100644 --- a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue +++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue @@ -3,7 +3,7 @@ import { mapState, mapActions } from 'vuex'; import { GlModal, GlSprintf, GlForm } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; -import { REMOVE_GROUP_LINK_MODAL_ID } from '../constants'; +import { REMOVE_GROUP_LINK_MODAL_ID } from '../../constants'; export default { name: 'RemoveGroupLinkModal', diff --git a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue b/app/assets/javascripts/members/components/table/created_at.vue index 0bad70894f9..0bad70894f9 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue +++ b/app/assets/javascripts/members/components/table/created_at.vue diff --git a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue index 0a8af81c1d1..0a8af81c1d1 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue +++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue diff --git a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue b/app/assets/javascripts/members/components/table/expires_at.vue index de65e3fb10f..c91de061b50 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue +++ b/app/assets/javascripts/members/components/table/expires_at.vue @@ -6,7 +6,7 @@ import { formatDate, getDayDifference, } from '~/lib/utils/datetime_utility'; -import { DAYS_TO_EXPIRE_SOON } from '../constants'; +import { DAYS_TO_EXPIRE_SOON } from '../../constants'; export default { name: 'ExpiresAt', diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue index 320d8c99223..c61ebec33bd 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue +++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue @@ -3,7 +3,7 @@ import UserActionButtons from '../action_buttons/user_action_buttons.vue'; import GroupActionButtons from '../action_buttons/group_action_buttons.vue'; import InviteActionButtons from '../action_buttons/invite_action_buttons.vue'; import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue'; -import { MEMBER_TYPES } from '../constants'; +import { MEMBER_TYPES } from '../../constants'; export default { name: 'MemberActionButtons', diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue index a1f98d4008a..a1f98d4008a 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue +++ b/app/assets/javascripts/members/components/table/member_avatar.vue diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue index 030d72c3420..030d72c3420 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue +++ b/app/assets/javascripts/members/components/table/member_source.vue diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index a4f67caff31..da77e5caad2 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -1,14 +1,9 @@ <script> import { mapState } from 'vuex'; import { GlTable, GlBadge } from '@gitlab/ui'; -import MembersTableCell from 'ee_else_ce/vue_shared/components/members/table/members_table_cell.vue'; -import { - canOverride, - canRemove, - canResend, - canUpdate, -} from 'ee_else_ce/vue_shared/components/members/utils'; -import { FIELDS } from '../constants'; +import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue'; +import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils'; +import { FIELDS } from '../../constants'; import initUserPopovers from '~/user_popovers'; import MemberAvatar from './member_avatar.vue'; import MemberSource from './member_source.vue'; @@ -34,9 +29,7 @@ export default { RemoveGroupLinkModal, ExpirationDatepicker, LdapOverrideConfirmationModal: () => - import( - 'ee_component/vue_shared/components/members/ldap/ldap_override_confirmation_modal.vue' - ), + import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'), }, computed: { ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']), diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue index 11e1aef9803..20aa01b96bc 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue +++ b/app/assets/javascripts/members/components/table/members_table_cell.vue @@ -1,7 +1,14 @@ <script> import { mapState } from 'vuex'; -import { MEMBER_TYPES } from '../constants'; -import { isGroup, isDirectMember, isCurrentUser, canRemove, canResend, canUpdate } from '../utils'; +import { MEMBER_TYPES } from '../../constants'; +import { + isGroup, + isDirectMember, + isCurrentUser, + canRemove, + canResend, + canUpdate, +} from '../../utils'; export default { name: 'MembersTableCell', diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index 6f6cae6072d..8ad45ab6920 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -9,8 +9,7 @@ export default { components: { GlDropdown, GlDropdownItem, - LdapDropdownItem: () => - import('ee_component/vue_shared/components/members/ldap/ldap_dropdown_item.vue'), + LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'), }, props: { member: { diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/members/constants.js index 5885420a122..5885420a122 100644 --- a/app/assets/javascripts/vue_shared/components/members/constants.js +++ b/app/assets/javascripts/members/constants.js diff --git a/app/assets/javascripts/vuex_shared/modules/members/actions.js b/app/assets/javascripts/members/store/actions.js index 4c31b3c9744..4c31b3c9744 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/actions.js +++ b/app/assets/javascripts/members/store/actions.js diff --git a/app/assets/javascripts/members/store/index.js b/app/assets/javascripts/members/store/index.js new file mode 100644 index 00000000000..f219f8931b0 --- /dev/null +++ b/app/assets/javascripts/members/store/index.js @@ -0,0 +1,9 @@ +import createState from 'ee_else_ce/members/store/state'; +import mutations from 'ee_else_ce/members/store/mutations'; +import * as actions from 'ee_else_ce/members/store/actions'; + +export default initialState => ({ + state: createState(initialState), + actions, + mutations, +}); diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js b/app/assets/javascripts/members/store/mutation_types.js index 77307aa745b..77307aa745b 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js +++ b/app/assets/javascripts/members/store/mutation_types.js diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutations.js b/app/assets/javascripts/members/store/mutations.js index 2415e744290..2415e744290 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/mutations.js +++ b/app/assets/javascripts/members/store/mutations.js diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/members/store/state.js index ab3ebb34616..ab3ebb34616 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/state.js +++ b/app/assets/javascripts/members/store/state.js diff --git a/app/assets/javascripts/vuex_shared/modules/members/utils.js b/app/assets/javascripts/members/store/utils.js index 7dcd33111e8..7dcd33111e8 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/utils.js +++ b/app/assets/javascripts/members/store/utils.js diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/members/utils.js index 4229a62c0a7..4229a62c0a7 100644 --- a/app/assets/javascripts/vue_shared/components/members/utils.js +++ b/app/assets/javascripts/members/utils.js diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js index 25c357b6073..c803774f4a7 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js @@ -54,7 +54,6 @@ import { s__ } from '~/locale'; file.promptDiscardConfirmation = false; file.resolveMode = DEFAULT_RESOLVE_MODE; file.filePath = this.getFilePath(file); - file.iconClass = `fa-${file.blob_icon}`; file.blobPath = file.blob_path; if (file.type === CONFLICT_TYPES.TEXT) { diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index a5a930572e1..229f6f3e339 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; import { deprecatedCreateFlash as createFlash } from '../flash'; import initIssuableSidebar from '../init_issuable_sidebar'; import './merge_conflict_store'; @@ -24,6 +25,7 @@ export default function initMergeConflicts() { gl.MergeConflictsResolverApp = new Vue({ el: '#conflicts', components: { + FileIcon, 'diff-file-editor': gl.mergeConflicts.diffFileEditor, 'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines, 'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines, diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js index 9245ffdb3b9..4ae5cf04ff9 100644 --- a/app/assets/javascripts/monitoring/stores/variable_mapping.js +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -271,5 +271,3 @@ export const optionsFromSeriesData = ({ label, data = [] }) => { return [...optionsSet].map(parseSimpleCustomValues); }; - -export default {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 92bbce498d5..a4c5a881fae 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -404,5 +404,3 @@ export const barChartsDataParser = (data = []) => }), {}, ); - -export default {}; diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 9be53fe60f2..5073922e4a4 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -23,6 +23,7 @@ import { commentLineOptions, formatLineRange, } from './multiline_comment_utils'; +import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; export default { name: 'NoteableNote', @@ -169,12 +170,8 @@ export default { return this.line && this.startLineNumber !== this.endLineNumber; }, commentLineOptions() { - const sideA = this.line.type === 'new' ? 'right' : 'left'; - const sideB = sideA === 'left' ? 'right' : 'left'; - const lines = this.diffFile.highlighted_diff_lines.length - ? this.diffFile.highlighted_diff_lines - : this.diffFile.parallel_diff_lines.map(l => l[sideA] || l[sideB]); - return commentLineOptions(lines, this.commentLineStart, this.line.line_code, sideA); + const lines = this.diffFile[INLINE_DIFF_LINES_KEY].length; + return commentLineOptions(lines, this.commentLineStart, this.line.line_code); }, diffFile() { if (this.commentLineStart.line_code) { diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 61298a15c5d..c6932bfacae 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -1,16 +1,17 @@ import { mapGetters, mapActions, mapState } from 'vuex'; -import { scrollToElementWithContext } from '~/lib/utils/common_utils'; +import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils'; import eventHub from '../event_hub'; /** * @param {string} selector * @returns {boolean} */ -function scrollTo(selector) { +function scrollTo(selector, { withoutContext = false } = {}) { const el = document.querySelector(selector); + const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext; if (el) { - scrollToElementWithContext(el); + scrollFunction(el); return true; } @@ -35,7 +36,7 @@ function diffsJump({ expandDiscussion }, id) { function discussionJump({ expandDiscussion }, id) { const selector = `div.discussion[data-discussion-id="${id}"]`; expandDiscussion({ discussionId: id }); - return scrollTo(selector); + return scrollTo(selector, { withoutContext: true }); } /** diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 2c60b5ee84a..ee668f4406f 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -435,6 +435,10 @@ export const saveNote = ({ commit, dispatch }, noteData) => { }; const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => { + if (state.isResolvingDiscussion) { + return null; + } + if (resp.notes?.length) { dispatch('updateOrCreateNotes', resp.notes); dispatch('startTaskList'); @@ -574,6 +578,9 @@ export const submitSuggestion = ( const dispatchResolveDiscussion = () => dispatch('resolveDiscussion', { discussionId }).catch(() => {}); + commit(types.SET_RESOLVING_DISCUSSION, true); + dispatch('stopPolling'); + return Api.applySuggestion(suggestionId) .then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId })) .then(dispatchResolveDiscussion) @@ -587,6 +594,10 @@ export const submitSuggestion = ( const flashMessage = errorMessage || defaultMessage; Flash(__(flashMessage), 'alert', flashContainer); + }) + .finally(() => { + commit(types.SET_RESOLVING_DISCUSSION, false); + dispatch('restartPolling'); }); }; @@ -605,6 +616,8 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai }); commit(types.SET_APPLYING_BATCH_STATE, true); + commit(types.SET_RESOLVING_DISCUSSION, true); + dispatch('stopPolling'); return Api.applySuggestionBatch(suggestionIds) .then(() => Promise.all(applyAllSuggestions())) @@ -621,7 +634,11 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai Flash(__(flashMessage), 'alert', flashContainer); }) - .finally(() => commit(types.SET_APPLYING_BATCH_STATE, false)); + .finally(() => { + commit(types.SET_APPLYING_BATCH_STATE, false); + commit(types.SET_RESOLVING_DISCUSSION, false); + dispatch('restartPolling'); + }); }; export const addSuggestionInfoToBatch = ({ commit }, { suggestionId, noteId, discussionId }) => diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js index d94fc626a3f..f34247d4eb0 100644 --- a/app/assets/javascripts/notes/stores/collapse_utils.js +++ b/app/assets/javascripts/notes/stores/collapse_utils.js @@ -70,6 +70,3 @@ export const collapseSystemNotes = notes => { return acc; }, []); }; - -// for babel-rewire -export default {}; diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js index a8738fa7c5f..3194a2099ea 100644 --- a/app/assets/javascripts/notes/stores/modules/index.js +++ b/app/assets/javascripts/notes/stores/modules/index.js @@ -42,6 +42,7 @@ export default () => ({ current_user: {}, preview_note_path: 'path/to/preview', }, + isResolvingDiscussion: false, commentsDisabled: false, resolvableDiscussionsCount: 0, unresolvedDiscussionsCount: 0, diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 7496dd630f6..8270f2a225b 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -38,6 +38,7 @@ export const SET_TIMELINE_VIEW = 'SET_TIMELINE_VIEW'; export const SET_SELECTED_COMMENT_POSITION = 'SET_SELECTED_COMMENT_POSITION'; export const SET_SELECTED_COMMENT_POSITION_HOVER = 'SET_SELECTED_COMMENT_POSITION_HOVER'; export const SET_FETCHING_DISCUSSIONS = 'SET_FETCHING_DISCUSSIONS'; +export const SET_RESOLVING_DISCUSSION = 'SET_RESOLVING_DISCUSSION'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 7cc619ec1c5..85bdf60e8f9 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -213,6 +213,10 @@ export default { } }, + [types.SET_RESOLVING_DISCUSSION](state, isResolving) { + state.isResolvingDiscussion = isResolving; + }, + [types.UPDATE_NOTE](state, note) { const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id); diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index 6a0e92bff2d..e14696e0d1c 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -68,6 +68,10 @@ export const PACKAGE_REGISTRY_TABS = [ title: s__('PackageRegistry|Conan'), type: PackageType.CONAN, }, + { + title: s__('PackageRegistry|Generic'), + type: PackageType.GENERIC, + }, { title: s__('PackageRegistry|Maven'), diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js index c481abd8658..c0f7f150337 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', + GENERIC: 'generic', }; export const TrackingActions = { diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js index b0807558266..d7a883e4397 100644 --- a/app/assets/javascripts/packages/shared/utils.js +++ b/app/assets/javascripts/packages/shared/utils.js @@ -21,7 +21,8 @@ export const getPackageTypeLabel = packageType => { return s__('PackageType|PyPI'); case PackageType.COMPOSER: return s__('PackageType|Composer'); - + case PackageType.GENERIC: + return s__('PackageType|Generic'); default: return null; } diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 1879e263ce7..74c1c2e981e 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -73,22 +73,20 @@ document.addEventListener('DOMContentLoaded', () => { ); } - if (gon.features?.suggestPipeline) { - const successPipelineEl = document.querySelector('.js-success-pipeline-modal'); + const successPipelineEl = document.querySelector('.js-success-pipeline-modal'); - if (successPipelineEl) { - // eslint-disable-next-line no-new - new Vue({ - el: successPipelineEl, - render(createElement) { - return createElement(PipelineTourSuccessModal, { - props: { - ...successPipelineEl.dataset, - }, - }); - }, - }); - } + if (successPipelineEl) { + // eslint-disable-next-line no-new + new Vue({ + el: successPipelineEl, + render(createElement) { + return createElement(PipelineTourSuccessModal, { + props: { + ...successPipelineEl.dataset, + }, + }); + }, + }); } if (gon?.features?.gitlabCiYmlPreview) { diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js index 26dea17ca8a..eaf340f2725 100644 --- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js +++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js @@ -1,8 +1,5 @@ import { initCommitBoxInfo } from '~/projects/commit_box/info'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; -document.addEventListener('DOMContentLoaded', () => { - initCommitBoxInfo(); - - initPipelines(); -}); +initCommitBoxInfo(); +initPipelines(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index e0bd49bf6ef..0750f472341 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -15,35 +15,33 @@ import { __ } from '~/locale'; import loadAwardsHandler from '~/awards_handler'; import { initCommitBoxInfo } from '~/projects/commit_box/info'; -document.addEventListener('DOMContentLoaded', () => { - const hasPerfBar = document.querySelector('.with-performance-bar'); - const performanceHeight = hasPerfBar ? 35 : 0; - initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight); - new ZenMode(); - new ShortcutsNavigation(); +const hasPerfBar = document.querySelector('.with-performance-bar'); +const performanceHeight = hasPerfBar ? 35 : 0; +initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight); +new ZenMode(); +new ShortcutsNavigation(); - initCommitBoxInfo(); +initCommitBoxInfo(); - initNotes(); +initNotes(); - const filesContainer = $('.js-diffs-batch'); +const filesContainer = $('.js-diffs-batch'); - if (filesContainer.length) { - const batchPath = filesContainer.data('diffFilesPath'); +if (filesContainer.length) { + const batchPath = filesContainer.data('diffFilesPath'); - axios - .get(batchPath) - .then(({ data }) => { - filesContainer.html($(data.html)); - syntaxHighlight(filesContainer); - handleLocationHash(); - new Diff(); - }) - .catch(() => { - flash({ message: __('An error occurred while retrieving diff files') }); - }); - } else { - new Diff(); - } - loadAwardsHandler(); -}); + axios + .get(batchPath) + .then(({ data }) => { + filesContainer.html($(data.html)); + syntaxHighlight(filesContainer); + handleLocationHash(); + new Diff(); + }) + .catch(() => { + flash({ message: __('An error occurred while retrieving diff files') }); + }); +} else { + new Diff(); +} +loadAwardsHandler(); diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js index b456baac612..6239e4c99d2 100644 --- a/app/assets/javascripts/pages/projects/commits/show/index.js +++ b/app/assets/javascripts/pages/projects/commits/show/index.js @@ -1,12 +1,9 @@ import CommitsList from '~/commits'; import GpgBadges from '~/gpg_badges'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; - import mountCommits from '~/projects/commits'; -document.addEventListener('DOMContentLoaded', () => { - new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new - new ShortcutsNavigation(); // eslint-disable-line no-new - GpgBadges.fetch(); - mountCommits(document.getElementById('js-author-dropdown')); -}); +new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new +new ShortcutsNavigation(); // eslint-disable-line no-new +GpgBadges.fetch(); +mountCommits(document.getElementById('js-author-dropdown')); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 477a1ab887b..19aeb1d1ecf 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -2,46 +2,28 @@ import initProjectVisibilitySelector from '../../../project_visibility'; import initProjectNew from '../../../projects/project_new'; import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import Tracking from '~/tracking'; -import { isExperimentEnabled } from '~/lib/utils/experimentation'; document.addEventListener('DOMContentLoaded', () => { initProjectVisibilitySelector(); initProjectNew.bindEvents(); - const { category, property } = gon.tracking_data ?? { category: 'projects:new' }; - const hasNewCreateProjectUi = isExperimentEnabled('newCreateProjectUi'); + import( + /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation' + ) + .then(m => { + const el = document.querySelector('.js-experiment-new-project-creation'); - if (!hasNewCreateProjectUi) { - // Setting additional tracking for HAML template + if (!el) { + return; + } - Array.from( - document.querySelectorAll('.project-edit-container [data-experiment-track-label]'), - ).forEach(node => - node.addEventListener('click', event => { - const { experimentTrackLabel: label } = event.currentTarget.dataset; - Tracking.event(category, 'click_tab', { property, label }); - }), - ); - } else { - import( - /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation' - ) - .then(m => { - const el = document.querySelector('.js-experiment-new-project-creation'); - - if (!el) { - return; - } - - const config = { - hasErrors: 'hasErrors' in el.dataset, - isCiCdAvailable: 'isCiCdAvailable' in el.dataset, - }; - m.default(el, config); - }) - .catch(() => { - createFlash(__('An error occurred while loading project creation UI')); - }); - } + const config = { + hasErrors: 'hasErrors' in el.dataset, + isCiCdAvailable: 'isCiCdAvailable' in el.dataset, + }; + m.default(el, config); + }) + .catch(() => { + createFlash(__('An error occurred while loading project creation UI')); + }); }); diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 5317093c4cf..8c7aa04a0b6 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -9,47 +9,11 @@ import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; import projectSelect from '../../project_select'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import initClonePanel from '~/clone_panel'; export default class Project { constructor() { - const $cloneOptions = $('ul.clone-options-dropdown'); - if ($cloneOptions.length) { - const $projectCloneField = $('#project_clone'); - const $cloneBtnLabel = $('.js-git-clone-holder .js-clone-dropdown-label'); - const mobileCloneField = document.querySelector( - '.js-mobile-git-clone .js-clone-dropdown-label', - ); - - const selectedCloneOption = $cloneBtnLabel.text().trim(); - if (selectedCloneOption.length > 0) { - $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active'); - } - - $('a', $cloneOptions).on('click', e => { - e.preventDefault(); - const $this = $(e.currentTarget); - const url = $this.attr('href'); - const cloneType = $this.data('cloneType'); - - $('.is-active', $cloneOptions).removeClass('is-active'); - $(`a[data-clone-type="${cloneType}"]`).each(function() { - const $el = $(this); - const activeText = $el.find('.dropdown-menu-inner-title').text(); - const $container = $el.closest('.project-clone-holder'); - const $label = $container.find('.js-clone-dropdown-label'); - - $el.toggleClass('is-active'); - $label.text(activeText); - }); - - if (mobileCloneField) { - mobileCloneField.dataset.clipboardText = url; - } else { - $projectCloneField.val(url); - } - $('.js-git-empty .js-clone').text(url); - }); - } + initClonePanel(); // Ref switcher if (document.querySelector('.js-project-refs-dropdown')) { diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js index ae2209b0292..22dddb72f98 100644 --- a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js +++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js @@ -1,3 +1,3 @@ import initExpiresAtField from '~/access_tokens'; -document.addEventListener('DOMContentLoaded', initExpiresAtField); +initExpiresAtField(); diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js index ffc84dc106b..1dc238b56b4 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js @@ -1,3 +1,3 @@ import initForm from '../form'; -document.addEventListener('DOMContentLoaded', initForm); +initForm(); diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js index 816eb9b3a66..069f3c265f3 100644 --- a/app/assets/javascripts/performance/constants.js +++ b/app/assets/javascripts/performance/constants.js @@ -19,16 +19,27 @@ export const SNIPPET_MEASURE_BLOBS_CONTENT = 'snippet-blobs-content'; // Marks export const WEBIDE_MARK_APP_START = 'webide-app-start'; -export const WEBIDE_MARK_TREE_START = 'webide-tree-start'; -export const WEBIDE_MARK_TREE_FINISH = 'webide-tree-finished'; -export const WEBIDE_MARK_FILE_START = 'webide-file-start'; export const WEBIDE_MARK_FILE_CLICKED = 'webide-file-clicked'; export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished'; +export const WEBIDE_MARK_REPO_EDITOR_START = 'webide-init-editor-start'; +export const WEBIDE_MARK_REPO_EDITOR_FINISH = 'webide-init-editor-finish'; +export const WEBIDE_MARK_FETCH_BRANCH_DATA_START = 'webide-getBranchData-start'; +export const WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH = 'webide-getBranchData-finish'; +export const WEBIDE_MARK_FETCH_FILE_DATA_START = 'webide-getFileData-start'; +export const WEBIDE_MARK_FETCH_FILE_DATA_FINISH = 'webide-getFileData-finish'; +export const WEBIDE_MARK_FETCH_FILES_START = 'webide-getFiles-start'; +export const WEBIDE_MARK_FETCH_FILES_FINISH = 'webide-getFiles-finish'; +export const WEBIDE_MARK_FETCH_PROJECT_DATA_START = 'webide-getProjectData-start'; +export const WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH = 'webide-getProjectData-finish'; // Measures -export const WEBIDE_MEASURE_TREE_FROM_REQUEST = 'webide-tree-loading-from-request'; -export const WEBIDE_MEASURE_FILE_FROM_REQUEST = 'webide-file-loading-from-request'; export const WEBIDE_MEASURE_FILE_AFTER_INTERACTION = 'webide-file-loading-after-interaction'; +export const WEBIDE_MEASURE_FETCH_PROJECT_DATA = 'WebIDE: Project data'; +export const WEBIDE_MEASURE_FETCH_BRANCH_DATA = 'WebIDE: Branch data'; +export const WEBIDE_MEASURE_FETCH_FILE_DATA = 'WebIDE: File data'; +export const WEBIDE_MEASURE_BEFORE_VUE = 'WebIDE: Before Vue app'; +export const WEBIDE_MEASURE_REPO_EDITOR = 'WebIDE: Repo Editor'; +export const WEBIDE_MEASURE_FETCH_FILES = 'WebIDE: Fetch Files'; // // MR Diffs namespace diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index 8c5f45e9d34..d4857a19ff7 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -7,6 +7,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-buy-pipeline-minutes-notification-callout', '.js-token-expiry-callout', '.js-registration-enabled-callout', + '.js-new-user-signups-cap-reached', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue new file mode 100644 index 00000000000..9279273283e --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -0,0 +1,139 @@ +<script> +import { + GlButton, + GlForm, + GlFormCheckbox, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlSprintf, +} from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + components: { + GlButton, + GlForm, + GlFormCheckbox, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlSprintf, + }, + props: { + defaultBranch: { + type: String, + required: false, + default: '', + }, + defaultMessage: { + type: String, + required: false, + default: '', + }, + isSaving: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + message: this.defaultMessage, + branch: this.defaultBranch, + openMergeRequest: false, + }; + }, + computed: { + isDefaultBranch() { + return this.branch === this.defaultBranch; + }, + submitDisabled() { + return !(this.message && this.branch); + }, + }, + methods: { + onSubmit() { + this.$emit('submit', { + message: this.message, + branch: this.branch, + openMergeRequest: this.openMergeRequest, + }); + }, + onReset() { + this.$emit('cancel'); + }, + }, + i18n: { + commitMessage: __('Commit message'), + targetBranch: __('Target Branch'), + startMergeRequest: __('Start a %{new_merge_request} with these changes'), + newMergeRequest: __('new merge request'), + commitChanges: __('Commit changes'), + cancel: __('Cancel'), + }, +}; +</script> + +<template> + <div> + <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> + <gl-form-group + id="commit-group" + :label="$options.i18n.commitMessage" + label-cols-sm="2" + label-for="commit-message" + > + <gl-form-textarea + id="commit-message" + v-model="message" + class="gl-font-monospace!" + required + :placeholder="defaultMessage" + /> + </gl-form-group> + <gl-form-group + id="target-branch-group" + :label="$options.i18n.targetBranch" + label-cols-sm="2" + label-for="target-branch-field" + > + <gl-form-input + id="target-branch-field" + v-model="branch" + class="gl-font-monospace!" + required + /> + <gl-form-checkbox + v-if="!isDefaultBranch" + v-model="openMergeRequest" + data-testid="new-mr-checkbox" + class="gl-mt-3" + > + <gl-sprintf :message="$options.i18n.startMergeRequest"> + <template #new_merge_request> + <strong>{{ $options.i18n.newMergeRequest }}</strong> + </template> + </gl-sprintf> + </gl-form-checkbox> + </gl-form-group> + <div + class="gl-display-flex gl-justify-content-space-between gl-p-5 gl-bg-gray-10 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1" + > + <gl-button + type="submit" + class="js-no-auto-disable" + category="primary" + variant="success" + :disabled="submitDisabled" + :loading="isSaving" + > + {{ $options.i18n.commitChanges }} + </gl-button> + <gl-button type="reset" category="secondary" class="gl-mr-3"> + {{ $options.i18n.cancel }} + </gl-button> + </div> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/text_editor.vue index a925077c906..22f2a32c9ac 100644 --- a/app/assets/javascripts/pipeline_editor/components/text_editor.vue +++ b/app/assets/javascripts/pipeline_editor/components/text_editor.vue @@ -5,22 +5,10 @@ export default { components: { EditorLite, }, - props: { - value: { - type: String, - required: false, - default: '', - }, - }, }; </script> <template> <div class="gl-border-solid gl-border-gray-100 gl-border-1"> - <editor-lite - v-model="value" - file-name="*.yml" - :editor-options="{ readOnly: true }" - @editor-ready="$emit('editor-ready')" - /> + <editor-lite file-name="*.yml" v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql new file mode 100644 index 00000000000..11bca42fd69 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql @@ -0,0 +1,26 @@ +mutation commitCIFileMutation( + $projectPath: ID! + $branch: String! + $startBranch: String + $message: String! + $filePath: String! + $lastCommitId: String! + $content: String +) { + commitCreate( + input: { + projectPath: $projectPath + branch: $branch + startBranch: $startBranch + message: $message + actions: [ + { action: UPDATE, filePath: $filePath, lastCommitId: $lastCommitId, content: $content } + ] + } + ) { + commit { + id + } + errors + } +} diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index ccd7b74064f..8268a907a29 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -10,7 +10,11 @@ import PipelineEditorApp from './pipeline_editor_app.vue'; export const initPipelineEditor = (selector = '#js-pipeline-editor') => { const el = document.querySelector(selector); - const { projectPath, defaultBranch, ciConfigPath } = el?.dataset; + if (!el) { + return null; + } + + const { ciConfigPath, commitId, defaultBranch, newMergeRequestPath, projectPath } = el?.dataset; Vue.use(VueApollo); @@ -24,9 +28,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { render(h) { return h(PipelineEditorApp, { props: { - projectPath, - defaultBranch, ciConfigPath, + commitId, + defaultBranch, + newMergeRequestPath, + projectPath, }, }); }, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 50b946af456..59635296de4 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,20 +1,33 @@ <script> -import { GlLoadingIcon, GlAlert, GlTabs, GlTab } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; +import { redirectTo, mergeUrlParams, refreshCurrentPage } from '~/lib/utils/url_utility'; -import TextEditor from './components/text_editor.vue'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import CommitForm from './components/commit/commit_form.vue'; +import TextEditor from './components/text_editor.vue'; +import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql'; import getBlobContent from './graphql/queries/blob_content.graphql'; +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + +const LOAD_FAILURE_NO_REF = 'LOAD_FAILURE_NO_REF'; +const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE'; +const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; +const COMMIT_FAILURE = 'COMMIT_FAILURE'; +const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; + export default { components: { - GlLoadingIcon, GlAlert, - GlTabs, + GlLoadingIcon, GlTab, - TextEditor, + GlTabs, PipelineGraph, + CommitForm, + TextEditor, }, props: { projectPath: { @@ -26,16 +39,30 @@ export default { required: false, default: null, }, + commitId: { + type: String, + required: false, + default: null, + }, ciConfigPath: { type: String, required: true, }, + newMergeRequestPath: { + type: String, + required: true, + }, }, data() { return { - error: null, - content: '', + showFailureAlert: false, + failureType: null, + failureReasons: [], + + isSaving: false, editorIsReady: false, + content: '', + contentModel: '', }; }, apollo: { @@ -51,51 +78,168 @@ export default { update(data) { return data?.blobContent?.rawData; }, + result({ data }) { + this.contentModel = data?.blobContent?.rawData ?? ''; + }, error(error) { - this.error = error; + this.handleBlobContentError(error); }, }, }, computed: { - loading() { + isLoading() { return this.$apollo.queries.content.loading; }, - errorMessage() { - const { message: generalReason, networkError } = this.error ?? {}; - - const { data } = networkError?.response ?? {}; - // 404 for missing file uses `message` - // 400 for a missing ref uses `error` - const networkReason = data?.message ?? data?.error; - - const reason = networkReason ?? generalReason ?? this.$options.i18n.unknownError; - return sprintf(this.$options.i18n.errorMessageWithReason, { reason }); + defaultCommitMessage() { + return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath }); }, pipelineData() { // Note data will loaded as part of https://gitlab.com/gitlab-org/gitlab/-/issues/263141 return {}; }, + failure() { + switch (this.failureType) { + case LOAD_FAILURE_NO_REF: + return { + text: this.$options.errorTexts[LOAD_FAILURE_NO_REF], + variant: 'danger', + }; + case LOAD_FAILURE_NO_FILE: + return { + text: this.$options.errorTexts[LOAD_FAILURE_NO_FILE], + variant: 'danger', + }; + case LOAD_FAILURE_UNKNOWN: + return { + text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], + variant: 'danger', + }; + case COMMIT_FAILURE: + return { + text: this.$options.errorTexts[COMMIT_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT_FAILURE], + variant: 'danger', + }; + } + }, }, i18n: { - unknownError: __('Unknown Error'), - errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'), + defaultCommitMessage: __('Update %{sourcePath} file'), tabEdit: s__('Pipelines|Write pipeline configuration'), tabGraph: s__('Pipelines|Visualize'), }, + errorTexts: { + [LOAD_FAILURE_NO_REF]: s__( + 'Pipelines|Repository does not have a default branch, please set one.', + ), + [LOAD_FAILURE_NO_FILE]: s__('Pipelines|No CI file found in this repository, please add one.'), + [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), + [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), + }, + methods: { + handleBlobContentError(error = {}) { + const { networkError } = error; + + const { response } = networkError; + if (response?.status === 404) { + // 404 for missing CI file + this.reportFailure(LOAD_FAILURE_NO_FILE); + } else if (response?.status === 400) { + // 400 for a missing ref when no default branch is set + this.reportFailure(LOAD_FAILURE_NO_REF); + } else { + this.reportFailure(LOAD_FAILURE_UNKNOWN); + } + }, + dismissFailure() { + this.showFailureAlert = false; + }, + reportFailure(type, reasons = []) { + this.showFailureAlert = true; + this.failureType = type; + this.failureReasons = reasons; + }, + redirectToNewMergeRequest(sourceBranch) { + const url = mergeUrlParams( + { + [MR_SOURCE_BRANCH]: sourceBranch, + [MR_TARGET_BRANCH]: this.defaultBranch, + }, + this.newMergeRequestPath, + ); + redirectTo(url); + }, + async onCommitSubmit(event) { + this.isSaving = true; + const { message, branch, openMergeRequest } = event; + + try { + const { + data: { + commitCreate: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: commitCiFileMutation, + variables: { + projectPath: this.projectPath, + branch, + startBranch: this.defaultBranch, + message, + filePath: this.ciConfigPath, + content: this.contentModel, + lastCommitId: this.commitId, + }, + }); + + if (errors?.length) { + this.reportFailure(COMMIT_FAILURE, errors); + return; + } + + if (openMergeRequest) { + this.redirectToNewMergeRequest(branch); + } else { + // Refresh the page to ensure commit is updated + refreshCurrentPage(); + } + } catch (error) { + this.reportFailure(COMMIT_FAILURE, [error?.message]); + } finally { + this.isSaving = false; + } + }, + onCommitCancel() { + this.contentModel = this.content; + }, + }, }; </script> <template> <div class="gl-mt-4"> - <gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert> + <gl-alert + v-if="showFailureAlert" + :variant="failure.variant" + :dismissible="true" + @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> <div class="gl-mt-4"> - <gl-loading-icon v-if="loading" size="lg" /> - <div v-else class="file-editor"> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> + <div v-else class="file-editor gl-mb-3"> <gl-tabs> <!-- editor should be mounted when its tab is visible, so the container has a size --> <gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady"> <!-- editor should be mounted only once, when the tab is displayed --> - <text-editor v-model="content" @editor-ready="editorIsReady = true" /> + <text-editor v-model="contentModel" @editor-ready="editorIsReady = true" /> </gl-tab> <gl-tab :title="$options.i18n.tabGraph"> @@ -103,6 +247,13 @@ export default { </gl-tab> </gl-tabs> </div> + <commit-form + :default-branch="defaultBranch" + :default-message="defaultCommitMessage" + :is-saving="isSaving" + @cancel="onCommitCancel" + @submit="onCommitSubmit" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue index e52afe08336..1ea71610897 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue @@ -32,7 +32,7 @@ export default { if (action.scheduled_at) { const confirmationMessage = sprintf( s__( - "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.", + 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.', ), { jobName: action.name }, ); diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 7afbb59cbd6..4b4fb6082c6 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -1,6 +1,13 @@ <script> -import { mapGetters } from 'vuex'; -import { GlModalDirective, GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui'; +import { mapState, mapGetters, mapActions } from 'vuex'; +import { + GlModalDirective, + GlTooltipDirective, + GlFriendlyWrap, + GlIcon, + GlButton, + GlPagination, +} from '@gitlab/ui'; import { __ } from '~/locale'; import TestCaseDetails from './test_case_details.vue'; @@ -10,6 +17,7 @@ export default { GlIcon, GlFriendlyWrap, GlButton, + GlPagination, TestCaseDetails, }, directives: { @@ -24,11 +32,15 @@ export default { }, }, computed: { - ...mapGetters(['getSuiteTests']), + ...mapState(['pageInfo']), + ...mapGetters(['getSuiteTests', 'getSuiteTestCount']), hasSuites() { return this.getSuiteTests.length > 0; }, }, + methods: { + ...mapActions(['setPage']), + }, wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'], }; </script> @@ -129,6 +141,14 @@ export default { </div> </div> </div> + + <gl-pagination + v-model="pageInfo.page" + class="gl-display-flex gl-justify-content-center" + :per-page="pageInfo.perPage" + :total-items="getSuiteTestCount" + @input="setPage" + /> </div> <div v-else> diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index f10bbeec77c..3c664457756 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -47,6 +47,7 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => { }); }; +export const setPage = ({ commit }, page) => commit(types.SET_PAGE, page); export const setSelectedSuiteIndex = ({ commit }, data) => commit(types.SET_SELECTED_SUITE_INDEX, data); export const removeSelectedSuiteIndex = ({ commit }) => diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js index c123014756d..56f769c00fa 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -14,5 +14,10 @@ export const getSelectedSuite = state => export const getSuiteTests = state => { const { test_cases: testCases = [] } = getSelectedSuite(state); - return testCases.map(addIconStatus); + const { page, perPage } = state.pageInfo; + const start = (page - 1) * perPage; + + return testCases.map(addIconStatus).slice(start, start + perPage); }; + +export const getSuiteTestCount = state => getSelectedSuite(state)?.test_cases?.length || 0; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js index 52345888cb0..803f6bf60b1 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -1,3 +1,4 @@ +export const SET_PAGE = 'SET_PAGE'; export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX'; export const SET_SUMMARY = 'SET_SUMMARY'; export const SET_SUITE = 'SET_SUITE'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js index 3652a12a6ba..cf0bf8483dd 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -1,6 +1,14 @@ import * as types from './mutation_types'; export default { + [types.SET_PAGE](state, page) { + Object.assign(state, { + pageInfo: Object.assign(state.pageInfo, { + page, + }), + }); + }, + [types.SET_SUITE](state, { suite = {}, index = null }) { state.testReports.test_suites[index] = { ...suite, hasFullSuite: true }; }, diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js index af79521d68a..7f5da549a9d 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/state.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -4,4 +4,8 @@ export default ({ summaryEndpoint = '', suiteEndpoint = '' }) => ({ testReports: {}, selectedSuiteIndex: null, isLoading: false, + pageInfo: { + page: 1, + perPage: 20, + }, }); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index db2b0856e1b..f7d823802b6 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -4,110 +4,116 @@ import $ from 'jquery'; import Api from './api'; import ProjectSelectComboButton from './project_select_combo_button'; import { s__ } from './locale'; +import { loadCSSFile } from './lib/utils/css_utils'; const projectSelect = () => { - $('.ajax-project-select').each(function(i, select) { - let placeholder; - const simpleFilter = $(select).data('simpleFilter') || false; - const isInstantiated = $(select).data('select2'); - this.groupId = $(select).data('groupId'); - this.userId = $(select).data('userId'); - this.includeGroups = $(select).data('includeGroups'); - this.allProjects = $(select).data('allProjects') || false; - this.orderBy = $(select).data('orderBy') || 'id'; - this.withIssuesEnabled = $(select).data('withIssuesEnabled'); - this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); - this.withShared = - $(select).data('withShared') === undefined ? true : $(select).data('withShared'); - this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; - this.allowClear = $(select).data('allowClear') || false; + loadCSSFile(gon.select2_css_path) + .then(() => { + $('.ajax-project-select').each(function(i, select) { + let placeholder; + const simpleFilter = $(select).data('simpleFilter') || false; + const isInstantiated = $(select).data('select2'); + this.groupId = $(select).data('groupId'); + this.userId = $(select).data('userId'); + this.includeGroups = $(select).data('includeGroups'); + this.allProjects = $(select).data('allProjects') || false; + this.orderBy = $(select).data('orderBy') || 'id'; + this.withIssuesEnabled = $(select).data('withIssuesEnabled'); + this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled'); + this.withShared = + $(select).data('withShared') === undefined ? true : $(select).data('withShared'); + this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false; + this.allowClear = $(select).data('allowClear') || false; - placeholder = s__('ProjectSelect|Search for project'); - if (this.includeGroups) { - placeholder += s__('ProjectSelect| or group'); - } - - $(select).select2({ - placeholder, - minimumInputLength: 0, - query: query => { - let projectsCallback; - const finalCallback = function(projects) { - const data = { - results: projects, - }; - return query.callback(data); - }; + placeholder = s__('ProjectSelect|Search for project'); if (this.includeGroups) { - projectsCallback = function(projects) { - const groupsCallback = function(groups) { - const data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(query.term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (this.groupId) { - return Api.groupProjects( - this.groupId, - query.term, - { - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - with_shared: this.withShared, - include_subgroups: this.includeProjectsInSubgroups, - order_by: 'similarity', - }, - projectsCallback, - ); - } else if (this.userId) { - return Api.userProjects( - this.userId, - query.term, - { - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - with_shared: this.withShared, - include_subgroups: this.includeProjectsInSubgroups, - }, - projectsCallback, - ); + placeholder += s__('ProjectSelect| or group'); } - return Api.projects( - query.term, - { - order_by: this.orderBy, - with_issues_enabled: this.withIssuesEnabled, - with_merge_requests_enabled: this.withMergeRequestsEnabled, - membership: !this.allProjects, + + $(select).select2({ + placeholder, + minimumInputLength: 0, + query: query => { + let projectsCallback; + const finalCallback = function(projects) { + const data = { + results: projects, + }; + return query.callback(data); + }; + if (this.includeGroups) { + projectsCallback = function(projects) { + const groupsCallback = function(groups) { + const data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, {}, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (this.groupId) { + return Api.groupProjects( + this.groupId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + order_by: 'similarity', + }, + projectsCallback, + ); + } else if (this.userId) { + return Api.userProjects( + this.userId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } + return Api.projects( + query.term, + { + order_by: this.orderBy, + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + membership: !this.allProjects, + }, + projectsCallback, + ); + }, + id(project) { + if (simpleFilter) return project.id; + return JSON.stringify({ + name: project.name, + url: project.web_url, + }); + }, + text(project) { + return project.name_with_namespace || project.name; }, - projectsCallback, - ); - }, - id(project) { - if (simpleFilter) return project.id; - return JSON.stringify({ - name: project.name, - url: project.web_url, - }); - }, - text(project) { - return project.name_with_namespace || project.name; - }, - initSelection(el, callback) { - return Api.project(el.val()).then(({ data }) => callback(data)); - }, + initSelection(el, callback) { + // eslint-disable-next-line promise/no-nesting + return Api.project(el.val()).then(({ data }) => callback(data)); + }, - allowClear: this.allowClear, + allowClear: this.allowClear, - dropdownCssClass: 'ajax-project-dropdown', - }); - if (isInstantiated || simpleFilter) return select; - return new ProjectSelectComboButton(select); - }); + dropdownCssClass: 'ajax-project-dropdown', + }); + if (isInstantiated || simpleFilter) return select; + return new ProjectSelectComboButton(select); + }); + }) + .catch(() => {}); }; export default () => { diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js index d3b5f532dc1..865dd23bd80 100644 --- a/app/assets/javascripts/project_select_combo_button.js +++ b/app/assets/javascripts/project_select_combo_button.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import AccessorUtilities from './lib/utils/accessor'; +import { loadCSSFile } from './lib/utils/css_utils'; export default class ProjectSelectComboButton { constructor(select) { @@ -46,9 +47,14 @@ export default class ProjectSelectComboButton { openDropdown(event) { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $(event.currentTarget) - .siblings('.project-item-select') - .select2('open'); + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + $(event.currentTarget) + .siblings('.project-item-select') + .select2('open'); + }) + .catch(() => {}); }) .catch(() => {}); } 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 f404e6030f4..2e16071e563 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 @@ -12,6 +12,7 @@ import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg'; 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 = [ { name: BLANK_PANEL, @@ -105,7 +106,7 @@ export default { this.handleLocationHashChange(); if (this.hasErrors) { - this.activeTab = BLANK_PANEL; + this.activeTab = localStorage.getItem(LAST_ACTIVE_TAB_KEY) || BLANK_PANEL; } window.addEventListener('hashchange', () => { @@ -127,6 +128,9 @@ export default { handleLocationHashChange() { this.activeTab = window.location.hash.substring(1) || null; + if (this.activeTab) { + localStorage.setItem(LAST_ACTIVE_TAB_KEY, this.activeTab); + } }, }, diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue index 4ac0bca84c1..dca63e1a569 100644 --- a/app/assets/javascripts/registry/explorer/pages/index.vue +++ b/app/assets/javascripts/registry/explorer/pages/index.vue @@ -1,7 +1,3 @@ -<script> -export default {}; -</script> - <template> <div> <router-view ref="router-view" /> diff --git a/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue b/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue new file mode 100644 index 00000000000..d75fb31fd98 --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue @@ -0,0 +1,50 @@ +<script> +import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; + +export default { + components: { + GlFormGroup, + GlFormSelect, + }, + props: { + formOptions: { + type: Array, + required: false, + default: () => [], + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + value: { + type: String, + required: false, + default: '', + }, + name: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <gl-form-group :id="`${name}-form-group`" :label-for="name" :label="label"> + <gl-form-select :id="name" :value="value" :disabled="disabled" @input="$emit('input', $event)"> + <option + v-for="option in formOptions" + :key="option.key" + :value="option.key" + data-testid="option" + > + {{ option.label }} + </option> + </gl-form-select> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/registry/settings/components/expiration_run_text.vue b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue new file mode 100644 index 00000000000..186ad2f34b9 --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue @@ -0,0 +1,31 @@ +<script> +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { NEXT_CLEANUP_LABEL, NOT_SCHEDULED_POLICY_TEXT } from '~/registry/settings/constants'; + +export default { + components: { + GlFormGroup, + GlFormInput, + }, + props: { + value: { + type: String, + required: false, + default: NOT_SCHEDULED_POLICY_TEXT, + }, + }, + i18n: { + NEXT_CLEANUP_LABEL, + }, +}; +</script> + +<template> + <gl-form-group + id="expiration-policy-info-text-group" + :label="$options.i18n.NEXT_CLEANUP_LABEL" + label-for="expiration-policy-info-text" + > + <gl-form-input id="expiration-policy-info-text" class="gl-pl-0!" plaintext :value="value" /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/registry/settings/components/expiration_textarea.vue b/app/assets/javascripts/registry/settings/components/expiration_textarea.vue new file mode 100644 index 00000000000..1e1194ebb5c --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_textarea.vue @@ -0,0 +1,109 @@ +<script> +import { GlFormGroup, GlFormTextarea, GlSprintf, GlLink } from '@gitlab/ui'; +import { NAME_REGEX_LENGTH, TEXT_AREA_INVALID_FEEDBACK } from '../constants'; + +export default { + components: { + GlFormGroup, + GlFormTextarea, + GlSprintf, + GlLink, + }, + inject: ['tagsRegexHelpPagePath'], + props: { + error: { + type: String, + required: false, + default: '', + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + value: { + type: String, + required: false, + default: '', + }, + name: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + placeholder: { + type: String, + required: true, + }, + description: { + type: String, + required: true, + }, + }, + computed: { + textAreaLengthErrorMessage() { + return this.isInputValid(this.value) ? '' : TEXT_AREA_INVALID_FEEDBACK; + }, + textAreaValidation() { + const nameRegexErrors = this.error || this.textAreaLengthErrorMessage; + return { + state: nameRegexErrors === null ? null : !nameRegexErrors, + message: nameRegexErrors, + }; + }, + internalValue: { + get() { + return this.value; + }, + set(value) { + this.$emit('input', value); + this.$emit('validation', this.isInputValid(value)); + }, + }, + }, + methods: { + isInputValid(value) { + return !value || value.length <= NAME_REGEX_LENGTH; + }, + }, +}; +</script> + +<template> + <gl-form-group + :id="`${name}-form-group`" + :label-for="name" + :state="textAreaValidation.state" + :invalid-feedback="textAreaValidation.message" + > + <template #label> + <span data-testid="label"> + <gl-sprintf :message="label"> + <template #italic="{content}"> + <i>{{ content }}</i> + </template> + </gl-sprintf> + </span> + </template> + <gl-form-textarea + :id="name" + v-model="internalValue" + :placeholder="placeholder" + :state="textAreaValidation.state" + :disabled="disabled" + trim + /> + <template #description> + <span data-testid="description" class="gl-text-gray-400"> + <gl-sprintf :message="description"> + <template #link="{content}"> + <gl-link :href="tagsRegexHelpPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </template> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue new file mode 100644 index 00000000000..9dabe8ac51a --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue @@ -0,0 +1,55 @@ +<script> +import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui'; +import { ENABLED_TEXT, DISABLED_TEXT, ENABLE_TOGGLE_DESCRIPTION } from '../constants'; + +export default { + components: { + GlFormGroup, + GlToggle, + GlSprintf, + }, + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + value: { + type: Boolean, + required: false, + default: false, + }, + }, + i18n: { + ENABLE_TOGGLE_DESCRIPTION, + }, + computed: { + enabled: { + get() { + return this.value; + }, + set(value) { + this.$emit('input', value); + }, + }, + toggleStatusText() { + return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; + }, + }, +}; +</script> + +<template> + <gl-form-group id="expiration-policy-toggle-group" label-for="expiration-policy-toggle"> + <div class="gl-display-flex"> + <gl-toggle id="expiration-policy-toggle" v-model="enabled" :disabled="disabled" /> + <span class="gl-ml-5 gl-line-height-24" data-testid="description"> + <gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION"> + <template #toggleStatus> + <strong>{{ toggleStatusText }}</strong> + </template> + </gl-sprintf> + </span> + </div> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js index e790658f491..bc3ec3104ad 100644 --- a/app/assets/javascripts/registry/settings/constants.js +++ b/app/assets/javascripts/registry/settings/constants.js @@ -1,6 +1,6 @@ import { s__, __ } from '~/locale'; -export const SET_CLEANUP_POLICY_BUTTON = s__('ContainerRegistry|Set cleanup policy'); +export const SET_CLEANUP_POLICY_BUTTON = __('Save'); export const CLEANUP_POLICY_CARD_HEADER = s__('ContainerRegistry|Tag expiration policy'); export const UNAVAILABLE_FEATURE_TITLE = s__( `ContainerRegistry|Cleanup policy for tags is disabled`, @@ -12,3 +12,46 @@ export const UNAVAILABLE_USER_FEATURE_TEXT = __(`Please contact your administrat export const UNAVAILABLE_ADMIN_FEATURE_TEXT = s__( `ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`, ); + +export const TEXT_AREA_INVALID_FEEDBACK = s__( + 'ContainerRegistry|The value of this input should be less than 256 characters', +); + +export const KEEP_HEADER_TEXT = s__('ContainerRegistry|Keep these tags'); +export const KEEP_INFO_TEXT = s__( + 'ContainerRegistry|Tags that match these rules will always be %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag will always be kept.', +); +export const KEEP_N_LABEL = s__('ContainerRegistry|Keep the most recent:'); +export const NAME_REGEX_KEEP_LABEL = s__('ContainerRegistry|Keep tags matching:'); +export const NAME_REGEX_KEEP_PLACEHOLDER = 'production-v.*'; +export const NAME_REGEX_KEEP_DESCRIPTION = s__( + 'ContainerRegistry|Tags with names matching this regex pattern will be kept. %{linkStart}More information%{linkEnd}', +); + +export const REMOVE_HEADER_TEXT = s__('ContainerRegistry|Remove these tags'); +export const REMOVE_INFO_TEXT = s__( + 'ContainerRegistry|Tags that match these rules will be %{strongStart}removed%{strongEnd}, unless kept by a rule above.', +); +export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Remove tags older than:'); +export const NAME_REGEX_LABEL = s__('ContainerRegistry|Remove tags matching:'); +export const NAME_REGEX_PLACEHOLDER = '.*'; +export const NAME_REGEX_DESCRIPTION = s__( + 'ContainerRegistry|Tags with names matching this regex pattern will be removed. %{linkStart}More information%{linkEnd}', +); + +export const ENABLED_TEXT = __('Enabled'); +export const DISABLED_TEXT = __('Disabled'); + +export const ENABLE_TOGGLE_DESCRIPTION = s__( + 'ContainerRegistry|%{toggleStatus} - Tags matching the rules defined below will be automatically scheduled for deletion.', +); + +export const CADENCE_LABEL = s__('ContainerRegistry|Run cleanup every:'); + +export const NEXT_CLEANUP_LABEL = s__('ContainerRegistry|Next cleanup scheduled to run on:'); +export const NOT_SCHEDULED_POLICY_TEXT = s__('ContainerRegistry|Not yet scheduled'); +export const EXPIRATION_POLICY_FOOTER_NOTE = s__( + 'ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time', +); + +export const NAME_REGEX_LENGTH = 255; diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql index 224e0ed9472..1d6c89133af 100644 --- a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql +++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql @@ -5,4 +5,5 @@ fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy { nameRegex nameRegexKeep olderThan + nextRunAt } diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js index f7b1c5abd3a..6a4584b1b28 100644 --- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -13,7 +13,13 @@ export default () => { if (!el) { return null; } - const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset; + const { + isAdmin, + enableHistoricEntries, + projectPath, + adminSettingsPath, + tagsRegexHelpPagePath, + } = el.dataset; return new Vue({ el, apolloProvider, @@ -21,10 +27,11 @@ export default () => { RegistrySettingsApp, }, provide: { - projectPath, isAdmin: parseBoolean(isAdmin), - adminSettingsPath, enableHistoricEntries: parseBoolean(enableHistoricEntries), + projectPath, + adminSettingsPath, + tagsRegexHelpPagePath, }, render(createElement) { return createElement('registry-settings-app', {}); diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index f245e2bfd2f..0e9975ea81f 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -3,7 +3,7 @@ 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 IssuesList from './issues_list.vue'; -import { status } from '../constants'; +import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants'; export default { name: 'ReportSection', @@ -152,12 +152,12 @@ export default { }, slotName() { if (this.isSuccess) { - return 'success'; + return SLOT_SUCCESS; } else if (this.isLoading) { - return 'loading'; + return SLOT_LOADING; } - return 'error'; + return SLOT_ERROR; }, }, methods: { diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index b3905cbfcfb..9250bfd7678 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -18,10 +18,18 @@ export const ICON_SUCCESS = 'success'; export const ICON_NOTFOUND = 'notfound'; export const status = { - LOADING: 'LOADING', - ERROR: 'ERROR', - SUCCESS: 'SUCCESS', + LOADING, + ERROR, + SUCCESS, }; export const ACCESSIBILITY_ISSUE_ERROR = 'error'; export const ACCESSIBILITY_ISSUE_WARNING = 'warning'; + +/** + * Slot names for the ReportSection component, corresponding to the success, + * loading and error statuses. + */ +export const SLOT_SUCCESS = 'success'; +export const SLOT_LOADING = 'loading'; +export const SLOT_ERROR = 'error'; diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index cf6a0a4a151..3c1b3afe889 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -1,9 +1,11 @@ <script> +import { GlButton } from '@gitlab/ui'; import { n__ } from '~/locale'; import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue'; export default { components: { + GlButton, UncollapsedAssigneeList, }, inject: ['rootPath'], @@ -27,9 +29,15 @@ export default { <template> <div class="gl-display-flex gl-flex-direction-column"> <div v-if="emptyUsers" data-testid="none"> - <span> - {{ __('None') }} - </span> + <span> {{ __('None') }} -</span> + <gl-button + data-testid="assign-yourself" + category="tertiary" + variant="link" + @click="$emit('assign-self')" + > + <span class="gl-text-gray-400">{{ __('assign yourself') }}</span> + </gl-button> </div> <uncollapsed-assignee-list v-else :users="users" :root-path="rootPath" /> </div> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index 51719df313f..1e3e870ec83 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -1,19 +1,18 @@ <script> -import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; const MARK_TEXT = __('Mark as done'); const TODO_TEXT = __('Add a To-Do'); export default { - directives: { - tooltip, - }, components: { GlIcon, GlLoadingIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { issuableId: { type: Number, @@ -71,16 +70,13 @@ export default { <template> <button - v-tooltip + v-gl-tooltip.left.viewport :class="buttonClasses" :title="buttonTooltip" :aria-label="buttonLabel" :data-issuable-id="issuableId" :data-issuable-type="issuableType" type="button" - data-container="body" - data-placement="left" - data-boundary="viewport" @click="handleButtonClick" > <gl-icon diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index dccd6807f13..19846026726 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -15,6 +15,7 @@ import { parseBoolean, spriteIcon } from '../lib/utils/common_utils'; import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { fixTitle, dispose } from '~/tooltips'; +import { loadCSSFile } from '../lib/utils/css_utils'; // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; @@ -592,92 +593,97 @@ function UsersSelect(currentUser, els, options = {}) { if ($('.ajax-users-select').length) { import(/* webpackChunkName: 'select2' */ 'select2/select2') .then(() => { - $('.ajax-users-select').each((i, select) => { - const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP); - options.skipLdap = $(select).hasClass('skip_ldap'); - const showNullUser = $(select).data('nullUser'); - const showAnyUser = $(select).data('anyUser'); - const showEmailUser = $(select).data('emailUser'); - const firstUser = $(select).data('firstUser'); - return $(select).select2({ - placeholder: __('Search for a user'), - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query(query) { - return userSelect.users(query.term, options, users => { - let name; - const data = { - results: users, - }; - if (query.term.length === 0) { - if (firstUser) { - // Move current user to the front of the list - const ref = data.results; - - for (let index = 0, len = ref.length; index < len; index += 1) { - const obj = ref[index]; - if (obj.username === firstUser) { - data.results.splice(index, 1); - data.results.unshift(obj); - break; + // eslint-disable-next-line promise/no-nesting + loadCSSFile(gon.select2_css_path) + .then(() => { + $('.ajax-users-select').each((i, select) => { + const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP); + options.skipLdap = $(select).hasClass('skip_ldap'); + const showNullUser = $(select).data('nullUser'); + const showAnyUser = $(select).data('anyUser'); + const showEmailUser = $(select).data('emailUser'); + const firstUser = $(select).data('firstUser'); + return $(select).select2({ + placeholder: __('Search for a user'), + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query(query) { + return userSelect.users(query.term, options, users => { + let name; + const data = { + results: users, + }; + if (query.term.length === 0) { + if (firstUser) { + // Move current user to the front of the list + const ref = data.results; + + for (let index = 0, len = ref.length; index < len; index += 1) { + const obj = ref[index]; + if (obj.username === firstUser) { + data.results.splice(index, 1); + data.results.unshift(obj); + break; + } + } + } + if (showNullUser) { + const nullUser = { + name: s__('UsersSelect|Unassigned'), + id: 0, + }; + data.results.unshift(nullUser); + } + if (showAnyUser) { + name = showAnyUser; + if (name === true) { + name = s__('UsersSelect|Any User'); + } + const anyUser = { + name, + id: null, + }; + data.results.unshift(anyUser); } } - } - if (showNullUser) { - const nullUser = { - name: s__('UsersSelect|Unassigned'), - id: 0, - }; - data.results.unshift(nullUser); - } - if (showAnyUser) { - name = showAnyUser; - if (name === true) { - name = s__('UsersSelect|Any User'); + if ( + showEmailUser && + data.results.length === 0 && + query.term.match(/^[^@]+@[^@]+$/) + ) { + const trimmed = query.term.trim(); + const emailUser = { + name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), + username: trimmed, + id: trimmed, + invite: true, + }; + data.results.unshift(emailUser); } - const anyUser = { - name, - id: null, - }; - data.results.unshift(anyUser); - } - } - if ( - showEmailUser && - data.results.length === 0 && - query.term.match(/^[^@]+@[^@]+$/) - ) { - const trimmed = query.term.trim(); - const emailUser = { - name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }), - username: trimmed, - id: trimmed, - invite: true, - }; - data.results.unshift(emailUser); - } - return query.callback(data); + return query.callback(data); + }); + }, + initSelection() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.initSelection.apply(userSelect, args); + }, + formatResult() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.formatResult.apply(userSelect, args); + }, + formatSelection() { + const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; + return userSelect.formatSelection.apply(userSelect, args); + }, + dropdownCssClass: 'ajax-users-dropdown', + // we do not want to escape markup since we are displaying html in results + escapeMarkup(m) { + return m; + }, }); - }, - initSelection() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return userSelect.initSelection.apply(userSelect, args); - }, - formatResult() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return userSelect.formatResult.apply(userSelect, args); - }, - formatSelection() { - const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : []; - return userSelect.formatSelection.apply(userSelect, args); - }, - dropdownCssClass: 'ajax-users-dropdown', - // we do not want to escape markup since we are displaying html in results - escapeMarkup(m) { - return m; - }, - }); - }); + }); + }) + .catch(() => {}); }) .catch(() => {}); } @@ -790,7 +796,7 @@ UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) { const mergeIcon = issuableType === 'merge_request' && !user.can_merge - ? '<i class="fa fa-exclamation-triangle merge-icon"></i>' + ? `${spriteIcon('warning-solid', 's12 merge-icon')}` : ''; return `<span class="position-relative mr-2"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js index 66de4f8b682..29d067a46a6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js @@ -6,6 +6,7 @@ export const RUNNING = 'running'; export const SUCCESS = 'success'; export const FAILED = 'failed'; export const CANCELED = 'canceled'; +export const SKIPPED = 'skipped'; // ACTION STATUSES export const STOPPING = 'stopping'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue index 2f922b990d9..390469dec24 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -4,7 +4,15 @@ import { __ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import MemoryUsage from './memory_usage.vue'; -import { MANUAL_DEPLOY, WILL_DEPLOY, RUNNING, SUCCESS, FAILED, CANCELED } from './constants'; +import { + MANUAL_DEPLOY, + WILL_DEPLOY, + RUNNING, + SUCCESS, + FAILED, + CANCELED, + SKIPPED, +} from './constants'; export default { name: 'DeploymentInfo', @@ -38,6 +46,7 @@ export default { [SUCCESS]: __('Deployed to'), [FAILED]: __('Failed to deploy to'), [CANCELED]: __('Canceled deployment to'), + [SKIPPED]: __('Skipped deployment to'), }, computed: { deployTimeago() { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index d5fdbe726e9..6628ab7be83 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -7,12 +7,14 @@ import { GlDropdownSectionHeader, GlDropdownItem, GlTooltipDirective, + GlModalDirective, } from '@gitlab/ui'; import { n__, s__, sprintf } from '~/locale'; import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import MrWidgetIcon from './mr_widget_icon.vue'; +import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue'; export default { name: 'MRWidgetHeader', @@ -20,6 +22,7 @@ export default { clipboardButton, TooltipOnTruncate, MrWidgetIcon, + MrWidgetHowToMergeModal, GlButton, GlDropdown, GlDropdownSectionHeader, @@ -27,6 +30,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + GlModalDirective, }, props: { mr: { @@ -82,6 +86,9 @@ export default { ) : ''; }, + isFork() { + return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath; + }, }, }; </script> @@ -140,13 +147,22 @@ export default { </gl-button> </span> <gl-button + v-gl-modal-directive="'modal-merge-info'" :disabled="mr.sourceBranchRemoved" - data-target="#modal_merge_info" - data-toggle="modal" class="js-check-out-branch gl-mr-3" > {{ s__('mrWidget|Check out branch') }} </gl-button> + <mr-widget-how-to-merge-modal + :is-fork="isFork" + :can-merge="mr.canMerge" + :source-branch="mr.sourceBranch" + :source-project="mr.sourceProject" + :source-project-path="mr.sourceProjectFullPath" + :target-branch="mr.targetBranch" + :source-project-default-url="mr.sourceProjectDefaultUrl" + :reviewing-docs-path="mr.reviewingDocsPath" + /> </template> <gl-dropdown v-gl-tooltip diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue new file mode 100644 index 00000000000..957356eab27 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -0,0 +1,166 @@ +<script> +/* eslint-disable @gitlab/require-i18n-strings */ +import { GlModal, GlLink, GlSprintf } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { __ } from '~/locale'; + +export default { + i18n: { + steps: { + step1: { + label: __('Step 1.'), + help: __('Fetch and check out the branch for this merge request'), + }, + step2: { + label: __('Step 2.'), + help: __('Review the changes locally'), + }, + step3: { + label: __('Step 3.'), + help: __('Merge the branch and fix any conflicts that come up'), + }, + step4: { + label: __('Step 4.'), + help: __('Push the result of the merge to GitLab'), + }, + }, + copyCommands: __('Copy commands'), + tip: __( + '%{strongStart}Tip:%{strongEnd} You can also checkout merge requests locally by %{linkStart}following these guidelines%{linkEnd}', + ), + title: __('Check out, review, and merge locally'), + }, + components: { + GlModal, + ClipboardButton, + GlLink, + GlSprintf, + }, + props: { + canMerge: { + type: Boolean, + required: false, + default: false, + }, + isFork: { + type: Boolean, + required: false, + default: false, + }, + sourceBranch: { + type: String, + required: false, + default: '', + }, + sourceProjectPath: { + type: String, + required: false, + default: '', + }, + targetBranch: { + type: String, + required: false, + default: '', + }, + sourceProjectDefaultUrl: { + type: String, + required: false, + default: '', + }, + reviewingDocsPath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + mergeInfo1() { + return this.isFork + ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.sourceBranch}\ngit checkout -b "${this.sourceProjectPath}-${this.sourceBranch}" FETCH_HEAD` + : `git fetch origin\ngit checkout -b "${this.sourceBranch}" "origin/${this.sourceBranch}"`; + }, + mergeInfo2() { + return this.isFork + ? `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceProjectPath}-${this.sourceBranch}"` + : `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff " ${this.sourceBranch}"`; + }, + mergeInfo3() { + return this.canMerge + ? `git push origin "${this.targetBranch}"` + : __('Note that pushing to GitLab requires write access to this repository.'); + }, + }, +}; +</script> + +<template> + <gl-modal modal-id="modal-merge-info" :title="$options.i18n.title" no-fade hide-footer> + <p> + <strong> + {{ $options.i18n.steps.step1.label }} + </strong> + {{ $options.i18n.steps.step1.help }} + </p> + <div class="gl-display-flex"> + <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{ + mergeInfo1 + }}</pre> + <clipboard-button + :text="mergeInfo1" + :title="$options.i18n.copyCommands" + class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" + /> + </div> + + <p> + <strong> + {{ $options.i18n.steps.step2.label }} + </strong> + {{ $options.i18n.steps.step2.help }} + </p> + <p> + <strong> + {{ $options.i18n.steps.step3.label }} + </strong> + {{ $options.i18n.steps.step3.help }} + </p> + <div class="gl-display-flex"> + <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{ + mergeInfo2 + }}</pre> + <clipboard-button + :text="mergeInfo2" + :title="$options.i18n.copyCommands" + class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" + /> + </div> + <p> + <strong> + {{ $options.i18n.steps.step4.label }} + </strong> + {{ $options.i18n.steps.step4.help }} + </p> + <div class="gl-display-flex"> + <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{ + mergeInfo3 + }}</pre> + <clipboard-button + :text="mergeInfo3" + :title="$options.i18n.copyCommands" + class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" + /> + </div> + <p v-if="reviewingDocsPath"> + <gl-sprintf :message="$options.i18n.tip"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #link="{ content }"> + <gl-link class="gl-display-inline-block" :href="reviewingDocsPath" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 55efd7e7d3b..dffe3cab904 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -1,7 +1,6 @@ <script> import { isNumber } from 'lodash'; import ArtifactsApp from './artifacts_list_app.vue'; -import Deployment from './deployment/deployment.vue'; import MrWidgetContainer from './mr_widget_container.vue'; import MrWidgetPipeline from './mr_widget_pipeline.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -18,7 +17,7 @@ export default { name: 'MrWidgetPipelineContainer', components: { ArtifactsApp, - Deployment, + Deployment: () => import('./deployment/deployment.vue'), MrWidgetContainer, MrWidgetPipeline, MergeTrainPositionIndicator: () => diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index 82566682bca..bc23ca6b1fc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -1,10 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import ciIcon from '../../vue_shared/components/ci_icon.vue'; export default { components: { ciIcon, + GlButton, GlLoadingIcon, }, props: { @@ -32,21 +33,23 @@ export default { }; </script> <template> - <div class="d-flex align-self-start"> + <div class="gl-display-flex gl-align-self-start"> <div class="square s24 h-auto d-flex-center gl-mr-3"> - <div v-if="isLoading" class="mr-widget-icon d-inline-flex"> - <gl-loading-icon size="md" class="mr-loading-icon d-inline-flex" /> + <div v-if="isLoading" class="mr-widget-icon gl-display-inline-flex"> + <gl-loading-icon size="md" class="mr-loading-icon gl-display-inline-flex" /> </div> <ci-icon v-else :status="statusObj" :size="24" /> </div> - <button + <gl-button v-if="showDisabledButton" type="button" - class="js-disabled-merge-button btn btn-success btn-sm" - disabled="true" + category="primary" + variant="success" + class="js-disabled-merge-button" + :disabled="true" > {{ s__('mrWidget|Merge') }} - </button> + </gl-button> </div> </template> 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 d421b744fa1..2df03fbc679 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,7 @@ <script> import $ from 'jquery'; import { escape } from 'lodash'; +import { GlButton, GlModalDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; import StatusIcon from '../mr_widget_status_icon.vue'; @@ -9,6 +10,10 @@ export default { name: 'MRWidgetConflicts', components: { StatusIcon, + GlButton, + }, + directives: { + GlModalDirective, }, props: { /* TODO: This is providing all store and service down when it @@ -89,22 +94,21 @@ To merge this request, first rebase locally.`) </span> </span> <span v-if="showResolveButton" ref="popover"> - <a + <gl-button :href="mr.conflictResolutionPath" :disabled="mr.sourceBranchProtected" - class="js-resolve-conflicts-button btn btn-default btn-sm" + class="js-resolve-conflicts-button" > {{ s__('mrWidget|Resolve conflicts') }} - </a> + </gl-button> </span> - <button + <gl-button v-if="mr.canMerge" - class="js-merge-locally-button btn btn-default btn-sm" - data-toggle="modal" - data-target="#modal_merge_info" + v-gl-modal-directive="'modal-merge-info'" + class="js-merge-locally-button" > {{ s__('mrWidget|Merge locally') }} - </button> + </gl-button> </template> </div> </div> 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 ff0d065c71d..1c9909e7178 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 @@ -1,10 +1,11 @@ <script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; import { SQUASH_BEFORE_MERGE } from '../../i18n'; export default { components: { GlIcon, + GlFormCheckbox, }, directives: { GlTooltip: GlTooltipDirective, @@ -32,32 +33,23 @@ export default { tooltipTitle() { return this.isDisabled ? this.$options.i18n.tooltipTitle : null; }, - tooltipFocusable() { - return this.isDisabled ? '0' : null; - }, }, }; </script> <template> - <div class="inline"> - <label + <div class="gl-display-flex gl-align-items-center"> + <gl-form-checkbox v-gl-tooltip - :class="{ 'gl-text-gray-400': isDisabled }" - :tabindex="tooltipFocusable" - data-testid="squashLabel" + :checked="value" + :disabled="isDisabled" + name="squash" + class="qa-squash-checkbox js-squash-checkbox gl-mb-0 gl-mr-2" :title="tooltipTitle" + @change="checked => $emit('input', checked)" > - <input - :checked="value" - :disabled="isDisabled" - type="checkbox" - name="squash" - class="qa-squash-checkbox js-squash-checkbox" - @change="$emit('input', $event.target.checked)" - /> {{ $options.i18n.checkboxLabel }} - </label> + </gl-form-checkbox> <a v-if="helpPath" v-gl-tooltip 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 190d790f584..433dcf2e219 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 @@ -16,7 +16,6 @@ import WidgetHeader from './components/mr_widget_header.vue'; import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; -import Deployment from './components/deployment/deployment.vue'; import WidgetRelatedLinks from './components/mr_widget_related_links.vue'; import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; import MergedState from './components/states/mr_widget_merged.vue'; @@ -63,7 +62,6 @@ export default { 'mr-widget-suggest-pipeline': WidgetSuggestPipeline, 'mr-widget-merge-help': WidgetMergeHelp, MrWidgetPipelineContainer, - Deployment, 'mr-widget-related-links': WidgetRelatedLinks, MrWidgetAlertMessage, 'mr-widget-merged': MergedState, @@ -155,10 +153,7 @@ export default { }, shouldSuggestPipelines() { return ( - gon.features?.suggestPipeline && - !this.mr.hasCI && - this.mr.mergeRequestAddCiConfigPath && - !this.mr.isDismissedSuggestPipeline + !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline ); }, shouldRenderCodeQuality() { 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 8b235b20ad4..f50b6caf0f5 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 @@ -220,6 +220,7 @@ export default class MergeRequestStore { this.sourceProjectFullPath = data.source_project_full_path; this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path; this.conflictsDocsPath = data.conflicts_docs_path; + this.reviewingDocsPath = data.reviewing_and_managing_merge_requests_docs_path; this.ciEnvironmentsStatusPath = data.ci_environments_status_path; this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path; this.eligibleApproversDocsPath = data.eligible_approvers_docs_path; @@ -229,6 +230,7 @@ export default class MergeRequestStore { this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path; this.humanAccess = data.human_access; this.newPipelinePath = data.new_project_pipeline_path; + this.sourceProjectDefaultUrl = data.source_project_default_url; this.userCalloutsPath = data.user_callouts_path; this.suggestPipelineFeatureId = data.suggest_pipeline_feature_id; this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline; @@ -240,6 +242,10 @@ export default class MergeRequestStore { this.baseBlobPath = blobPath.base_path || ''; this.codequalityHelpPath = data.codequality_help_path; this.codeclimate = data.codeclimate; + + // Security reports + this.sastComparisonPath = data.sast_comparison_path; + this.secretScanningComparisonPath = data.secret_scanning_comparison_path; } get isNothingToMergeState() { diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 7a687ea4ad0..6d5912df96b 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { groupBy } from 'lodash'; -import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { glEmojiTag } from '../../emoji'; import { __, sprintf } from '~/locale'; @@ -10,8 +10,8 @@ const NO_USER_ID = -1; export default { components: { + GlButton, GlIcon, - GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -64,7 +64,7 @@ export default { methods: { getAwardClassBindings(awardList) { return { - active: this.hasReactionByCurrentUser(awardList), + selected: this.hasReactionByCurrentUser(awardList), disabled: this.currentUserId === NO_USER_ID, }; }, @@ -150,40 +150,39 @@ export default { <template> <div class="awards js-awards-block"> - <button + <gl-button v-for="awardList in groupedAwards" :key="awardList.name" v-gl-tooltip.viewport + class="gl-mr-3" :class="awardList.classes" :title="awardList.title" data-testid="award-button" - class="btn award-control" - type="button" @click="handleAward(awardList.name)" > - <span data-testid="award-html" v-html="awardList.html"></span> - <span class="award-control-text js-counter">{{ awardList.list.length }}</span> - </button> + <template #emoji> + <span class="award-emoji-block" data-testid="award-html" v-html="awardList.html"></span> + </template> + <span class="js-counter">{{ awardList.list.length }}</span> + </gl-button> <div v-if="canAwardEmoji" class="award-menu-holder"> - <button + <gl-button v-gl-tooltip.viewport :class="addButtonClass" - class="award-control btn js-add-award" + class="add-reaction-button js-add-award" title="Add reaction" :aria-label="__('Add reaction')" - type="button" > - <span class="award-control-icon award-control-icon-neutral"> + <span class="reaction-control-icon reaction-control-icon-neutral"> <gl-icon aria-hidden="true" name="slight-smile" /> </span> - <span class="award-control-icon award-control-icon-positive"> + <span class="reaction-control-icon reaction-control-icon-positive"> <gl-icon aria-hidden="true" name="smiley" /> </span> - <span class="award-control-icon award-control-icon-super-positive"> - <gl-icon aria-hidden="true" name="smiley" /> + <span class="reaction-control-icon reaction-control-icon-super-positive"> + <gl-icon aria-hidden="true" name="smile" /> </span> - <gl-loading-icon size="md" color="dark" class="award-control-icon-loading" /> - </button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js index d4c1808eec2..106dd7a3b97 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js @@ -1,3 +1 @@ export const HIGHLIGHT_CLASS_NAME = 'hll'; - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue index 328c7e3fd32..55526dcc381 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue @@ -77,7 +77,7 @@ export default { </script> <template> - <div> + <div data-testid="image-viewer"> <div :class="innerCssClasses" class="position-relative"> <img ref="contentImg" :src="path" @load="onImgLoad" /> <slot name="image-overlay"></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js index 40708453d79..aaadc9766db 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js @@ -89,5 +89,3 @@ export const inputStringToIsoDate = (value, utc = false) => { */ export const isoDateToInputString = (date, utc = false) => dateformat(date, dateFormats.inputFormat, utc); - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index b4115b0c6a4..4d07d9fcfdd 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -143,6 +143,7 @@ export default { :style="levelIndentation" class="file-row-name" data-qa-selector="file_name_content" + data-testid="file-row-name-container" :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]" > <file-icon diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue new file mode 100644 index 00000000000..c45666e69eb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue @@ -0,0 +1,90 @@ +<script> +import Tribute from 'tributejs'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils'; + +export default { + errorMessage: __( + 'An error occurred while getting autocomplete data. Please refresh the page and try again.', + ), + props: { + autocompleteTypes: { + type: Array, + required: false, + default: () => Object.values(GfmAutocompleteType), + }, + dataSources: { + type: Object, + required: false, + default: () => gl.GfmAutoComplete?.dataSources || {}, + }, + }, + computed: { + config() { + return this.autocompleteTypes.map(type => ({ + ...tributeConfig[type].config, + values: this.getValues(type), + })); + }, + }, + mounted() { + this.cache = {}; + this.tribute = new Tribute({ collection: this.config }); + + const input = this.$slots.default?.[0]?.elm; + this.tribute.attach(input); + }, + beforeDestroy() { + const input = this.$slots.default?.[0]?.elm; + this.tribute.detach(input); + }, + methods: { + cacheAssignees() { + const isAssigneesLengthSame = + this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length; + + if (!this.assignees || !isAssigneesLengthSame) { + this.assignees = + SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; + } + }, + filterValues(type) { + // The assignees AJAX response can come after the user first invokes autocomplete + // so we need to check more than once if we need to update the assignee cache + this.cacheAssignees(); + + return tributeConfig[type].filterValues + ? tributeConfig[type].filterValues({ + assignees: this.assignees, + collection: this.cache[type], + fullText: this.$slots.default?.[0]?.elm?.value, + selectionStart: this.$slots.default?.[0]?.elm?.selectionStart, + }) + : this.cache[type]; + }, + getValues(type) { + return (inputText, processValues) => { + if (this.cache[type]) { + processValues(this.filterValues(type)); + } else if (this.dataSources[type]) { + axios + .get(this.dataSources[type]) + .then(response => { + this.cache[type] = response.data; + processValues(this.filterValues(type)); + }) + .catch(() => createFlash({ message: this.$options.errorMessage })); + } else { + processValues([]); + } + }; + }, + }, + render(createElement) { + return createElement('div', this.$slots.default); + }, +}; +</script> diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js new file mode 100644 index 00000000000..b2e995d0f17 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js @@ -0,0 +1,142 @@ +import { escape, last } from 'lodash'; +import { spriteIcon } from '~/lib/utils/common_utils'; + +const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings + +const nonWordOrInteger = /\W|^\d+$/; + +export const GfmAutocompleteType = { + Issues: 'issues', + Labels: 'labels', + Members: 'members', + MergeRequests: 'mergeRequests', + Milestones: 'milestones', + Snippets: 'snippets', +}; + +function doesCurrentLineStartWith(searchString, fullText, selectionStart) { + const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; + const currentLine = fullText.split('\n')[currentLineNumber - 1]; + return currentLine.startsWith(searchString); +} + +export const tributeConfig = { + [GfmAutocompleteType.Issues]: { + config: { + trigger: '#', + lookup: value => value.iid + value.title, + menuItemTemplate: ({ original }) => + `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, + selectTemplate: ({ original }) => original.reference || `#${original.iid}`, + }, + }, + + [GfmAutocompleteType.Labels]: { + config: { + trigger: '~', + lookup: 'title', + menuItemTemplate: ({ original }) => ` + <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span> + ${escape(original.title)}`, + selectTemplate: ({ original }) => + nonWordOrInteger.test(original.title) + ? `~"${escape(original.title)}"` + : `~${escape(original.title)}`, + }, + filterValues({ collection, fullText, selectionStart }) { + if (doesCurrentLineStartWith('/label', fullText, selectionStart)) { + return collection.filter(label => !label.set); + } + + if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) { + return collection.filter(label => label.set); + } + + return collection; + }, + }, + + [GfmAutocompleteType.Members]: { + config: { + trigger: '@', + fillAttr: 'username', + lookup: value => + value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username, + menuItemTemplate: ({ original }) => { + const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; + const noAvatarClasses = `${commonClasses} gl-rounded-small + gl-display-flex gl-align-items-center gl-justify-content-center`; + + const avatar = original.avatar_url + ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />` + : `<div class="${noAvatarClasses}" aria-hidden="true"> + ${original.username.charAt(0).toUpperCase()}</div>`; + + let displayName = original.name; + let parentGroupOrUsername = `@${original.username}`; + + if (original.type === groupType) { + const splitName = original.name.split(' / '); + displayName = splitName.pop(); + parentGroupOrUsername = splitName.pop(); + } + + const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; + + const disabledMentionsIcon = original.mentionsDisabled + ? spriteIcon('notifications-off', 's16 gl-ml-3') + : ''; + + return ` + <div class="gl-display-flex gl-align-items-center"> + ${avatar} + <div class="gl-font-sm gl-line-height-normal gl-ml-3"> + <div>${escape(displayName)}${count}</div> + <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> + </div> + ${disabledMentionsIcon} + </div> + `; + }, + }, + filterValues({ assignees, collection, fullText, selectionStart }) { + if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) { + return collection.filter(member => !assignees.includes(member.username)); + } + + if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) { + return collection.filter(member => assignees.includes(member.username)); + } + + return collection; + }, + }, + + [GfmAutocompleteType.MergeRequests]: { + config: { + trigger: '!', + lookup: value => value.iid + value.title, + menuItemTemplate: ({ original }) => + `<small>${original.reference || original.iid}</small> ${escape(original.title)}`, + selectTemplate: ({ original }) => original.reference || `!${original.iid}`, + }, + }, + + [GfmAutocompleteType.Milestones]: { + config: { + trigger: '%', + lookup: 'title', + menuItemTemplate: ({ original }) => escape(original.title), + selectTemplate: ({ original }) => `%"${escape(original.title)}"`, + }, + }, + + [GfmAutocompleteType.Snippets]: { + config: { + trigger: '$', + fillAttr: 'id', + lookup: value => value.id + value.title, + menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`, + }, + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue deleted file mode 100644 index dde7e3ebe13..00000000000 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ /dev/null @@ -1,238 +0,0 @@ -<script> -import { escape, last } from 'lodash'; -import Tribute from 'tributejs'; -import axios from '~/lib/utils/axios_utils'; -import { spriteIcon } from '~/lib/utils/common_utils'; -import SidebarMediator from '~/sidebar/sidebar_mediator'; - -const AutoComplete = { - Issues: 'issues', - Labels: 'labels', - Members: 'members', - MergeRequests: 'mergeRequests', - Milestones: 'milestones', - Snippets: 'snippets', -}; - -const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings - -function doesCurrentLineStartWith(searchString, fullText, selectionStart) { - const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; - const currentLine = fullText.split('\n')[currentLineNumber - 1]; - return currentLine.startsWith(searchString); -} - -const autoCompleteMap = { - [AutoComplete.Issues]: { - filterValues() { - return this[AutoComplete.Issues]; - }, - menuItemTemplate({ original }) { - return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`; - }, - }, - [AutoComplete.Labels]: { - filterValues() { - const fullText = this.$slots.default?.[0]?.elm?.value; - const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart; - - if (doesCurrentLineStartWith('/label', fullText, selectionStart)) { - return this.labels.filter(label => !label.set); - } - - if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) { - return this.labels.filter(label => label.set); - } - - return this.labels; - }, - menuItemTemplate({ original }) { - return ` - <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span> - ${escape(original.title)}`; - }, - }, - [AutoComplete.Members]: { - filterValues() { - const fullText = this.$slots.default?.[0]?.elm?.value; - const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart; - - // Need to check whether sidebar store assignees has been updated - // in the case where the assignees AJAX response comes after the user does @ autocomplete - const isAssigneesLengthSame = - this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length; - - if (!this.assignees || !isAssigneesLengthSame) { - this.assignees = - SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; - } - - if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) { - return this.members.filter(member => !this.assignees.includes(member.username)); - } - - if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) { - return this.members.filter(member => this.assignees.includes(member.username)); - } - - return this.members; - }, - menuItemTemplate({ original }) { - const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; - const noAvatarClasses = `${commonClasses} gl-rounded-small - gl-display-flex gl-align-items-center gl-justify-content-center`; - - const avatar = original.avatar_url - ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />` - : `<div class="${noAvatarClasses}" aria-hidden="true"> - ${original.username.charAt(0).toUpperCase()}</div>`; - - let displayName = original.name; - let parentGroupOrUsername = `@${original.username}`; - - if (original.type === groupType) { - const splitName = original.name.split(' / '); - displayName = splitName.pop(); - parentGroupOrUsername = splitName.pop(); - } - - const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; - - const disabledMentionsIcon = original.mentionsDisabled - ? spriteIcon('notifications-off', 's16 gl-ml-3') - : ''; - - return ` - <div class="gl-display-flex gl-align-items-center"> - ${avatar} - <div class="gl-font-sm gl-line-height-normal gl-ml-3"> - <div>${escape(displayName)}${count}</div> - <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div> - </div> - ${disabledMentionsIcon} - </div> - `; - }, - }, - [AutoComplete.MergeRequests]: { - filterValues() { - return this[AutoComplete.MergeRequests]; - }, - menuItemTemplate({ original }) { - return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`; - }, - }, - [AutoComplete.Milestones]: { - filterValues() { - return this[AutoComplete.Milestones]; - }, - menuItemTemplate({ original }) { - return escape(original.title); - }, - }, - [AutoComplete.Snippets]: { - filterValues() { - return this[AutoComplete.Snippets]; - }, - menuItemTemplate({ original }) { - return `<small>${original.id}</small> ${escape(original.title)}`; - }, - }, -}; - -export default { - name: 'GlMentions', - props: { - dataSources: { - type: Object, - required: false, - default: () => gl.GfmAutoComplete?.dataSources || {}, - }, - }, - mounted() { - const NON_WORD_OR_INTEGER = /\W|^\d+$/; - - this.tribute = new Tribute({ - collection: [ - { - trigger: '#', - lookup: value => value.iid + value.title, - menuItemTemplate: autoCompleteMap[AutoComplete.Issues].menuItemTemplate, - selectTemplate: ({ original }) => original.reference || `#${original.iid}`, - values: this.getValues(AutoComplete.Issues), - }, - { - trigger: '@', - fillAttr: 'username', - lookup: value => - value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username, - menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate, - values: this.getValues(AutoComplete.Members), - }, - { - trigger: '~', - lookup: 'title', - menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate, - selectTemplate: ({ original }) => - NON_WORD_OR_INTEGER.test(original.title) - ? `~"${escape(original.title)}"` - : `~${escape(original.title)}`, - values: this.getValues(AutoComplete.Labels), - }, - { - trigger: '!', - lookup: value => value.iid + value.title, - menuItemTemplate: autoCompleteMap[AutoComplete.MergeRequests].menuItemTemplate, - selectTemplate: ({ original }) => original.reference || `!${original.iid}`, - values: this.getValues(AutoComplete.MergeRequests), - }, - { - trigger: '%', - lookup: 'title', - menuItemTemplate: autoCompleteMap[AutoComplete.Milestones].menuItemTemplate, - selectTemplate: ({ original }) => `%"${escape(original.title)}"`, - values: this.getValues(AutoComplete.Milestones), - }, - { - trigger: '$', - fillAttr: 'id', - lookup: value => value.id + value.title, - menuItemTemplate: autoCompleteMap[AutoComplete.Snippets].menuItemTemplate, - values: this.getValues(AutoComplete.Snippets), - }, - ], - }); - - const input = this.$slots.default?.[0]?.elm; - this.tribute.attach(input); - }, - beforeDestroy() { - const input = this.$slots.default?.[0]?.elm; - this.tribute.detach(input); - }, - methods: { - getValues(autoCompleteType) { - return (inputText, processValues) => { - if (this[autoCompleteType]) { - const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this); - processValues(filteredValues); - } else if (this.dataSources[autoCompleteType]) { - axios - .get(this.dataSources[autoCompleteType]) - .then(response => { - this[autoCompleteType] = response.data; - const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this); - processValues(filteredValues); - }) - .catch(() => {}); - } else { - processValues([]); - } - }; - }, - }, - render(createElement) { - return createElement('div', this.$slots.default); - }, -}; -</script> diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js index 02f28da8bb0..61ab2a698ce 100644 --- a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js +++ b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js @@ -1,5 +1,3 @@ export function pixeliseValue(val) { return val ? `${val}px` : ''; } - -export default {}; diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 9cfba85e0d8..0d703545073 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -10,14 +10,14 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import GLForm from '~/gl_form'; import MarkdownHeader from './header.vue'; import MarkdownToolbar from './toolbar.vue'; -import GlMentions from '~/vue_shared/components/gl_mentions.vue'; +import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue'; import Suggestions from '~/vue_shared/components/markdown/suggestions.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import axios from '~/lib/utils/axios_utils'; export default { components: { - GlMentions, + GfmAutocomplete, MarkdownHeader, MarkdownToolbar, GlIcon, @@ -246,9 +246,9 @@ export default { /> <div v-show="!previewMarkdown" class="md-write-holder"> <div class="zen-backdrop"> - <gl-mentions v-if="glFeatures.tributeAutocomplete"> + <gfm-autocomplete v-if="glFeatures.tributeAutocomplete"> <slot name="textarea"></slot> - </gl-mentions> + </gfm-autocomplete> <slot v-else name="textarea"></slot> <a class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue index 8104d919bf6..85481f3f7b4 100644 --- a/app/assets/javascripts/vue_shared/components/pikaday.vue +++ b/app/assets/javascripts/vue_shared/components/pikaday.vue @@ -1,10 +1,14 @@ <script> import Pikaday from 'pikaday'; +import { GlIcon } from '@gitlab/ui'; import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; export default { name: 'DatePicker', + components: { + GlIcon, + }, props: { label: { type: String, @@ -66,7 +70,7 @@ export default { <div class="dropdown open"> <button type="button" class="dropdown-menu-toggle" data-toggle="dropdown" @click="toggled"> <span class="dropdown-toggle-text"> {{ label }} </span> - <i class="fa fa-chevron-down" aria-hidden="true"> </i> + <gl-icon name="chevron-down" class="gl-absolute gl-right-3 gl-top-3 gl-text-gray-500" /> </button> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue index c90bd4da6c2..3dbf0ccdfa9 100644 --- a/app/assets/javascripts/vue_shared/components/select2_select.vue +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; import 'select2'; +import { loadCSSFile } from '~/lib/utils/css_utils'; export default { // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 @@ -20,10 +21,14 @@ export default { }, mounted() { - $(this.$refs.dropdownInput) - .val(this.value) - .select2(this.options) - .on('change', event => this.$emit('input', event.target.value)); + loadCSSFile(gon.select2_css_path) + .then(() => { + $(this.$refs.dropdownInput) + .val(this.value) + .select2(this.options) + .on('change', event => this.$emit('input', event.target.value)); + }) + .catch(() => {}); }, beforeDestroy() { 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 2f71907f772..8ce624aa303 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 @@ -105,6 +105,11 @@ export default { required: false, default: __('Manage group labels'), }, + isEditing: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -131,6 +136,11 @@ export default { showDropdownContents(showDropdownContents) { this.setContentIsOnViewport(showDropdownContents); }, + isEditing(newVal) { + if (newVal) { + this.toggleDropdownContents(); + } + }, }, mounted() { this.setInitialState({ diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue index 579ad53e6db..b48dfa8b452 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue @@ -1,6 +1,7 @@ <script> import { isFunction } from 'lodash'; import tooltip from '../directives/tooltip'; +import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; export default { directives: { @@ -49,7 +50,7 @@ export default { }, updateTooltip() { const target = this.selectTarget(); - this.showTooltip = Boolean(target && target.scrollWidth > target.offsetWidth); + this.showTooltip = hasHorizontalOverflow(target); }, }, }; diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js new file mode 100644 index 00000000000..9b1cbfe218b --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js @@ -0,0 +1,8 @@ +export const SEVERITY_CLASS_NAME_MAP = { + critical: 'text-danger-800', + high: 'text-danger-600', + medium: 'text-warning-400', + low: 'text-warning-200', + info: 'text-primary-400', + unknown: 'text-secondary-400', +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue new file mode 100644 index 00000000000..babb9fddcf6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue @@ -0,0 +1,59 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { SEVERITY_CLASS_NAME_MAP } from './constants'; + +export default { + components: { + GlSprintf, + }, + props: { + message: { + type: Object, + required: true, + }, + }, + computed: { + shouldShowCountMessage() { + return !this.message.status && Boolean(this.message.countMessage); + }, + }, + methods: { + getSeverityClass(severity) { + return SEVERITY_CLASS_NAME_MAP[severity]; + }, + }, + slotNames: ['critical', 'high', 'other'], + spacingClasses: { + critical: 'gl-pl-4', + high: 'gl-px-2', + other: 'gl-px-2', + }, +}; +</script> + +<template> + <span> + <gl-sprintf :message="message.message"> + <template #total="{content}"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + <span v-if="shouldShowCountMessage" class="gl-font-sm"> + <gl-sprintf :message="message.countMessage"> + <template v-for="slotName in $options.slotNames" #[slotName]="{content}"> + <span :key="slotName"> + <strong + v-if="message[slotName] > 0" + :class="[getSeverityClass(slotName), $options.spacingClasses[slotName]]" + > + {{ content }} + </strong> + <span v-else :class="$options.spacingClasses[slotName]"> + {{ content }} + </span> + </span> + </template> + </gl-sprintf> + </span> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index 2f87c4e7878..413b4a70b40 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -1,3 +1,9 @@ export const FEEDBACK_TYPE_DISMISSAL = 'dismissal'; export const FEEDBACK_TYPE_ISSUE = 'issue'; export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request'; + +/** + * Security scan report types, as provided by the backend. + */ +export const REPORT_TYPE_SAST = 'sast'; +export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; 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 89253cc7116..b61783ed7b0 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 @@ -1,19 +1,28 @@ <script> +import { mapActions, mapGetters } from 'vuex'; import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ReportSection from '~/reports/components/report_section.vue'; -import { status } from '~/reports/constants'; +import { LOADING, ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants'; import { s__ } from '~/locale'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; -import Flash from '~/flash'; +import createFlash from '~/flash'; import Api from '~/api'; +import SecuritySummary from './components/security_summary.vue'; +import store from './store'; +import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants'; +import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION } from './constants'; export default { + store, components: { GlIcon, GlLink, GlSprintf, ReportSection, + SecuritySummary, }, + mixins: [glFeatureFlagsMixin()], props: { pipelineId: { type: Number, @@ -27,25 +36,53 @@ export default { type: String, required: true, }, + sastComparisonPath: { + type: String, + required: false, + default: '', + }, + secretScanningComparisonPath: { + type: String, + required: false, + default: '', + }, }, data() { return { - hasSecurityReports: false, + availableSecurityReports: [], + canShowCounts: false, - // Error state is shown even when successfully loaded, since success + // When core_security_mr_widget_counts is not enabled, the + // error state is shown even when successfully loaded, since success // state suggests that the security scans detected no security problems, // which is not necessarily the case. A future iteration will actually // check whether problems were found and display the appropriate status. - status: status.ERROR, + status: ERROR, }; }, + computed: { + ...mapGetters(['groupedSummaryText', 'summaryStatus']), + hasSecurityReports() { + return this.availableSecurityReports.length > 0; + }, + hasSastReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SAST); + }, + hasSecretDetectionReports() { + return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION); + }, + isLoaded() { + return this.summaryStatus !== LOADING; + }, + }, created() { - this.checkHasSecurityReports(this.$options.reportTypes) - .then(hasSecurityReports => { - this.hasSecurityReports = hasSecurityReports; + this.checkAvailableSecurityReports(this.$options.reportTypes) + .then(availableSecurityReports => { + this.availableSecurityReports = Array.from(availableSecurityReports); + this.fetchCounts(); }) .catch(error => { - Flash({ + createFlash({ message: this.$options.i18n.apiError, captureError: true, error, @@ -53,7 +90,18 @@ export default { }); }, methods: { - async checkHasSecurityReports(reportTypes) { + ...mapActions(MODULE_SAST, { + setSastDiffEndpoint: 'setDiffEndpoint', + fetchSastDiff: 'fetchDiff', + }), + ...mapActions(MODULE_SECRET_DETECTION, { + setSecretDetectionDiffEndpoint: 'setDiffEndpoint', + fetchSecretDetectionDiff: 'fetchDiff', + }), + async checkAvailableSecurityReports(reportTypes) { + const reportTypesSet = new Set(reportTypes); + const availableReportTypes = new Set(); + let page = 1; while (page) { // eslint-disable-next-line no-await-in-loop @@ -62,18 +110,40 @@ export default { page, }); - const hasSecurityReports = jobs.some(({ artifacts = [] }) => - artifacts.some(({ file_type }) => reportTypes.includes(file_type)), - ); + jobs.forEach(({ artifacts = [] }) => { + artifacts.forEach(({ file_type }) => { + if (reportTypesSet.has(file_type)) { + availableReportTypes.add(file_type); + } + }); + }); - if (hasSecurityReports) { - return true; + // If we've found artifacts for all the report types, stop looking! + if (availableReportTypes.size === reportTypesSet.size) { + return availableReportTypes; } page = parseIntPagination(normalizeHeaders(headers)).nextPage; } - return false; + return availableReportTypes; + }, + fetchCounts() { + if (!this.glFeatures.coreSecurityMrWidgetCounts) { + return; + } + + if (this.sastComparisonPath && this.hasSastReports) { + this.setSastDiffEndpoint(this.sastComparisonPath); + this.fetchSastDiff(); + this.canShowCounts = true; + } + + if (this.secretScanningComparisonPath && this.hasSecretDetectionReports) { + this.setSecretDetectionDiffEndpoint(this.secretScanningComparisonPath); + this.fetchSecretDetectionDiff(); + this.canShowCounts = true; + } }, activatePipelinesTab() { if (window.mrTabs) { @@ -81,7 +151,7 @@ export default { } }, }, - reportTypes: ['sast', 'secret_detection'], + reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION], i18n: { apiError: s__( 'SecurityReports|Failed to get security report information. Please reload the page or try again later.', @@ -89,13 +159,57 @@ export default { scansHaveRun: s__( 'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', ), + downloadFromPipelineTab: s__( + 'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports', + ), securityReportsHelp: s__('SecurityReports|Security reports help page link'), }, + summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR], }; </script> <template> <report-section - v-if="hasSecurityReports" + v-if="canShowCounts" + :status="summaryStatus" + :has-issues="false" + class="mr-widget-border-top mr-report" + data-testid="security-mr-widget" + > + <template v-for="slot in $options.summarySlots" #[slot]> + <span :key="slot"> + <security-summary :message="groupedSummaryText" /> + + <gl-link + target="_blank" + data-testid="help" + :href="securityReportsDocsPath" + :aria-label="$options.i18n.securityReportsHelp" + > + <gl-icon name="question" /> + </gl-link> + </span> + </template> + + <template v-if="isLoaded" #sub-heading> + <span class="gl-font-sm"> + <gl-sprintf :message="$options.i18n.downloadFromPipelineTab"> + <template #link="{ content }"> + <gl-link + class="gl-font-sm" + data-testid="show-pipelines" + @click="activatePipelinesTab" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </span> + </template> + </report-section> + + <!-- TODO: Remove this section when removing core_security_mr_widget_counts + feature flag. See https://gitlab.com/gitlab-org/gitlab/-/issues/284097 --> + <report-section + v-else-if="hasSecurityReports" :status="status" :has-issues="false" class="mr-widget-border-top mr-report" diff --git a/app/assets/javascripts/vue_shared/security_reports/store/constants.js b/app/assets/javascripts/vue_shared/security_reports/store/constants.js new file mode 100644 index 00000000000..6aeab56eea2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/constants.js @@ -0,0 +1,7 @@ +/** + * Vuex module names corresponding to security scan types. These are similar to + * the snake_case report types from the backend, but should not be considered + * to be equivalent. + */ +export const MODULE_SAST = 'sast'; +export const MODULE_SECRET_DETECTION = 'secretDetection'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js new file mode 100644 index 00000000000..1e5a60c32fd --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/getters.js @@ -0,0 +1,66 @@ +import { s__, sprintf } from '~/locale'; +import { countVulnerabilities, groupedTextBuilder } from './utils'; +import { LOADING, ERROR, SUCCESS } from '~/reports/constants'; +import { TRANSLATION_IS_LOADING } from './messages'; + +export const summaryCounts = state => + countVulnerabilities( + state.reportTypes.reduce((acc, reportType) => { + acc.push(...state[reportType].newIssues); + return acc; + }, []), + ); + +export const groupedSummaryText = (state, getters) => { + const reportType = s__('ciReport|Security scanning'); + let status = ''; + + // All reports are loading + if (getters.areAllReportsLoading) { + return { message: sprintf(TRANSLATION_IS_LOADING, { reportType }) }; + } + + // All reports returned error + if (getters.allReportsHaveError) { + return { message: s__('ciReport|Security scanning failed loading any results') }; + } + + if (getters.areReportsLoading && getters.anyReportHasError) { + status = s__('ciReport|is loading, errors when loading results'); + } else if (getters.areReportsLoading && !getters.anyReportHasError) { + status = s__('ciReport|is loading'); + } else if (!getters.areReportsLoading && getters.anyReportHasError) { + status = s__('ciReport|: Loading resulted in an error'); + } + + const { critical, high, other } = getters.summaryCounts; + + return groupedTextBuilder({ reportType, status, critical, high, other }); +}; + +export const summaryStatus = (state, getters) => { + if (getters.areReportsLoading) { + return LOADING; + } + + if (getters.anyReportHasError || getters.anyReportHasIssues) { + return ERROR; + } + + return SUCCESS; +}; + +export const areReportsLoading = state => + state.reportTypes.some(reportType => state[reportType].isLoading); + +export const areAllReportsLoading = state => + state.reportTypes.every(reportType => state[reportType].isLoading); + +export const allReportsHaveError = state => + state.reportTypes.every(reportType => state[reportType].hasError); + +export const anyReportHasError = state => + state.reportTypes.some(reportType => state[reportType].hasError); + +export const anyReportHasIssues = state => + state.reportTypes.some(reportType => state[reportType].newIssues.length > 0); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/index.js b/app/assets/javascripts/vue_shared/security_reports/store/index.js new file mode 100644 index 00000000000..10705e04a21 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/index.js @@ -0,0 +1,16 @@ +import Vuex from 'vuex'; +import * as getters from './getters'; +import state from './state'; +import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants'; +import sast from './modules/sast'; +import secretDetection from './modules/secret_detection'; + +export default () => + new Vuex.Store({ + modules: { + [MODULE_SAST]: sast, + [MODULE_SECRET_DETECTION]: secretDetection, + }, + getters, + state, + }); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/messages.js b/app/assets/javascripts/vue_shared/security_reports/store/messages.js new file mode 100644 index 00000000000..c25e252a768 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/messages.js @@ -0,0 +1,4 @@ +import { s__ } from '~/locale'; + +export const TRANSLATION_IS_LOADING = s__('ciReport|%{reportType} is loading'); +export const TRANSLATION_HAS_ERROR = s__('ciReport|%{reportType}: Loading resulted in an error'); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/state.js b/app/assets/javascripts/vue_shared/security_reports/store/state.js new file mode 100644 index 00000000000..5dc4d1ad2fb --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/state.js @@ -0,0 +1,5 @@ +import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants'; + +export default () => ({ + reportTypes: [MODULE_SAST, MODULE_SECRET_DETECTION], +}); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js index 6e50efae741..c5e786c92b1 100644 --- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -1,5 +1,7 @@ import pollUntilComplete from '~/lib/utils/poll_until_complete'; import axios from '~/lib/utils/axios_utils'; +import { __, n__, sprintf } from '~/locale'; +import { CRITICAL, HIGH } from '~/vulnerabilities/constants'; import { FEEDBACK_TYPE_DISMISSAL, FEEDBACK_TYPE_ISSUE, @@ -73,3 +75,79 @@ export const parseDiff = (diff, enrichData) => { existing: diff.existing ? diff.existing.map(enrichVulnerability) : [], }; }; + +const createCountMessage = ({ critical, high, other, total }) => { + const otherMessage = n__('%d Other', '%d Others', other); + const countMessage = __( + '%{criticalStart}%{critical} Critical%{criticalEnd} %{highStart}%{high} High%{highEnd} and %{otherStart}%{otherMessage}%{otherEnd}', + ); + return total ? sprintf(countMessage, { critical, high, otherMessage }) : ''; +}; + +const createStatusMessage = ({ reportType, status, total }) => { + const vulnMessage = n__('vulnerability', 'vulnerabilities', total); + let message; + if (status) { + message = __('%{reportType} %{status}'); + } else if (!total) { + message = __('%{reportType} detected %{totalStart}no%{totalEnd} vulnerabilities.'); + } else { + message = __( + '%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}', + ); + } + return sprintf(message, { reportType, status, total, vulnMessage }); +}; + +/** + * Counts vulnerabilities. + * Returns the amount of critical, high, and other vulnerabilities. + * + * @param {Array} vulnerabilities The raw vulnerabilities to parse + * @returns {{critical: number, high: number, other: number}} + */ +export const countVulnerabilities = (vulnerabilities = []) => + vulnerabilities.reduce( + (acc, { severity }) => { + if (severity === CRITICAL) { + acc.critical += 1; + } else if (severity === HIGH) { + acc.high += 1; + } else { + acc.other += 1; + } + + return acc; + }, + { critical: 0, high: 0, other: 0 }, + ); + +/** + * Takes an object of options and returns the object with an externalized string representing + * the critical, high, and other severity vulnerabilities for a given report. + * + * The resulting string _may_ still contain sprintf-style placeholders. These + * are left in place so they can be replaced with markup, via the + * SecuritySummary component. + * @param {{reportType: string, status: string, critical: number, high: number, other: number}} options + * @returns {Object} the parameters with an externalized string + */ +export const groupedTextBuilder = ({ + reportType = '', + status = '', + critical = 0, + high = 0, + other = 0, +} = {}) => { + const total = critical + high + other; + + return { + countMessage: createCountMessage({ critical, high, other, total }), + message: createStatusMessage({ reportType, status, total }), + critical, + high, + other, + status, + total, + }; +}; diff --git a/app/assets/javascripts/vuex_shared/modules/members/index.js b/app/assets/javascripts/vuex_shared/modules/members/index.js deleted file mode 100644 index 586d52a5288..00000000000 --- a/app/assets/javascripts/vuex_shared/modules/members/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import createState from 'ee_else_ce/vuex_shared/modules/members/state'; -import mutations from 'ee_else_ce/vuex_shared/modules/members/mutations'; -import * as actions from 'ee_else_ce/vuex_shared/modules/members/actions'; - -export default initialState => ({ - namespaced: true, - state: createState(initialState), - actions, - mutations, -}); diff --git a/app/assets/javascripts/vulnerabilities/constants.js b/app/assets/javascripts/vulnerabilities/constants.js new file mode 100644 index 00000000000..42fb38e8e7e --- /dev/null +++ b/app/assets/javascripts/vulnerabilities/constants.js @@ -0,0 +1,15 @@ +/** + * Vulnerability severities as provided by the backend on vulnerability + * objects. + */ +export const CRITICAL = 'critical'; +export const HIGH = 'high'; +export const MEDIUM = 'medium'; +export const LOW = 'low'; +export const INFO = 'info'; +export const UNKNOWN = 'unknown'; + +/** + * All vulnerability severities in decreasing order. + */ +export const SEVERITIES = [CRITICAL, HIGH, MEDIUM, LOW, INFO, UNKNOWN]; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 4b1139d2354..85a7fa1d2b1 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -5,7 +5,6 @@ // directory. @import '@gitlab/at.js/dist/css/jquery.atwho'; @import 'dropzone/dist/basic'; -@import 'select2'; // GitLab UI framework @import 'framework'; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 4f09f1a394b..d9ad4992458 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -253,3 +253,111 @@ vertical-align: middle; } } + + +// The following encompasses the "add reaction" button redesign to +// align properly within GitLab UI's gl-button. The implementation +// above will be deprecated once all instances of "award emoji" are +// migrated to Vue. + +.gl-button .award-emoji-block gl-emoji { + top: -1px; + margin-top: -1px; + margin-bottom: -1px; +} + +.add-reaction-button { + position: relative; + + // This forces the height and width of the inner content to match + // other gl-buttons despite all child elements being set to + // `position:absolute` + &::after { + content: '\a0'; + width: 1em; + } + + .reaction-control-icon { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + + // center the icon vertically and horizontally within the button + display: flex; + align-items: center; + justify-content: center; + + @include transition(opacity, transform); + + .gl-icon { + height: $default-icon-size; + width: $default-icon-size; + } + } + + .reaction-control-icon-neutral { + opacity: 1; + } + + .reaction-control-icon-positive, + .reaction-control-icon-super-positive { + opacity: 0; + } + + &:hover, + &.active, + &:active, + &.is-active { + // extra specificty added to override another selector + .reaction-control-icon .gl-icon { + color: $blue-500; + transform: scale(1.15); + } + + .reaction-control-icon-neutral { + opacity: 0; + } + } + + &:hover { + .reaction-control-icon-positive { + opacity: 1; + } + } + + &.active, + &:active, + &.is-active { + .reaction-control-icon-positive { + opacity: 0; + } + + .reaction-control-icon-super-positive { + opacity: 1; + } + } + + &.disabled { + cursor: default; + + &:hover, + &:focus, + &:active { + .reaction-control-icon .gl-icon { + color: inherit; + transform: scale(1); + } + + .reaction-control-icon-neutral { + opacity: 1; + } + + .reaction-control-icon-positive, + .reaction-control-icon-super-positive { + opacity: 0; + } + } + } +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index deb2d6c4641..3b59c028437 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -135,7 +135,6 @@ hr { text-overflow: ellipsis; white-space: nowrap; - > div:not(.block):not(.select2-display-none), .str-truncated { display: inline; } @@ -389,11 +388,7 @@ img.emoji { 🚨 Do not use these classes — they are deprecated and being removed. 🚨 See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details. **/ -.prepend-top-15 { margin-top: 15px; } .prepend-top-20 { margin-top: 20px; } -.prepend-left-15 { margin-left: 15px; } -.prepend-left-20 { margin-left: 20px; } -.append-right-20 { margin-right: 20px; } .append-bottom-20 { margin-bottom: 20px; } .ml-10 { margin-left: 4.5rem; } .inline { display: inline-block; } diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index e16ab5ee72f..a9925fb3621 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -2,10 +2,6 @@ .diff-file { margin-bottom: $gl-padding; - &.conflict { - border-top: 1px solid $border-color; - } - &.has-body { .file-title { box-shadow: 0 -2px 0 0 var(--white); diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 7be676ed83c..be97db42d1d 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -133,11 +133,6 @@ label { } .input-group { - .select2-container { - display: table-cell; - max-width: 180px; - } - .input-group-prepend, .input-group-append { background-color: $input-group-addon-bg; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 86a5aa1a16e..d8ce6826fc1 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -1,275 +1,3 @@ -/** Select2 selectbox style override **/ -.select2-container { - width: 100% !important; - - &.input-md, - &.input-lg { - display: block; - } -} - -.select2-container, -.select2-container.select2-drop-above { - .select2-choice { - background: $white; - color: $gl-text-color; - border-color: $input-border; - height: 34px; - padding: $gl-vert-padding $gl-input-padding; - font-size: $gl-font-size; - line-height: 1.42857143; - border-radius: $border-radius-base; - - .select2-arrow { - background-image: none; - background-color: transparent; - border: 0; - padding-top: 12px; - padding-right: 20px; - font-size: 10px; - - b { - display: none; - } - - &::after { - content: '\f078'; - position: absolute; - z-index: 1; - text-align: center; - pointer-events: none; - box-sizing: border-box; - color: $gray-darkest; - display: inline-block; - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - } - } - - .select2-chosen { - margin-right: 15px; - } - - &:hover { - border-color: $gray-darkest; - color: $gl-text-color; - } - } - - // Essentially we’re doing @include form-control-focus here (from - // bootstrap/scss/mixins/_forms.scss), except that the bootstrap mixin adds a - // `&:focus` selector and we’re never actually focusing the .select2-choice - // link nor the .select2-container, the Select2 library focuses an off-screen - // .select2-focusser element instead. - &.select2-container-active:not(.select2-dropdown-open) { - .select2-choice { - color: $input-focus-color; - background-color: $input-focus-bg; - border-color: $input-focus-border-color; - outline: 0; - } - - // Reusable focus “glow” box-shadow - @mixin form-control-focus-glow { - @if $enable-shadows { - box-shadow: $input-box-shadow, $input-focus-box-shadow; - } @else { - box-shadow: $input-focus-box-shadow; - } - } - - // Apply the focus “glow” shadow to the .select2-container if it also has - // the .block-truncated class as that applies an overflow: hidden, thereby - // hiding the glow of the nested .select2-choice element. - &.block-truncated { - @include form-control-focus-glow; - } - - // Apply the glow directly to the .select2-choice link if we’re not - // block-truncating the container. - &:not(.block-truncated) .select2-choice { - @include form-control-focus-glow; - } - } - - &.is-invalid { - ~ .invalid-feedback { - display: block; - } - - .select2-choices, - .select2-choice { - border-color: $red-500; - } - } -} - -.select2-drop, -.select2-drop.select2-drop-above { - background: $white; - box-shadow: 0 2px 4px $dropdown-shadow-color; - border-radius: $border-radius-base; - border: 1px solid $border-color; - min-width: 175px; - color: $gl-text-color; - z-index: 999; - - .modal-open & { - z-index: $zindex-modal + 200; - } -} - -.select2-drop-mask { - z-index: 998; - - .modal-open & { - z-index: $zindex-modal + 100; - } -} - -.select2-drop.select2-drop-above.select2-drop-active { - border-top: 1px solid $border-color; - margin-top: -6px; -} - -.select2-container-active { - .select2-choice, - .select2-choices { - box-shadow: none; - } -} - -.select2-dropdown-open, -.select2-dropdown-open.select2-drop-above { - .select2-choice { - border-color: $gray-darkest; - outline: 0; - } -} - -.select2-container-multi { - .select2-choices { - border-radius: $border-radius-default; - border-color: $input-border; - background: none; - - .select2-search-field input { - padding: 5px $gl-input-padding; - height: auto; - font-family: inherit; - font-size: inherit; - } - - .select2-search-choice { - margin: 5px 0 0 8px; - box-shadow: none; - border-color: $input-border; - color: $gl-text-color; - line-height: 15px; - background-color: $gray-light; - background-image: none; - padding: 3px 18px 3px 5px; - - .select2-search-choice-close { - top: 5px; - left: initial; - right: 3px; - } - - &.select2-search-choice-focus { - border-color: $gl-text-color; - } - } - } -} - -.select2-drop-active { - margin-top: $dropdown-vertical-offset; - font-size: 14px; - - .select2-results { - max-height: 350px; - } -} - -.select2-search { - padding: $grid-size; - - .select2-drop-auto-width & { - padding: $grid-size; - } - - input { - padding: $grid-size; - background: transparent image-url('select2.png'); - color: $gl-text-color; - background-clip: content-box; - background-origin: content-box; - background-repeat: no-repeat; - background-position: right 0 bottom 0 !important; - border: 1px solid $input-border; - border-radius: $border-radius-default; - line-height: 16px; - transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; - - &:focus { - border-color: $blue-300; - } - - &.select2-active { - background-color: $white; - background-image: image-url('select2-spinner.gif') !important; - background-origin: content-box; - background-repeat: no-repeat; - background-position: right 6px center !important; - background-size: 16px 16px !important; - } - } - - + .select2-results { - padding-top: 0; - } -} - -.select2-results { - margin: 0; - padding: #{$gl-padding / 2} 0; - - .select2-no-results, - .select2-searching, - .select2-ajax-error, - .select2-selection-limit { - background: transparent; - padding: #{$gl-padding / 2} $gl-padding; - } - - .select2-result-label, - .select2-more-results { - padding: #{$gl-padding / 2} $gl-padding; - } - - .select2-highlighted { - background: transparent; - color: $gl-text-color; - - .select2-result-label { - background: $gray-darker; - } - } - - .select2-result { - padding: 0 1px; - } - - li.select2-result-with-children > .select2-result-label { - font-weight: $gl-font-weight-bold; - color: $gl-text-color; - } -} - .ajax-users-select { width: 400px; @@ -282,14 +10,6 @@ } } -.select2-highlighted { - .group-result { - .group-path { - color: $gray-700; - } - } -} - .group-result { .group-image { float: left; @@ -345,11 +65,3 @@ .ajax-users-dropdown { min-width: 250px !important; } - -.select2-result-selectable, -.select2-result-unselectable { - .select2-match { - font-weight: $gl-font-weight-bold; - text-decoration: none; - } -} diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index ffc15af6329..a88ca474bb7 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -92,7 +92,6 @@ .board-title-caret { border-radius: $border-radius-default; line-height: $gl-spacing-scale-5; - height: $gl-spacing-scale-5; &.btn svg { top: 0; diff --git a/app/assets/stylesheets/page_bundles/ci_status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss index 8522a0a8fe4..89fbe1c4caf 100644 --- a/app/assets/stylesheets/page_bundles/ci_status.scss +++ b/app/assets/stylesheets/page_bundles/ci_status.scss @@ -26,6 +26,7 @@ } &.ci-canceled, + &.ci-skipped, &.ci-disabled, &.ci-scheduled, &.ci-manual { diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 4e27f438e36..f7b8a4c5b84 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -58,22 +58,6 @@ } } -.cluster-application-banner { - height: 45px; - display: flex; - align-items: center; - justify-content: space-between; -} - -.cluster-application-banner-close { - align-self: flex-start; - font-weight: 500; - font-size: 20px; - color: $orange-500; - opacity: 1; - margin: $gl-padding-8 14px 0 0; -} - .cluster-application-description { flex: 1; } diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 5c845c37e90..acf9a654159 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -74,10 +74,6 @@ justify-content: flex-end; } - .select2 { - float: right; - } - .encoding-selector, .soft-wrap-toggle { display: inline-block; diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss index 74f80a11471..3f40d2c433d 100644 --- a/app/assets/stylesheets/pages/import.scss +++ b/app/assets/stylesheets/pages/import.scss @@ -17,14 +17,6 @@ max-width: 300px; } -.import-namespace-select { - > .select2-choice { - border-radius: $border-radius-default 0 0 $border-radius-default; - position: relative; - left: 1px; - } -} - .import-slash-divider { background-color: $gray-lightest; border: 1px solid $border-color; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index cc4827f75d4..aa849e1b17b 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -199,10 +199,6 @@ border: 0; } - .select2-container span { - margin-top: 0; - } - &.assignee { .author-link { display: block; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 08faebc8ec0..51870ace23b 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -92,6 +92,11 @@ ul.related-merge-requests > li { } } +.issues-footer { + padding-top: $gl-padding; + padding-bottom: 37px; +} + .issues-nav-controls, .new-branch-col { font-size: 0; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 09501d3713d..e5a9e99b2fb 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -10,12 +10,6 @@ } .input-group { - .select2-container { - display: unset; - max-width: unset; - flex-grow: 1; - } - > div { &:last-child { padding-right: 0; @@ -52,7 +46,6 @@ flex-grow: 1; } - + .select2 a, + .btn-default { border-radius: 0 $border-radius-base $border-radius-base 0; } @@ -258,10 +251,6 @@ color: $gray-700; } -.transfer-project .select2-container { - min-width: 200px; -} - .deploy-key { // Ensure that the fingerprint does not overflow on small screens .fingerprint { @@ -1057,11 +1046,6 @@ pre.light-well { margin-bottom: 0; } } - - .select2-choice { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } } .project-home-empty { diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb index d5cd9c55422..a26dc554506 100644 --- a/app/controllers/admin/cohorts_controller.rb +++ b/app/controllers/admin/cohorts_controller.rb @@ -5,7 +5,7 @@ class Admin::CohortsController < Admin::ApplicationController track_unique_visits :index, target_id: 'i_analytics_cohorts' - feature_category :instance_statistics + feature_category :devops_reports def index if Gitlab::CurrentSettings.usage_ping_enabled diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 33a8cc4ae42..da89276f5eb 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -2,7 +2,6 @@ class Admin::DashboardController < Admin::ApplicationController include CountHelper - helper_method :show_license_breakdown? COUNTED_ITEMS = [Project, User, Group].freeze @@ -23,10 +22,6 @@ class Admin::DashboardController < Admin::ApplicationController def stats @users_statistics = UsersStatistics.latest end - - def show_license_breakdown? - false - end end Admin::DashboardController.prepend_if_ee('EE::Admin::DashboardController') diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb index db304c82dd6..88ca2c88aab 100644 --- a/app/controllers/admin/instance_review_controller.rb +++ b/app/controllers/admin/instance_review_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true class Admin::InstanceReviewController < Admin::ApplicationController - feature_category :instance_statistics + feature_category :devops_reports def index redirect_to("#{::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL}/instance_review?#{instance_review_params}") diff --git a/app/controllers/admin/instance_statistics_controller.rb b/app/controllers/admin/instance_statistics_controller.rb index 05a0a1ce314..30891fcfe7c 100644 --- a/app/controllers/admin/instance_statistics_controller.rb +++ b/app/controllers/admin/instance_statistics_controller.rb @@ -7,7 +7,7 @@ class Admin::InstanceStatisticsController < Admin::ApplicationController track_unique_visits :index, target_id: 'i_analytics_instance_statistics' - feature_category :instance_statistics + feature_category :devops_reports def index end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 0d7af57328a..3f5f3b6e9df 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -150,7 +150,7 @@ module IssuableCollections common_attributes + [:project, project: :namespace] when 'MergeRequest' common_attributes + [ - :target_project, :latest_merge_request_diff, :approvals, :approved_by_users, + :target_project, :latest_merge_request_diff, :approvals, :approved_by_users, :reviewers, source_project: :route, head_pipeline: :project, target_project: :namespace ] end diff --git a/app/controllers/concerns/sorting_preference.rb b/app/controllers/concerns/sorting_preference.rb index a51b68147d5..8d8845e2f41 100644 --- a/app/controllers/concerns/sorting_preference.rb +++ b/app/controllers/concerns/sorting_preference.rb @@ -4,8 +4,11 @@ module SortingPreference include SortingHelper include CookiesHelper - def set_sort_order - set_sort_order_from_user_preference || set_sort_order_from_cookie || params[:sort] || default_sort_order + def set_sort_order(field = sorting_field, default_order = default_sort_order) + set_sort_order_from_user_preference(field) || + set_sort_order_from_cookie(field) || + params[:sort] || + default_order end # Implement sorting_field method on controllers @@ -29,42 +32,42 @@ module SortingPreference private - def set_sort_order_from_user_preference + def set_sort_order_from_user_preference(field = sorting_field) return unless current_user - return unless sorting_field + return unless field user_preference = current_user.user_preference sort_param = params[:sort] - sort_param ||= user_preference[sorting_field] + sort_param ||= user_preference[field] return sort_param if Gitlab::Database.read_only? - if user_preference[sorting_field] != sort_param - user_preference.update(sorting_field => sort_param) + if user_preference[field] != sort_param + user_preference.update(field => sort_param) end sort_param end - def set_sort_order_from_cookie + def set_sort_order_from_cookie(field = sorting_field) return unless legacy_sort_cookie_name sort_param = params[:sort] if params[:sort].present? # fallback to legacy cookie value for backward compatibility sort_param ||= cookies[legacy_sort_cookie_name] - sort_param ||= cookies[remember_sorting_key] + sort_param ||= cookies[remember_sorting_key(field)] sort_value = update_cookie_value(sort_param) - set_secure_cookie(remember_sorting_key, sort_value) + set_secure_cookie(remember_sorting_key(field), sort_value) sort_value end # Convert sorting_field to legacy cookie name for backwards compatibility # :merge_requests_sort => 'mergerequest_sort' # :issues_sort => 'issue_sort' - def remember_sorting_key - @remember_sorting_key ||= sorting_field + def remember_sorting_key(field = sorting_field) + @remember_sorting_key ||= field .to_s .split('_')[0..-2] .map(&:singularize) diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index 6abb2e16226..65ab835c33c 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -8,6 +8,8 @@ module WikiActions include RedisTracking extend ActiveSupport::Concern + RESCUE_GIT_TIMEOUTS_IN = %w[show edit history diff pages].freeze + included do before_action { respond_to :html } @@ -38,6 +40,12 @@ module WikiActions feature: :track_unique_wiki_page_views, feature_default_enabled: true helper_method :view_file_button, :diff_file_html_data + + rescue_from ::Gitlab::Git::CommandTimedOut do |exc| + raise exc unless RESCUE_GIT_TIMEOUTS_IN.include?(action_name) + + render 'shared/wikis/git_error' + end end def new @@ -46,11 +54,7 @@ module WikiActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def pages - @wiki_pages = Kaminari.paginate_array( - wiki.list_pages(sort: params[:sort], direction: params[:direction]) - ).page(params[:page]) - - @wiki_entries = WikiDirectory.group_pages(@wiki_pages) + @wiki_entries = WikiDirectory.group_pages(wiki_pages) render 'shared/wikis/pages' end @@ -225,9 +229,19 @@ module WikiActions unless @sidebar_page # Fallback to default sidebar @sidebar_wiki_entries, @sidebar_limited = wiki.sidebar_entries end + rescue ::Gitlab::Git::CommandTimedOut => e + @sidebar_error = e end # rubocop:enable Gitlab/ModuleWithInstanceVariables + def wiki_pages + strong_memoize(:wiki_pages) do + Kaminari.paginate_array( + wiki.list_pages(sort: params[:sort], direction: params[:direction]) + ).page(params[:page]) + end + end + def wiki_params params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha) end diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb index 9c2e361e92f..a504d2ce991 100644 --- a/app/controllers/groups/application_controller.rb +++ b/app/controllers/groups/application_controller.rb @@ -3,11 +3,14 @@ class Groups::ApplicationController < ApplicationController include RoutableActions include ControllerWithCrossProjectAccessCheck + include SortingHelper + include SortingPreference layout 'group' skip_before_action :authenticate_user! before_action :group + before_action :set_sorting requires_cross_project_access private @@ -57,6 +60,16 @@ class Groups::ApplicationController < ApplicationController url_for(safe_params) end + + def set_sorting + if has_project_list? + @group_projects_sort = set_sort_order(Project::SORTING_PREFERENCE_FIELD, sort_value_name) + end + end + + def has_project_list? + false + end end Groups::ApplicationController.prepend_if_ee('EE::Groups::ApplicationController') diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb index 718914dea35..10a6ad06ae5 100644 --- a/app/controllers/groups/children_controller.rb +++ b/app/controllers/groups/children_controller.rb @@ -2,12 +2,15 @@ module Groups class ChildrenController < Groups::ApplicationController + extend ::Gitlab::Utils::Override + before_action :group skip_cross_project_access_check :index feature_category :subgroups def index + params[:sort] ||= @group_projects_sort parent = if params[:parent_id].present? GroupFinder.new(current_user).execute(id: params[:parent_id]) else @@ -40,5 +43,12 @@ module Groups params: params.to_unsafe_h).execute @children = @children.page(params[:page]) end + + private + + override :has_project_list? + def has_project_list? + true + end end end diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 03d41f1dd6d..84dc570a1e9 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -5,9 +5,6 @@ class Groups::MilestonesController < Groups::ApplicationController before_action :milestone, only: [:edit, :show, :update, :issues, :merge_requests, :participants, :labels, :destroy] before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy] - before_action do - push_frontend_feature_flag(:burnup_charts, @group, default_enabled: true) - end feature_category :issue_tracking diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 8d528e123e1..40cb40c9905 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -329,6 +329,11 @@ class GroupsController < Groups::ApplicationController def markdown_service_params params.merge(group: group) end + + override :has_project_list? + def has_project_list? + %w(details show index).include?(action_name) + end end GroupsController.prepend_if_ee('EE::GroupsController') diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb index 8ecf8fadefd..ebe867d915d 100644 --- a/app/controllers/projects/alert_management_controller.rb +++ b/app/controllers/projects/alert_management_controller.rb @@ -3,7 +3,7 @@ class Projects::AlertManagementController < Projects::ApplicationController before_action :authorize_read_alert_management_alert! - feature_category :alert_management + feature_category :incident_management def index end diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb index a3f4d784f25..86121bed381 100644 --- a/app/controllers/projects/alerting/notifications_controller.rb +++ b/app/controllers/projects/alerting/notifications_controller.rb @@ -10,7 +10,7 @@ module Projects prepend_before_action :repository, :project_without_auth - feature_category :alert_management + feature_category :incident_management def create token = extract_alert_manager_token(request) diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 02e941db636..d1c0f7edc5c 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -33,7 +33,6 @@ class Projects::BlobController < Projects::ApplicationController before_action :set_last_commit_sha, only: [:edit, :update] before_action only: :show do - push_frontend_feature_flag(:suggest_pipeline, default_enabled: true) push_frontend_feature_flag(:gitlab_ci_yml_preview, @project, default_enabled: false) end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 7fbeac12644..da19ddf6105 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -69,7 +69,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic } options = additional_attributes.merge( - diff_view: unified_diff_lines_view_type(@merge_request.project), + diff_view: "inline", merge_ref_head_diff: render_merge_ref_head_diff? ) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index f2b41294a85..627d578643f 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -27,7 +27,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] before_action only: [:show] do - push_frontend_feature_flag(:suggest_pipeline, default_enabled: true) push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true) push_frontend_feature_flag(:multiline_comments, @project, default_enabled: true) @@ -36,11 +35,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true) push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true) push_frontend_feature_flag(:merge_request_widget_graphql, @project) - push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true) push_frontend_feature_flag(:unified_diff_components, @project) push_frontend_feature_flag(:highlight_current_diff_row, @project) push_frontend_feature_flag(:default_merge_ref_for_diffs, @project) push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true) + push_frontend_feature_flag(:core_security_mr_widget_counts, @project) push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true) push_frontend_feature_flag(:test_failure_history, @project) @@ -481,7 +480,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo def endpoint_metadata_url(project, merge_request) params = request.query_parameters - params[:view] = unified_diff_lines_view_type(project) + params[:view] = "inline" if Feature.enabled?(:default_merge_ref_for_diffs, project) params = params.merge(diff_head: true) diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 31189c888b7..dcd3c49441e 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -6,9 +6,6 @@ class Projects::MilestonesController < Projects::ApplicationController before_action :check_issuables_available! before_action :milestone, only: [:edit, :update, :destroy, :show, :issues, :merge_requests, :participants, :labels, :promote] - before_action do - push_frontend_feature_flag(:burnup_charts, @project, default_enabled: true) - end # Allow read any milestone before_action :authorize_read_milestone! diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb index 2892542e63c..7f711417f0b 100644 --- a/app/controllers/projects/prometheus/alerts_controller.rb +++ b/app/controllers/projects/prometheus/alerts_controller.rb @@ -16,7 +16,7 @@ module Projects before_action :authorize_read_prometheus_alerts!, except: [:notify] before_action :alert, only: [:update, :show, :destroy, :metrics_dashboard] - feature_category :alert_management + feature_category :incident_management def index render json: serialize_as_json(alerts) diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index c9386a2edec..f8155b77e60 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -7,7 +7,6 @@ module Projects before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token] before_action do - push_frontend_feature_flag(:http_integrations_list, @project) push_frontend_feature_flag(:multiple_http_integrations_custom_mapping, @project) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index c03a820b384..8666b0f9576 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -34,12 +34,6 @@ class ProjectsController < Projects::ApplicationController # Project Export Rate Limit before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export] - # Experiments - before_action only: [:new, :create] do - frontend_experimentation_tracking_data(:new_create_project_ui, 'click_tab') - push_frontend_experiment(:new_create_project_ui) - end - before_action only: [:edit] do push_frontend_feature_flag(:service_desk_custom_address, @project) push_frontend_feature_flag(:approval_suggestions, @project, default_enabled: true) diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb index 96185608c09..ab846966792 100644 --- a/app/controllers/repositories/lfs_api_controller.rb +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -92,16 +92,26 @@ module Repositories { upload: { href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}", - header: { - Authorization: authorization_header, - # git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This - # ensures that Workhorse can intercept the request. - 'Content-Type': LFS_TRANSFER_CONTENT_TYPE - }.compact + header: upload_headers } } end + def upload_headers + headers = { + Authorization: authorization_header, + # git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This + # ensures that Workhorse can intercept the request. + 'Content-Type': LFS_TRANSFER_CONTENT_TYPE + } + + if Feature.enabled?(:lfs_chunked_encoding, project) + headers['Transfer-Encoding'] = 'chunked' + end + + headers + end + def lfs_check_batch_operation! if batch_operation_disallowed? render( diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 6692c285335..2c827292928 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -27,6 +27,10 @@ class UploadsController < ApplicationController feature_category :not_owned + def self.model_classes + MODEL_CLASSES + end + def uploader_class PersonalFileUploader end @@ -99,7 +103,7 @@ class UploadsController < ApplicationController end def upload_model_class - MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError) + self.class.model_classes[params[:model]] || raise(UnknownUploadModelError) end def upload_model_class_has_mounts? @@ -112,3 +116,5 @@ class UploadsController < ApplicationController upload_model_class.uploader_options.has_key?(upload_mount) end end + +UploadsController.prepend_if_ee('EE::UploadsController') diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index d431c3e3699..922b53b514d 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -339,15 +339,6 @@ class IssuableFinder cte << items items = klass.with(cte.to_arel).from(klass.table_name) - elsif Feature.enabled?(:pg_hint_plan_for_issuables, params.project) - items = items.optimizer_hints(<<~HINTS) - BitmapScan( - issues idx_issues_on_project_id_and_created_at_and_id_and_state_id - idx_issues_on_project_id_and_due_date_and_id_and_state_id - idx_issues_on_project_id_and_updated_at_and_id_and_state_id - index_issues_on_project_id_and_iid - ) - HINTS end items.full_search(search, matched_columns: params[:in], use_minimum_char_limit: !use_cte_for_search?) diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 1f847b09752..978550aedaf 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -41,6 +41,8 @@ class MergeRequestsFinder < IssuableFinder :environment, :merged_after, :merged_before, + :reviewer_id, + :reviewer_username, :target_branch, :wip ] @@ -54,6 +56,10 @@ class MergeRequestsFinder < IssuableFinder MergeRequest end + def params_class + MergeRequestsFinder::Params + end + def filter_items(_items) items = by_commit(super) items = by_source_branch(items) @@ -62,12 +68,14 @@ class MergeRequestsFinder < IssuableFinder items = by_merged_at(items) items = by_approvals(items) items = by_deployments(items) + items = by_reviewer(items) by_source_project_id(items) end def filter_negated_items(items) items = super(items) + items = by_negated_reviewer(items) by_negated_target_branch(items) end @@ -186,6 +194,30 @@ class MergeRequestsFinder < IssuableFinder items.where_exists(deploys) end + + def by_reviewer(items) + return items unless params.reviewer_id? || params.reviewer_username? + + if params.filter_by_no_reviewer? + items.no_review_requested + elsif params.filter_by_any_reviewer? + items.review_requested + elsif params.reviewer + items.review_requested_to(params.reviewer) + else # reviewer not found + items.none + end + end + + def by_negated_reviewer(items) + return items unless not_params.reviewer_id? || not_params.reviewer_username? + + if not_params.reviewer.present? + items.no_review_requested_to(not_params.reviewer) + else # reviewer not found + items.none + end + end end MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder') diff --git a/app/finders/merge_requests_finder/params.rb b/app/finders/merge_requests_finder/params.rb new file mode 100644 index 00000000000..e44e96054d3 --- /dev/null +++ b/app/finders/merge_requests_finder/params.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class MergeRequestsFinder + class Params < IssuableFinder::Params + def filter_by_no_reviewer? + params[:reviewer_id].to_s.downcase == FILTER_NONE + end + + def filter_by_any_reviewer? + params[:reviewer_id].to_s.downcase == FILTER_ANY + end + + def reviewer + strong_memoize(:reviewer) do + if reviewer_id? + User.find_by_id(params[:reviewer_id]) + elsif reviewer_username? + User.find_by_username(params[:reviewer_username]) + else + nil + end + end + end + end +end diff --git a/app/finders/releases/evidence_pipeline_finder.rb b/app/finders/releases/evidence_pipeline_finder.rb new file mode 100644 index 00000000000..2e706087feb --- /dev/null +++ b/app/finders/releases/evidence_pipeline_finder.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Releases + class EvidencePipelineFinder + include Gitlab::Utils::StrongMemoize + + attr_reader :project, :params + + def initialize(project, params = {}) + @project = project + @params = params + end + + def execute + # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245 + return params[:evidence_pipeline] if params[:evidence_pipeline] + + sha = existing_tag&.dereferenced_target&.sha + sha ||= repository&.commit(ref)&.sha + + return unless sha + + project.ci_pipelines.for_sha(sha).last + end + + private + + def repository + strong_memoize(:repository) do + project.repository + end + end + + def existing_tag + repository.find_tag(tag_name) + end + + def tag_name + params[:tag] + end + + def ref + params[:ref] + end + end +end diff --git a/app/graphql/mutations/alert_management/create_alert_issue.rb b/app/graphql/mutations/alert_management/create_alert_issue.rb index 2ddb94700c2..2c128e1b339 100644 --- a/app/graphql/mutations/alert_management/create_alert_issue.rb +++ b/app/graphql/mutations/alert_management/create_alert_issue.rb @@ -10,6 +10,7 @@ module Mutations result = create_alert_issue(alert, current_user) track_usage_event(:incident_management_incident_created, current_user.id) + track_usage_event(:incident_management_alert_create_incident, current_user.id) prepare_response(alert, result) end diff --git a/app/graphql/mutations/award_emojis/add.rb b/app/graphql/mutations/award_emojis/add.rb index 856fdd5fb14..e7ee2ec4fad 100644 --- a/app/graphql/mutations/award_emojis/add.rb +++ b/app/graphql/mutations/award_emojis/add.rb @@ -8,8 +8,6 @@ module Mutations def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) - check_object_is_awardable!(awardable) - service = ::AwardEmojis::AddService.new(awardable, args[:name], current_user).execute { diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb index df6b883529e..28140054dea 100644 --- a/app/graphql/mutations/award_emojis/base.rb +++ b/app/graphql/mutations/award_emojis/base.rb @@ -3,6 +3,10 @@ module Mutations module AwardEmojis class Base < BaseMutation + include ::Mutations::FindsByGid + + NOT_EMOJI_AWARDABLE = 'You cannot award emoji to this resource.' + authorize :award_emoji argument :awardable_id, @@ -22,20 +26,15 @@ module Mutations private + # TODO: remove this method when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 def find_object(id:) - # TODO: remove this line when the compatibility layer is removed - # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 - id = ::Types::GlobalIDType[::Awardable].coerce_isolated_input(id) - GitlabSchema.find_by_gid(id) + super(id: ::Types::GlobalIDType[::Awardable].coerce_isolated_input(id)) end - # Called by mutations methods after performing an authorization check - # of an awardable object. - def check_object_is_awardable!(object) - unless object.is_a?(Awardable) && object.emoji_awardable? - raise Gitlab::Graphql::Errors::ResourceNotAvailable, - 'Cannot award emoji to this resource' - end + def authorize!(object) + super + raise_resource_not_available_error!(NOT_EMOJI_AWARDABLE) unless object.emoji_awardable? end end end diff --git a/app/graphql/mutations/award_emojis/remove.rb b/app/graphql/mutations/award_emojis/remove.rb index c654688c6dc..a9655daeea7 100644 --- a/app/graphql/mutations/award_emojis/remove.rb +++ b/app/graphql/mutations/award_emojis/remove.rb @@ -8,8 +8,6 @@ module Mutations def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) - check_object_is_awardable!(awardable) - service = ::AwardEmojis::DestroyService.new(awardable, args[:name], current_user).execute { diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb index 679ec7a14ff..e741f972b1b 100644 --- a/app/graphql/mutations/award_emojis/toggle.rb +++ b/app/graphql/mutations/award_emojis/toggle.rb @@ -12,8 +12,6 @@ module Mutations def resolve(args) awardable = authorized_find!(id: args[:awardable_id]) - check_object_is_awardable!(awardable) - service = ::AwardEmojis::ToggleService.new(awardable, args[:name], current_user).execute toggled_on = awardable.awarded_emoji?(args[:name], current_user) diff --git a/app/graphql/mutations/concerns/mutations/finds_by_gid.rb b/app/graphql/mutations/concerns/mutations/finds_by_gid.rb new file mode 100644 index 00000000000..157f87a413d --- /dev/null +++ b/app/graphql/mutations/concerns/mutations/finds_by_gid.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Mutations + module FindsByGid + def find_object(id:) + GitlabSchema.find_by_gid(id) + end + end +end diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb index 9b216b31f9b..d34e351b2a6 100644 --- a/app/graphql/mutations/issues/update.rb +++ b/app/graphql/mutations/issues/update.rb @@ -11,7 +11,7 @@ module Mutations required: false, description: copy_field_description(Types::IssueType, :title) - argument :milestone_id, GraphQL::ID_TYPE, + argument :milestone_id, GraphQL::ID_TYPE, # rubocop: disable Graphql/IDType required: false, description: 'The ID of the milestone to assign to the issue. On update milestone will be removed if set to null' diff --git a/app/graphql/mutations/releases/create.rb b/app/graphql/mutations/releases/create.rb index 57c1541c368..156cd252848 100644 --- a/app/graphql/mutations/releases/create.rb +++ b/app/graphql/mutations/releases/create.rb @@ -40,12 +40,11 @@ module Mutations authorize :create_release - def resolve(project_path:, milestones: nil, assets: nil, **scalars) + def resolve(project_path:, assets: nil, **scalars) project = authorized_find!(full_path: project_path) params = { **scalars, - milestones: milestones.presence || [], assets: assets.to_h }.with_indifferent_access diff --git a/app/graphql/mutations/releases/update.rb b/app/graphql/mutations/releases/update.rb new file mode 100644 index 00000000000..bf72b907679 --- /dev/null +++ b/app/graphql/mutations/releases/update.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Mutations + module Releases + class Update < Base + graphql_name 'ReleaseUpdate' + + field :release, + Types::ReleaseType, + null: true, + description: 'The release after mutation.' + + argument :tag_name, GraphQL::STRING_TYPE, + required: true, as: :tag, + description: 'Name of the tag associated with the release' + + argument :name, GraphQL::STRING_TYPE, + required: false, + description: 'Name of the release' + + argument :description, GraphQL::STRING_TYPE, + required: false, + description: 'Description (release notes) of the release' + + argument :released_at, Types::TimeType, + required: false, + description: 'The release date' + + argument :milestones, [GraphQL::STRING_TYPE], + required: false, + description: 'The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.' + + authorize :update_release + + def ready?(**args) + if args.key?(:released_at) && args[:released_at].nil? + raise Gitlab::Graphql::Errors::ArgumentError, + 'if the releasedAt argument is provided, it cannot be null' + end + + if args.key?(:milestones) && args[:milestones].nil? + raise Gitlab::Graphql::Errors::ArgumentError, + 'if the milestones argument is provided, it cannot be null' + end + + super + end + + def resolve(project_path:, **scalars) + project = authorized_find!(full_path: project_path) + + params = scalars.with_indifferent_access + + release_result = ::Releases::UpdateService.new(project, current_user, params).execute + + if release_result[:status] == :success + { + release: release_result[:release], + errors: [] + } + else + { + release: nil, + errors: [release_result[:message]] + } + end + end + end + end +end diff --git a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb index 669b487db10..13b5672d750 100644 --- a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb +++ b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb @@ -3,6 +3,8 @@ module Resolvers module ErrorTracking class SentryErrorStackTraceResolver < BaseResolver + type Types::ErrorTracking::SentryErrorStackTraceType, null: true + argument :id, ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError], required: true, description: 'ID of the Sentry issue' diff --git a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb index c5cf924ce7f..e844ffedbeb 100644 --- a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb +++ b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb @@ -4,19 +4,26 @@ module Resolvers module ErrorTracking class SentryErrorsResolver < BaseResolver type Types::ErrorTracking::SentryErrorType.connection_type, null: true + extension Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension + + argument :search_term, ::GraphQL::STRING_TYPE, + description: 'Search query for the Sentry error details', + required: false + + # TODO: convert to Enum + argument :sort, ::GraphQL::STRING_TYPE, + description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default', + required: false + + delegate :project, to: :object def resolve(**args) args[:cursor] = args.delete(:after) - project = object.project - result = ::ErrorTracking::ListIssuesService.new( - project, - context[:current_user], - args - ).execute + result = ::ErrorTracking::ListIssuesService.new(project, current_user, args).execute - next_cursor = result[:pagination]&.dig('next', 'cursor') - previous_cursor = result[:pagination]&.dig('previous', 'cursor') + next_cursor = result.dig(:pagination, 'next', 'cursor') + previous_cursor = result.dig(:pagination, 'previous', 'cursor') issues = result[:issues] # ReactiveCache is still fetching data @@ -24,6 +31,10 @@ module Resolvers Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *issues) end + + def self.field_options + super.merge(connection: false) # we manage the pagination manually, so opt out of the connection field extension + end end end end diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb index 798e0433d06..49d5d62c860 100644 --- a/app/graphql/types/error_tracking/sentry_error_collection_type.rb +++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb @@ -9,27 +9,12 @@ module Types authorize :read_sentry_issue field :errors, - Types::ErrorTracking::SentryErrorType.connection_type, - connection: false, - null: true, description: "Collection of Sentry Errors", - extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension], - resolver: Resolvers::ErrorTracking::SentryErrorsResolver do - argument :search_term, - String, - description: 'Search query for the Sentry error details', - required: false - argument :sort, - String, - description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default', - required: false - end - field :detailed_error, Types::ErrorTracking::SentryDetailedErrorType, - null: true, + resolver: Resolvers::ErrorTracking::SentryErrorsResolver + field :detailed_error, description: 'Detailed version of a Sentry error on the project', resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver - field :error_stack_trace, Types::ErrorTracking::SentryErrorStackTraceType, - null: true, + field :error_stack_trace, description: 'Stack Trace of Sentry Error', resolver: Resolvers::ErrorTracking::SentryErrorStackTraceResolver field :external_url, diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 75ccac6d590..18576b4ca34 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -65,6 +65,7 @@ module Types mount_mutation Mutations::Notes::RepositionImageDiffNote mount_mutation Mutations::Notes::Destroy mount_mutation Mutations::Releases::Create + mount_mutation Mutations::Releases::Update mount_mutation Mutations::Terraform::State::Delete mount_mutation Mutations::Terraform::State::Lock mount_mutation Mutations::Terraform::State::Unlock diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 5a436886117..16f758c0c6b 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -238,8 +238,7 @@ module Types field :jira_imports, Types::JiraImportType.connection_type, null: true, - description: 'Jira imports into the project', - resolver: Resolvers::Projects::JiraImportsResolver + description: 'Jira imports into the project' field :services, Types::Projects::ServiceType.connection_type, diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 11c5369f726..ddba6589474 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -38,6 +38,8 @@ module Types feature_flag: :user_group_counts field :status, Types::UserStatusType, null: true, description: 'User status' + field :location, ::GraphQL::STRING_TYPE, null: true, + description: 'The location of the user.' field :project_memberships, Types::ProjectMemberType.connection_type, null: true, description: 'Project memberships of the user', method: :project_members diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 512649b3008..9a43a4a3a15 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -49,12 +49,12 @@ module ApplicationSettingsHelper all_protocols_enabled? || Gitlab::CurrentSettings.enabled_git_access_protocol == 'http' end - def enabled_project_button(project, protocol) + def enabled_protocol_button(container, protocol) case protocol when 'ssh' - ssh_clone_button(project, append_link: false) + ssh_clone_button(container, append_link: false) else - http_clone_button(project, append_link: false) + http_clone_button(container, append_link: false) end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 981b5e4d92b..2faa24393cd 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -382,8 +382,7 @@ module BlobHelper end def show_suggest_pipeline_creation_celebration? - Feature.enabled?(:suggest_pipeline, default_enabled: true) && - @blob.path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] && + @blob.path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] && @blob.auxiliary_viewer&.valid?(project: @project, sha: @commit.sha, user: current_user) && @project.uses_default_ci_config? && cookies[suggest_pipeline_commit_cookie_name].present? diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index c999d1f94ad..ea24f469ffa 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -58,10 +58,10 @@ module ButtonHelper end end - def http_clone_button(project, append_link: true) + def http_clone_button(container, append_link: true) protocol = gitlab_config.protocol.upcase dropdown_description = http_dropdown_description(protocol) - append_url = project.http_url_to_repo if append_link + append_url = container.http_url_to_repo if append_link dropdown_item_with_description(protocol, dropdown_description, href: append_url, data: { clone_type: 'http' }) end @@ -74,13 +74,13 @@ module ButtonHelper end end - def ssh_clone_button(project, append_link: true) + def ssh_clone_button(container, append_link: true) if Gitlab::CurrentSettings.user_show_add_ssh_key_message? && current_user.try(:require_ssh_key?) - dropdown_description = _("You won't be able to pull or push project code via SSH until you add an SSH key to your profile") + dropdown_description = s_("MissingSSHKeyWarningLink|You won't be able to pull or push repositories via SSH until you add an SSH key to your profile") end - append_url = project.ssh_url_to_repo if append_link + append_url = container.ssh_url_to_repo if append_link dropdown_item_with_description('SSH', dropdown_description, href: append_url, data: { clone_type: 'ssh' }) end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index d6d06434590..69a2efebb1f 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -203,14 +203,6 @@ module DiffHelper set_secure_cookie(:diff_view, params.delete(:view), type: CookiesHelper::COOKIE_TYPE_PERMANENT) if params[:view].present? end - def unified_diff_lines_view_type(project) - if Feature.enabled?(:unified_diff_lines, project, default_enabled: true) - 'inline' - else - diff_view - end - end - private def diff_btn(title, name, selected) diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index e10e9a83b05..45f5281b515 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -51,7 +51,7 @@ module DropdownsHelper default_label = data_attr[:default_label] content_tag(:button, disabled: options[:disabled], class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}") - output << icon('chevron-down') + output << sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3") output.html_safe end end diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index c4487ae8e4a..491d2731e91 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -52,6 +52,8 @@ module EnvironmentHelper s_('Deployment|failed') when 'canceled' s_('Deployment|canceled') + when 'skipped' + s_('Deployment|skipped') end klass = "ci-status ci-#{status.dasherize}" diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 29ead76a607..a0a840add94 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -189,6 +189,10 @@ module GroupsHelper params.key?(:purchased_quantity) && params[:purchased_quantity].to_i > 0 end + def project_list_sort_by + @group_projects_sort || @sort || params[:sort] || sort_value_recently_created + end + private def just_created? diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index de1e0e4e05e..2d47ee89d11 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -31,7 +31,7 @@ module SearchHelper [ resources_results, generic_results - ].flatten.uniq do |item| + ].flatten do |item| item[:label] end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 10174e5d719..2166c3faec4 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -40,6 +40,7 @@ module SortingHelper sort_value_latest_activity => sort_title_latest_activity, sort_value_recently_created => sort_title_created_date, sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name_desc, sort_value_stars_desc => sort_title_stars } @@ -95,8 +96,8 @@ module SortingHelper sort_value_name_desc => sort_title_name_desc, sort_value_recently_created => sort_title_recently_created, sort_value_oldest_created => sort_title_oldest_created, - sort_value_recently_updated => sort_title_recently_updated, - sort_value_oldest_updated => sort_title_oldest_updated + sort_value_latest_activity => sort_title_recently_updated, + sort_value_oldest_activity => sort_title_oldest_updated } end diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb index 13bf9c92d52..d6a4d6ac57a 100644 --- a/app/helpers/storage_helper.rb +++ b/app/helpers/storage_helper.rb @@ -15,9 +15,11 @@ module StorageHelper counter_wikis: storage_counter(statistics.wiki_size), counter_build_artifacts: storage_counter(statistics.build_artifacts_size), counter_lfs_objects: storage_counter(statistics.lfs_objects_size), - counter_snippets: storage_counter(statistics.snippets_size) + counter_snippets: storage_counter(statistics.snippets_size), + counter_packages: storage_counter(statistics.packages_size), + counter_uploads: storage_counter(statistics.uploads_size) } - _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets}") % counters + _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters end end diff --git a/app/helpers/suggest_pipeline_helper.rb b/app/helpers/suggest_pipeline_helper.rb index 3151b792344..f0a12f0e268 100644 --- a/app/helpers/suggest_pipeline_helper.rb +++ b/app/helpers/suggest_pipeline_helper.rb @@ -2,8 +2,6 @@ module SuggestPipelineHelper def should_suggest_gitlab_ci_yml? - Feature.enabled?(:suggest_pipeline, default_enabled: true) && - current_user && - params[:suggest_gitlab_ci_yml] == 'true' + current_user && params[:suggest_gitlab_ci_yml] == 'true' end end diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index e93c1b82cd7..a06a31ddf32 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -57,7 +57,10 @@ module UserCalloutsHelper end def show_registration_enabled_user_callout? - current_user&.admin? && signup_enabled? && !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) + !Gitlab.com? && + current_user&.admin? && + signup_enabled? && + !user_dismissed?(REGISTRATION_ENABLED_CALLOUT) end private diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 7d4ab192f2f..fbd95094fbd 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -123,6 +123,19 @@ module UsersHelper } end + def unblock_user_modal_data(user) + { + path: unblock_admin_user_path(user), + method: 'put', + modal_attributes: { + title: s_('AdminUsers|Unblock user %{username}?') % { username: sanitize_name(user.name) }, + message: s_('AdminUsers|You can always block their account again if needed.'), + okVariant: 'info', + okTitle: s_('AdminUsers|Unblock') + }.to_json + } + end + def user_block_effects header = tag.p s_('AdminUsers|Blocking user has the following effects:') diff --git a/app/models/analytics/devops_adoption/segment.rb b/app/models/analytics/devops_adoption/segment.rb index 71d4a312627..baab5b94126 100644 --- a/app/models/analytics/devops_adoption/segment.rb +++ b/app/models/analytics/devops_adoption/segment.rb @@ -7,7 +7,7 @@ class Analytics::DevopsAdoption::Segment < ApplicationRecord has_many :groups, through: :segment_selections validates :name, presence: true, uniqueness: true, length: { maximum: 255 } - validate :validate_segment_count + validate :validate_segment_count, on: :create accepts_nested_attributes_for :segment_selections, allow_destroy: true diff --git a/app/models/analytics/devops_adoption/segment_selection.rb b/app/models/analytics/devops_adoption/segment_selection.rb index 6b70c13a773..8f95ce088a2 100644 --- a/app/models/analytics/devops_adoption/segment_selection.rb +++ b/app/models/analytics/devops_adoption/segment_selection.rb @@ -14,7 +14,7 @@ class Analytics::DevopsAdoption::SegmentSelection < ApplicationRecord validates :group_id, uniqueness: { scope: :segment_id, if: :group } validate :exclusive_project_or_group - validate :validate_selection_count + validate :validate_selection_count, on: :create private @@ -27,9 +27,9 @@ class Analytics::DevopsAdoption::SegmentSelection < ApplicationRecord def validate_selection_count return unless segment - selection_count_for_segment = self.class.where(segment: segment).count - - if selection_count_for_segment >= ALLOWED_SELECTIONS_PER_SEGMENT + # handle single model creation and bulk creation from accepts_nested_attributes_for + selections = segment.segment_selections + [self] + if selections.reject(&:marked_for_destruction?).uniq.size > ALLOWED_SELECTIONS_PER_SEGMENT errors.add(:segment, s_('DevopsAdoptionSegmentSelection|The maximum number of selections has been reached')) end end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 34030e079c7..f0f4d3ef339 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -59,6 +59,25 @@ class BulkImports::Entity < ApplicationRecord end end + def update_tracker_for(relation:, has_next_page:, next_page: nil) + attributes = { + relation: relation, + has_next_page: has_next_page, + next_page: next_page, + bulk_import_entity_id: id + } + + trackers.upsert(attributes, unique_by: %i[bulk_import_entity_id relation]) + end + + def has_next_page?(relation) + trackers.find_by(relation: relation)&.has_next_page + end + + def next_page_for(relation) + trackers.find_by(relation: relation)&.next_page + end + private def validate_parent_is_a_group diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 5b23cf46fdb..445775fc6f3 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -206,7 +206,7 @@ module Ci override :dependency_variables def dependency_variables - return [] unless ::Feature.enabled?(:ci_bridge_dependency_variables, project) + return [] unless ::Feature.enabled?(:ci_bridge_dependency_variables, project, default_enabled: true) super end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 84abd01786d..4b1299c7aee 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -379,8 +379,16 @@ module Ci Ci::BuildRunnerSession.where(build: build).delete_all end - after_transition any => [:skipped, :canceled] do |build| - build.deployment&.cancel + after_transition any => [:skipped, :canceled] do |build, transition| + if Feature.enabled?(:cd_skipped_deployment_status, build.project) + if transition.to_name == :skipped + build.deployment&.skip + else + build.deployment&.cancel + end + else + build.deployment&.cancel + end end end @@ -915,6 +923,14 @@ module Ci coverage_report end + def collect_codequality_reports!(codequality_report) + each_report(Ci::JobArtifact::CODEQUALITY_REPORT_FILE_TYPES) do |file_type, blob| + Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, codequality_report) + end + + codequality_report + end + def collect_terraform_reports!(terraform_reports) each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact| ::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact) diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb index d3051e3dadc..31afefba504 100644 --- a/app/models/ci/build_trace_chunks/fog.rb +++ b/app/models/ci/build_trace_chunks/fog.rb @@ -14,11 +14,15 @@ module Ci end def set_data(model, new_data) - # TODO: Support AWS S3 server side encryption - files.create({ - key: key(model), - body: new_data - }) + if Feature.enabled?(:ci_live_trace_use_fog_attributes) + files.create(create_attributes(model, new_data)) + else + # TODO: Support AWS S3 server side encryption + files.create({ + key: key(model), + body: new_data + }) + end end def append_data(model, new_data, offset) @@ -57,6 +61,13 @@ module Ci key_raw(model.build_id, model.chunk_index) end + def create_attributes(model, new_data) + { + key: key(model), + body: new_data + }.merge(object_store_config.fog_attributes) + end + def key_raw(build_id, chunk_index) "tmp/builds/#{build_id.to_i}/chunks/#{chunk_index.to_i}.log" end @@ -84,6 +95,14 @@ module Ci def object_store Gitlab.config.artifacts.object_store end + + def object_store_raw_config + object_store + end + + def object_store_config + @object_store_config ||= ::ObjectStorage::Config.new(object_store_raw_config) + end end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 7cedd13b407..c80d50ea131 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -7,15 +7,13 @@ module Ci include UpdateProjectStatistics include UsageStatistics include Sortable - include IgnorableColumns include Artifactable include FileStoreMounter extend Gitlab::Ci::Model - ignore_columns :locked, remove_after: '2020-07-22', remove_with: '13.4' - TEST_REPORT_FILE_TYPES = %w[junit].freeze COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze + CODEQUALITY_REPORT_FILE_TYPES = %w[codequality].freeze ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze NON_ERASABLE_FILE_TYPES = %w[trace].freeze TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze @@ -157,6 +155,10 @@ module Ci with_file_types(COVERAGE_REPORT_FILE_TYPES) end + scope :codequality_reports, -> do + with_file_types(CODEQUALITY_REPORT_FILE_TYPES) + end + scope :terraform_reports, -> do with_file_types(TERRAFORM_REPORT_FILE_TYPES) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 8707d635e03..4bfb38cbe2d 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -956,6 +956,14 @@ module Ci end end + def codequality_reports + Gitlab::Ci::Reports::CodequalityReports.new.tap do |codequality_reports| + latest_report_builds(Ci::JobArtifact.codequality_reports).each do |build| + build.collect_codequality_reports!(codequality_reports) + end + end + end + def terraform_reports ::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports| latest_report_builds(::Ci::JobArtifact.terraform_reports).each do |build| diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index d1d6defb713..6f4b273a2c8 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -4,8 +4,8 @@ require 'openssl' module Clusters module Applications - # DEPRECATED: This model represents the Helm 2 Tiller server, and is no longer being actively used. - # It is being kept around for a potential cleanup of the unused Tiller server. + # DEPRECATED: This model represents the Helm 2 Tiller server. + # It is being kept around to enable the cleanup of the unused Tiller server. class Helm < ApplicationRecord self.table_name = 'clusters_applications_helm' @@ -27,29 +27,11 @@ module Clusters end def set_initial_status - return unless not_installable? - - self.status = status_states[:installable] if cluster&.platform_kubernetes_active? - end - - # It can only be uninstalled if there are no other applications installed - # or with intermitent installation statuses in the database. - def allowed_to_uninstall? - strong_memoize(:allowed_to_uninstall) do - applications = nil - - Clusters::Cluster::APPLICATIONS.each do |application_name, klass| - next if application_name == 'helm' - - extra_apps = Clusters::Applications::Helm.where('EXISTS (?)', klass.select(1).where(cluster_id: cluster_id)) - - applications = applications ? applications.or(extra_apps) : extra_apps - end - - !applications.exists? - end + # The legacy Tiller server is not installable, which is the initial status of every app end + # DEPRECATED: This command is only for development and testing purposes, to simulate + # a Helm 2 cluster with an existing Tiller server. def install_command Gitlab::Kubernetes::Helm::V2::InitCommand.new( name: name, @@ -70,13 +52,6 @@ module Clusters ca_key.present? && ca_cert.present? end - def post_uninstall - cluster.kubeclient.delete_namespace(Gitlab::Kubernetes::Helm::NAMESPACE) - rescue Kubeclient::ResourceNotFoundError - # we actually don't care if the namespace is not present - # since we want to delete it anyway. - end - private def files diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb index f01bd60ef16..b08c05b1934 100644 --- a/app/models/concerns/enums/internal_id.rb +++ b/app/models/concerns/enums/internal_id.rb @@ -15,7 +15,8 @@ module Enums operations_user_lists: 7, alert_management_alerts: 8, sprints: 9, # iterations - design_management_designs: 10 + design_management_designs: 10, + incident_management_oncall_schedules: 11 } end end diff --git a/app/models/concerns/optimized_issuable_label_filter.rb b/app/models/concerns/optimized_issuable_label_filter.rb index 7be4a26d4fa..82055822cfb 100644 --- a/app/models/concerns/optimized_issuable_label_filter.rb +++ b/app/models/concerns/optimized_issuable_label_filter.rb @@ -1,6 +1,15 @@ # frozen_string_literal: true module OptimizedIssuableLabelFilter + extend ActiveSupport::Concern + + prepended do + extend Gitlab::Cache::RequestCache + + # Avoid repeating label queries times when the finder is instantiated multiple times during the request. + request_cache(:find_label_ids) { [root_namespace.id, params.label_names] } + end + def by_label(items) return items unless params.labels? @@ -41,7 +50,7 @@ module OptimizedIssuableLabelFilter def issuables_with_selected_labels(items, target_model) if root_namespace - all_label_ids = find_label_ids(root_namespace) + all_label_ids = find_label_ids # Found less labels in the DB than we were searching for. Return nothing. return items.none if all_label_ids.size != params.label_names.size @@ -57,18 +66,20 @@ module OptimizedIssuableLabelFilter items end - def find_label_ids(root_namespace) - finder_params = { - include_subgroups: true, - include_ancestor_groups: true, - include_descendant_groups: true, - group: root_namespace, - title: params.label_names - } - - LabelsFinder - .new(nil, finder_params) - .execute(skip_authorization: true) + def find_label_ids + group_labels = Label + .where(project_id: nil) + .where(title: params.label_names) + .where(group_id: root_namespace.self_and_descendants.select(:id)) + + project_labels = Label + .where(group_id: nil) + .where(title: params.label_names) + .where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendants.select(:id))) + + Label + .from_union([group_labels, project_labels], remove_duplicates: false) + .reorder(nil) .pluck(:title, :id) .group_by(&:first) .values diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 36ac1bdb236..ad741366a74 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -41,8 +41,8 @@ class Deployment < ApplicationRecord scope :visible, -> { where(status: %i[running success failed canceled]) } scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success } scope :active, -> { where(status: %i[created running]) } - scope :older_than, -> (deployment) { where('id < ?', deployment.id) } - scope :with_deployable, -> { includes(:deployable).where('deployable_id IS NOT NULL') } + scope :older_than, -> (deployment) { where('deployments.id < ?', deployment.id) } + scope :with_deployable, -> { joins('INNER JOIN ci_builds ON ci_builds.id = deployments.deployable_id').preload(:deployable) } FINISHED_STATUSES = %i[success failed canceled].freeze @@ -63,6 +63,10 @@ class Deployment < ApplicationRecord transition any - [:canceled] => :canceled end + event :skip do + transition any - [:skipped] => :skipped + end + before_transition any => FINISHED_STATUSES do |deployment| deployment.finished_at = Time.current end @@ -105,7 +109,8 @@ class Deployment < ApplicationRecord running: 1, success: 2, failed: 3, - canceled: 4 + canceled: 4, + skipped: 5 } def self.last_for_environment(environment) @@ -144,6 +149,10 @@ class Deployment < ApplicationRecord project.repository.delete_refs(*ref_paths.flatten) end end + + def latest_for_sha(sha) + where(sha: sha).order(id: :desc).take + end end def commit @@ -297,6 +306,8 @@ class Deployment < ApplicationRecord drop when 'canceled' cancel + when 'skipped' + skip else raise ArgumentError, "The status #{status.inspect} is invalid" end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 4b2e62bf761..944a64f5419 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -19,7 +19,7 @@ class DiffNote < Note # EE might have added a type when the module was prepended validates :noteable_type, inclusion: { in: -> (_note) { noteable_types } } validate :positions_complete - validate :verify_supported + validate :verify_supported, unless: :importing? before_validation :set_line_code, if: :on_text?, unless: :importing? after_save :keep_around_commits, unless: :importing? @@ -149,7 +149,7 @@ class DiffNote < Note end def supported? - for_commit? || for_design? || self.noteable.has_complete_diff_refs? + for_commit? || for_design? || self.noteable&.has_complete_diff_refs? end def set_line_code diff --git a/app/models/environment.rb b/app/models/environment.rb index deded3eeae0..92e1caf5227 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -60,6 +60,7 @@ class Environment < ApplicationRecord addressable_url: true delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true + delegate :auto_rollback_enabled?, to: :project scope :available, -> { with_state(:available) } scope :stopped, -> { with_state(:stopped) } @@ -240,10 +241,6 @@ class Environment < ApplicationRecord def cancel_deployment_jobs! jobs = active_deployments.with_deployable jobs.each do |deployment| - # guard against data integrity issues, - # for example https://gitlab.com/gitlab-org/gitlab/-/issues/218659#note_348823660 - next unless deployment.deployable - Gitlab::OptimisticLocking.retry_lock(deployment.deployable) do |deployable| deployable.cancel! if deployable&.cancelable? end diff --git a/app/models/exported_protected_branch.rb b/app/models/exported_protected_branch.rb new file mode 100644 index 00000000000..6e8abbc2389 --- /dev/null +++ b/app/models/exported_protected_branch.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ExportedProtectedBranch < ProtectedBranch + has_many :push_access_levels, -> { where(deploy_key_id: nil) }, class_name: "ProtectedBranch::PushAccessLevel", foreign_key: :protected_branch_id +end diff --git a/app/models/issue.rb b/app/models/issue.rb index 7dc18cacd7c..14eed4bd607 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -328,7 +328,9 @@ class Issue < ApplicationRecord related_issues = ::Issue .select(['issues.*', 'issue_links.id AS issue_link_id', 'issue_links.link_type as issue_link_type_value', - 'issue_links.target_id as issue_link_source_id']) + 'issue_links.target_id as issue_link_source_id', + 'issue_links.created_at as issue_link_created_at', + 'issue_links.updated_at as issue_link_updated_at']) .joins("INNER JOIN issue_links ON (issue_links.source_id = issues.id AND issue_links.target_id = #{id}) OR diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index d379f85bc15..46673917008 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -314,6 +314,31 @@ class MergeRequest < ApplicationRecord scope :with_jira_issue_keys, -> { where('title ~ :regex OR merge_requests.description ~ :regex', regex: Gitlab::Regex.jira_issue_key_regex.source) } + scope :review_requested, -> do + where(reviewers_subquery.exists) + end + + scope :no_review_requested, -> do + where(reviewers_subquery.exists.not) + end + + scope :review_requested_to, ->(user) do + where( + reviewers_subquery + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user)) + .exists + ) + end + + scope :no_review_requested_to, ->(user) do + where( + reviewers_subquery + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user)) + .exists + .not + ) + end + after_save :keep_around_commit, unless: :importing? alias_attribute :project, :target_project @@ -361,6 +386,12 @@ class MergeRequest < ApplicationRecord end end + def self.reviewers_subquery + MergeRequestReviewer.arel_table + .project('true') + .where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) + end + def rebase_in_progress? rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid) end diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb index 1cb49c0cd76..c4e5274f832 100644 --- a/app/models/merge_request_reviewer.rb +++ b/app/models/merge_request_reviewer.rb @@ -2,5 +2,5 @@ class MergeRequestReviewer < ApplicationRecord belongs_to :merge_request - belongs_to :reviewer, class_name: "User", foreign_key: :user_id, inverse_of: :merge_request_assignees + belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 232d0a6b05d..238e8f70778 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -28,6 +28,7 @@ class Namespace < ApplicationRecord has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace' has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' + has_many :namespace_onboarding_actions # This should _not_ be `inverse_of: :namespace`, because that would also set # `user.namespace` when this user creates a group with themselves as `owner`. diff --git a/app/models/namespace_onboarding_action.rb b/app/models/namespace_onboarding_action.rb new file mode 100644 index 00000000000..ea0d9d495ae --- /dev/null +++ b/app/models/namespace_onboarding_action.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class NamespaceOnboardingAction < ApplicationRecord + belongs_to :namespace +end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 9855731778f..e33d09559ae 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -2,6 +2,8 @@ module Pages class LookupPath + include Gitlab::Utils::StrongMemoize + def initialize(project, trim_prefix: nil, domain: nil) @project = project @domain = domain @@ -37,37 +39,28 @@ module Pages attr_reader :project, :trim_prefix, :domain - def artifacts_archive - return unless Feature.enabled?(:pages_serve_from_artifacts_archive, project) - - project.pages_metadatum.artifacts_archive - end - def deployment - return unless Feature.enabled?(:pages_serve_from_deployments, project) + strong_memoize(:deployment) do + next unless Feature.enabled?(:pages_serve_from_deployments, project) - project.pages_metadatum.pages_deployment + project.pages_metadatum.pages_deployment + end end def zip_source - source = deployment || artifacts_archive - - return unless source&.file - - return if source.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project) + return unless deployment&.file - # artifacts archive doesn't support this - file_count = source.file_count if source.respond_to?(:file_count) + return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project) - global_id = ::Gitlab::GlobalId.build(source, id: source.id).to_s + global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s { type: 'zip', - path: source.file.url_or_file_path(expire_at: 1.day.from_now), + path: deployment.file.url_or_file_path(expire_at: 1.day.from_now), global_id: global_id, - sha256: source.file_sha256, - file_size: source.size, - file_count: file_count + sha256: deployment.file_sha256, + file_size: deployment.size, + file_count: deployment.file_count } end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 8192310ddfb..4004ea9a662 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -34,10 +34,10 @@ class PagesDomain < ApplicationRecord validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? } validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? } - default_value_for(:auto_ssl_enabled, allow_nil: false) { ::Gitlab::LetsEncrypt.enabled? } - default_value_for :scope, allow_nil: false, value: :project - default_value_for :wildcard, allow_nil: false, value: false - default_value_for :usage, allow_nil: false, value: :pages + default_value_for(:auto_ssl_enabled, allows_nil: false) { ::Gitlab::LetsEncrypt.enabled? } + default_value_for :scope, allows_nil: false, value: :project + default_value_for :wildcard, allows_nil: false, value: false + default_value_for :usage, allows_nil: false, value: :pages attr_encrypted :key, mode: :per_attribute_iv_and_salt, diff --git a/app/models/project.rb b/app/models/project.rb index ebd8e56246d..acdacd357c2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -222,6 +222,7 @@ class Project < ApplicationRecord has_many :snippets, class_name: 'ProjectSnippet' has_many :hooks, class_name: 'ProjectHook' has_many :protected_branches + has_many :exported_protected_branches has_many :protected_tags has_many :repository_languages, -> { order "share DESC" } has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design' diff --git a/app/models/release.rb b/app/models/release.rb index c56df0a6aa3..bebf91fb247 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -29,6 +29,8 @@ class Release < ApplicationRecord scope :preloaded, -> { includes(:evidences, :milestones, project: [:project_feature, :route, { namespace: :route }]) } scope :with_project_and_namespace, -> { includes(project: :namespace) } scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } + scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) } + scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) } # Sorting scope :order_created, -> { reorder('created_at ASC') } diff --git a/app/models/snippet.rb b/app/models/snippet.rb index dc370b46bda..2e1a2e8e2b2 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -213,7 +213,8 @@ class Snippet < ApplicationRecord def blobs return [] unless repository_exists? - repository.ls_files(default_branch).map { |file| Blob.lazy(repository, default_branch, file) } + branch = default_branch + list_files(branch).map { |file| Blob.lazy(repository, branch, file) } end def hook_attrs diff --git a/app/models/snippet_blob.rb b/app/models/snippet_blob.rb index cf1ab089829..bad24cc45f6 100644 --- a/app/models/snippet_blob.rb +++ b/app/models/snippet_blob.rb @@ -21,6 +21,10 @@ class SnippetBlob data.bytesize end + def commit_id + nil + end + def data snippet.content end diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 0ddf2c5fbcd..8dd471b259e 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SystemNoteMetadata < ApplicationRecord + include Importable + # These notes's action text might contain a reference that is external. # We should always force a deep validation upon references that are found # in this note type. @@ -23,7 +25,7 @@ class SystemNoteMetadata < ApplicationRecord status alert_issue_added relate unrelate new_alert_added severity ].freeze - validates :note, presence: true + validates :note, presence: true, unless: :importing? validates :action, inclusion: { in: :icon_types }, allow_nil: true belongs_to :note diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index d329b429c9d..19700587f09 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -3,7 +3,6 @@ module Terraform class State < ApplicationRecord include UsageStatistics - include FileStoreMounter include IgnorableColumns # These columns are being removed since geo replication falls to the versioned state # Tracking in https://gitlab.com/gitlab-org/gitlab/-/issues/258262 @@ -35,20 +34,9 @@ module Terraform format: { with: HEX_REGEXP, message: 'only allows hex characters' } default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) } - default_value_for(:versioning_enabled, true) - - mount_file_store_uploader StateUploader - - def file_store - super || StateUploader.default_store - end def latest_file - if versioning_enabled? - latest_version&.file - else - latest_version&.file || file - end + latest_version&.file end def locked? @@ -56,13 +44,14 @@ module Terraform end def update_file!(data, version:, build:) + # This check is required to maintain backwards compatibility with + # states that were created prior to versioning being supported. + # This can be removed in 14.0 when support for these states is dropped. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/258960 if versioning_enabled? create_new_version!(data: data, version: version, build: build) - elsif latest_version.present? - migrate_legacy_version!(data: data, version: version, build: build) else - self.file = data - save! + migrate_legacy_version!(data: data, version: version, build: build) end end diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb index cc5d94b8e09..19d708616fc 100644 --- a/app/models/terraform/state_version.rb +++ b/app/models/terraform/state_version.rb @@ -10,9 +10,9 @@ module Terraform scope :ordered_by_version_desc, -> { order(version: :desc) } - default_value_for(:file_store) { VersionedStateUploader.default_store } + default_value_for(:file_store) { StateUploader.default_store } - mount_file_store_uploader VersionedStateUploader + mount_file_store_uploader StateUploader delegate :project_id, :uuid, to: :terraform_state, allow_nil: true diff --git a/app/models/user.rb b/app/models/user.rb index be64e057d59..032ad26455c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -166,6 +166,7 @@ class User < ApplicationRecord has_many :issue_assignees, inverse_of: :assignee has_many :merge_request_assignees, inverse_of: :assignee + has_many :merge_request_reviewers, inverse_of: :reviewer has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request @@ -587,11 +588,13 @@ class User < ApplicationRecord sanitized_order_sql = Arel.sql(sanitize_sql_array([order, query: query])) - where( - fuzzy_arel_match(:name, query, lower_exact_match: true) - .or(fuzzy_arel_match(:username, query, lower_exact_match: true)) - .or(arel_table[:email].eq(query)) - ).reorder(sanitized_order_sql, :name) + search_query = if Feature.enabled?(:user_search_secondary_email) + search_with_secondary_emails(query) + else + search_without_secondary_emails(query) + end + + search_query.reorder(sanitized_order_sql, :name) end # Limits the result set to users _not_ in the given query/list of IDs. @@ -606,6 +609,18 @@ class User < ApplicationRecord reorder(:name) end + def search_without_secondary_emails(query) + return none if query.blank? + + query = query.downcase + + where( + fuzzy_arel_match(:name, query, lower_exact_match: true) + .or(fuzzy_arel_match(:username, query, lower_exact_match: true)) + .or(arel_table[:email].eq(query)) + ) + end + # searches user by given pattern # it compares name, email, username fields and user's secondary emails with given pattern # This method uses ILIKE on PostgreSQL. @@ -616,15 +631,16 @@ class User < ApplicationRecord query = query.downcase email_table = Email.arel_table - matched_by_emails_user_ids = email_table + matched_by_email_user_id = email_table .project(email_table[:user_id]) .where(email_table[:email].eq(query)) + .take(1) # at most 1 record as there is a unique constraint where( fuzzy_arel_match(:name, query) .or(fuzzy_arel_match(:username, query)) .or(arel_table[:email].eq(query)) - .or(arel_table[:id].in(matched_by_emails_user_ids)) + .or(arel_table[:id].eq(matched_by_email_user_id)) ) end diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index cfad58fc0db..ad5651f9439 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -26,7 +26,8 @@ class UserCallout < ApplicationRecord suggest_pipeline: 22, customize_homepage: 23, feature_flags_new_version: 24, - registration_enabled_callout: 25 + registration_enabled_callout: 25, + new_user_signups_cap_reached: 26 # EE-only } validates :user, presence: true diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index 5cfbcfec5c0..f49a6ee8498 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -27,3 +27,5 @@ class IssuablePolicy < BasePolicy prevent :award_emoji end end + +IssuablePolicy.prepend_if_ee('EE::IssuablePolicy') diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb index 8f3fc53af10..b52f3411c49 100644 --- a/app/presenters/projects/import_export/project_export_presenter.rb +++ b/app/presenters/projects/import_export/project_export_presenter.rb @@ -15,6 +15,10 @@ module Projects self.respond_to?(:override_description) ? override_description : super end + def protected_branches + project.exported_protected_branches + end + private def converted_group_members diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index e46b269ea35..afd4d5b9a2b 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -2,6 +2,9 @@ class MergeRequestWidgetEntity < Grape::Entity include RequestAwareEntity + include ProjectsHelper + include ApplicationHelper + include ApplicationSettingsHelper SUGGEST_PIPELINE = 'suggest_pipeline' @@ -48,6 +51,10 @@ class MergeRequestWidgetEntity < Grape::Entity help_page_path('user/project/merge_requests/resolve_conflicts.md') end + expose :reviewing_and_managing_merge_requests_docs_path do |merge_request| + help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally-through-the-head-ref") + end + expose :merge_request_pipelines_docs_path do |merge_request| help_page_path('ci/merge_request_pipelines/index.md') end @@ -67,15 +74,15 @@ class MergeRequestWidgetEntity < Grape::Entity ) end - expose :user_callouts_path, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request| + expose :user_callouts_path do |_merge_request| user_callouts_path end - expose :suggest_pipeline_feature_id, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request| + expose :suggest_pipeline_feature_id do |_merge_request| SUGGEST_PIPELINE end - expose :is_dismissed_suggest_pipeline, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request| + expose :is_dismissed_suggest_pipeline do |_merge_request| current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE) end @@ -87,6 +94,10 @@ class MergeRequestWidgetEntity < Grape::Entity new_project_pipeline_path(merge_request.project) end + expose :source_project_default_url do |merge_request| + merge_request.source_project && default_url_to_repo(merge_request.source_project) + end + # Rendering and redacting Markdown can be expensive. These links are # just nice to have in the merge request widget, so only # include them if they are explicitly requested on first load. diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb index bf5f643a51b..77a096e7586 100644 --- a/app/services/issuable/import_csv/base_service.rb +++ b/app/services/issuable/import_csv/base_service.rb @@ -38,7 +38,7 @@ module Issuable def with_csv_lines csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8) - verify_headers!(csv_data) + validate_headers_presence!(csv_data.lines.first) csv_parsing_params = { col_sep: detect_col_sep(csv_data.lines.first), @@ -49,9 +49,9 @@ module Issuable CSV.new(csv_data, csv_parsing_params).each.with_index(2) end - def verify_headers!(data) - headers = data.lines.first.downcase - return if headers.include?('title') && headers.include?('description') + def validate_headers_presence!(headers) + headers.downcase! if headers + return if headers && headers.include?('title') && headers.include?('description') raise CSV::MalformedCSVError end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index b9c579a130f..f4a08169af7 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -125,8 +125,6 @@ module Projects end def create_pages_deployment(artifacts_path, build) - return unless Feature.enabled?(:zip_pages_deployments, project, default_enabled: true) - # we're using the full archive and pages daemon needs to read it # so we want the total count from entries, not only "public/" directory # because it better approximates work we need to do before we can serve the site diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb index 38ef80ced56..d0e1577bd8d 100644 --- a/app/services/releases/base_service.rb +++ b/app/services/releases/base_service.rb @@ -11,8 +11,6 @@ module Releases @project, @current_user, @params = project, user, params.dup end - delegate :repository, to: :project - def tag_name params[:tag] end @@ -39,22 +37,18 @@ module Releases end end - def existing_tag - strong_memoize(:existing_tag) do - repository.find_tag(tag_name) - end - end - - def tag_exist? - existing_tag.present? - end - def repository strong_memoize(:repository) do project.repository end end + def existing_tag + strong_memoize(:existing_tag) do + repository.find_tag(tag_name) + end + end + def milestones return [] unless param_for_milestone_titles_provided? @@ -78,7 +72,7 @@ module Releases end def param_for_milestone_titles_provided? - params.key?(:milestones) + !!params[:milestones] end def execute_hooks(release, action = 'create') diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index deefe559d5d..11fdbaf3169 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -10,7 +10,7 @@ module Releases # should be found before the creation of new tag # because tag creation can spawn new pipeline # which won't have any data for evidence yet - evidence_pipeline = find_evidence_pipeline + evidence_pipeline = Releases::EvidencePipelineFinder.new(project, params).execute tag = ensure_tag @@ -78,26 +78,10 @@ module Releases ) end - def find_evidence_pipeline - # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245 - return params[:evidence_pipeline] if params[:evidence_pipeline] - - sha = existing_tag&.dereferenced_target&.sha - sha ||= repository.commit(ref)&.sha - - return unless sha - - project.ci_pipelines.for_sha(sha).last - end - def create_evidence!(release, pipeline) - return if release.historical_release? + return if release.historical_release? || release.upcoming_release? - if release.upcoming_release? - CreateEvidenceWorker.perform_at(release.released_at, release.id, pipeline&.id) - else - CreateEvidenceWorker.perform_async(release.id, pipeline&.id) - end + ::Releases::CreateEvidenceWorker.perform_async(release.id, pipeline&.id) end end end diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb index 27668e9430e..debd1e8cd17 100644 --- a/app/services/users/approve_service.rb +++ b/app/services/users/approve_service.rb @@ -7,8 +7,9 @@ module Users end def execute(user) - return error(_('You are not allowed to approve a user')) unless allowed? - return error(_('The user you are trying to approve is not pending an approval')) unless approval_required?(user) + return error(_('You are not allowed to approve a user'), :forbidden) unless allowed? + return error(_('The user you are trying to approve is not pending an approval'), :conflict) if user.active? + return error(_('The user you are trying to approve is not pending an approval'), :conflict) unless approval_required?(user) if user.activate # Resends confirmation email if the user isn't confirmed yet. @@ -18,9 +19,9 @@ module Users DeviseMailer.user_admin_approval(user).deliver_later after_approve_hook(user) - success + success(message: 'Success', http_status: :created) else - error(user.errors.full_messages.uniq.join('. ')) + error(user.errors.full_messages.uniq.join('. '), :unprocessable_entity) end end diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb index 2306313fc82..d80725cb051 100644 --- a/app/uploaders/terraform/state_uploader.rb +++ b/app/uploaders/terraform/state_uploader.rb @@ -6,17 +6,33 @@ module Terraform storage_options Gitlab.config.terraform_state - delegate :project_id, to: :model + delegate :terraform_state, :project_id, to: :model # Use Lockbox to encrypt/decrypt the stored file (registers CarrierWave callbacks) encrypt(key: :key) def filename - "#{model.uuid}.tfstate" + # This check is required to maintain backwards compatibility with + # states that were created prior to versioning being supported. + # This can be removed in 14.0 when support for these states is dropped. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/258960 + if terraform_state.versioning_enabled? + "#{model.version}.tfstate" + else + "#{model.uuid}.tfstate" + end end def store_dir - project_id.to_s + # This check is required to maintain backwards compatibility with + # states that were created prior to versioning being supported. + # This can be removed in 14.0 when support for these states is dropped. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/258960 + if terraform_state.versioning_enabled? + Gitlab::HashedPath.new(model.uuid, root_hash: project_id) + else + project_id.to_s + end end def key diff --git a/app/uploaders/terraform/versioned_state_uploader.rb b/app/uploaders/terraform/versioned_state_uploader.rb deleted file mode 100644 index e50ab6c7dc6..00000000000 --- a/app/uploaders/terraform/versioned_state_uploader.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Terraform - class VersionedStateUploader < StateUploader - delegate :terraform_state, to: :model - - def filename - if terraform_state.versioning_enabled? - "#{model.version}.tfstate" - else - "#{model.uuid}.tfstate" - end - end - - def store_dir - if terraform_state.versioning_enabled? - Gitlab::HashedPath.new(model.uuid, root_hash: project_id) - else - project_id.to_s - end - end - end -end diff --git a/app/validators/json_schemas/codeclimate.json b/app/validators/json_schemas/codeclimate.json new file mode 100644 index 00000000000..56056c62c4e --- /dev/null +++ b/app/validators/json_schemas/codeclimate.json @@ -0,0 +1,34 @@ +{ + "description": "Codequality used by codeclimate parser", + "type": "object", + "required": ["description", "fingerprint", "severity", "location"], + "properties": { + "description": { "type": "string" }, + "fingerprint": { "type": "string" }, + "severity": { "type": "string" }, + "location": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "lines": { + "type": "object", + "properties": { + "begin": { "type": "integer" } + } + }, + "positions": { + "type": "object", + "properties": { + "begin": { + "type": "object", + "properties": { + "line": { "type": "integer" } + } + } + } + } + } + } + }, + "additionalProperties": true +} diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml index c1565cf42e1..b06070d15d4 100644 --- a/app/views/admin/application_settings/_ip_limits.html.haml +++ b/app/views/admin/application_settings/_ip_limits.html.haml @@ -2,44 +2,52 @@ = form_errors(@application_setting) %fieldset + %h5 + = _('Unauthenticated request rate limit') .form-group .form-check = f.check_box :throttle_unauthenticated_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_unauthenticated_checkbox' } - = f.label :throttle_unauthenticated_enabled, class: 'form-check-label' do + = f.label :throttle_unauthenticated_enabled, class: 'form-check-label label-bold' do Enable unauthenticated request rate limit %span.form-text.text-muted Helps reduce request volume (e.g. from crawlers or abusive bots) .form-group - = f.label :throttle_unauthenticated_requests_per_period, 'Max requests per period per IP', class: 'label-bold' + = f.label :throttle_unauthenticated_requests_per_period, 'Max unauthenticated requests per period per IP', class: 'label-bold' = f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control' .form-group - = f.label :throttle_unauthenticated_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold' + = f.label :throttle_unauthenticated_period_in_seconds, 'Unauthenticated rate limit period in seconds', class: 'label-bold' = f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control' + %hr + %h5 + = _('Authenticated API request rate limit') .form-group .form-check = f.check_box :throttle_authenticated_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_api_checkbox' } - = f.label :throttle_authenticated_api_enabled, class: 'form-check-label' do + = f.label :throttle_authenticated_api_enabled, class: 'form-check-label label-bold' do Enable authenticated API request rate limit %span.form-text.text-muted Helps reduce request volume (e.g. from crawlers or abusive bots) .form-group - = f.label :throttle_authenticated_api_requests_per_period, 'Max requests per period per user', class: 'label-bold' + = f.label :throttle_authenticated_api_requests_per_period, 'Max authenticated API requests per period per user', class: 'label-bold' = f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control' .form-group - = f.label :throttle_authenticated_api_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold' + = f.label :throttle_authenticated_api_period_in_seconds, 'Authenticated API rate limit period in seconds', class: 'label-bold' = f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control' + %hr + %h5 + = _('Authenticated web request rate limit') .form-group .form-check = f.check_box :throttle_authenticated_web_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_web_checkbox' } - = f.label :throttle_authenticated_web_enabled, class: 'form-check-label' do + = f.label :throttle_authenticated_web_enabled, class: 'form-check-label label-bold' do Enable authenticated web request rate limit %span.form-text.text-muted Helps reduce request volume (e.g. from crawlers or abusive bots) .form-group - = f.label :throttle_authenticated_web_requests_per_period, 'Max requests per period per user', class: 'label-bold' + = f.label :throttle_authenticated_web_requests_per_period, 'Max authenticated web requests per period per user', class: 'label-bold' = f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control' .form-group - = f.label :throttle_authenticated_web_period_in_seconds, 'Rate limit period in seconds', class: 'label-bold' + = f.label :throttle_authenticated_web_period_in_seconds, 'Authenticated web rate limit period in seconds', class: 'label-bold' = f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control' = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 9f1b7195ab7..4959e596148 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -6,7 +6,7 @@ .settings-header %h4 = _('Metrics - Prometheus') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable and configure Prometheus metrics.') @@ -17,7 +17,7 @@ .settings-header %h4 = _('Metrics - Grafana') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable and configure Grafana.') @@ -28,7 +28,7 @@ .settings-header %h4 = _('Profiling - Performance bar') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable access to the Performance Bar for a given group.') @@ -42,7 +42,7 @@ .settings-header#usage-statistics %h4 = _('Usage statistics') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } + %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' } = expanded_by_default? ? _('Collapse') : _('Expand') %p = _('Enable or disable version check and usage ping.') diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 220a211cca6..8cc04392752 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -9,7 +9,7 @@ dismissible: true.to_s } } = notice[:message].html_safe -- if @license.present? && show_license_breakdown? +- if @license.present? .license-panel.gl-mt-5 = render_if_exists 'admin/licenses/summary' = render_if_exists 'admin/licenses/breakdown' diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index 17bb054b869..5bc5404fada 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -18,28 +18,28 @@ .gl-mt-3 = form.check_box :repository_update_events, class: 'float-left' - .prepend-left-20 + .gl-ml-6 = form.label :repository_update_events, class: 'list-label' do %strong Repository update events %p.light This URL will be triggered when repository is updated %li = form.check_box :push_events, class: 'float-left' - .prepend-left-20 + .gl-ml-6 = form.label :push_events, class: 'list-label' do %strong Push events %p.light This URL will be triggered for each branch updated to the repository %li = form.check_box :tag_push_events, class: 'float-left' - .prepend-left-20 + .gl-ml-6 = form.label :tag_push_events, class: 'list-label' do %strong Tag push events %p.light This URL will be triggered when a new tag is pushed to the repository %li = form.check_box :merge_requests_events, class: 'float-left' - .prepend-left-20 + .gl-ml-6 = form.label :merge_requests_events, class: 'list-label' do %strong Merge request events %p.light diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml index 3b3de042511..c6627ae0f27 100644 --- a/app/views/admin/runners/_sort_dropdown.html.haml +++ b/app/views/admin/runners/_sort_dropdown.html.haml @@ -3,7 +3,7 @@ .dropdown.inline.gl-ml-3 %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = sorted_by - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %li = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sorted_by) diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 2c4befb1be2..06925964dc5 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -9,7 +9,7 @@ %span.runner-state.runner-state-specific Specific -- page_title _("Runners") +- page_title @runner.short_sha - add_to_breadcrumbs _("Runners"), admin_runners_path - breadcrumb_title "##{@runner.id}" @@ -39,17 +39,18 @@ %thead %tr %th Assigned projects - %th - @runner.runner_projects.each do |runner_project| - project = runner_project.project - if project - %tr.alert-info + %tr %td - %strong - = project.full_name - %td - .float-right - = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'gl-button btn btn-danger btn-sm' + .gl-alert.gl-alert-danger + = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + %strong + = project.full_name + .gl-alert-actions + = link_to s_('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-info btn-md gl-button' %table.table.unassigned-projects %thead diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index 679c4805280..b9f5b92b957 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -40,7 +40,8 @@ %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) } = s_('AdminUsers|Block') - else - = link_to _('Unblock'), unblock_admin_user_path(user), method: :put + %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: unblock_user_modal_data(user) } + = s_('AdminUsers|Unblock') - else %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) } = s_('AdminUsers|Block') diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 9c6f151a6b1..530c6878200 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -186,7 +186,8 @@ %li Log in %li Access Git repositories %br - = link_to 'Unblock user', unblock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?') } + %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: unblock_user_modal_data(@user) } + = s_('AdminUsers|Unblock user') - elsif !@user.internal? = render 'admin/users/block_user', user: @user diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 6ac852af2db..cb464eeafbb 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -27,6 +27,7 @@ provider_type: @cluster.provider_type, pre_installed_knative: @cluster.knative_pre_installed? ? 'true': 'false', help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'), + helm_help_path: help_page_path('user/clusters/applications.md', anchor: 'helm'), ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'), ingress_dns_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint'), ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'), diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 9a9fbfc1ee8..221d79a30ce 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -71,7 +71,7 @@ = sort_options_hash[@sort] - else = sort_title_recently_created - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-sort.dropdown-menu-right %li = link_to todos_filter_path(sort: sort_value_label_priority) do diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index 6fc156cf4ed..2ead8fc2cfd 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -10,7 +10,7 @@ = visibility_level_label(params[:visibility_level].to_i) - else = _('Any') - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right %li = link_to filter_projects_path(visibility_level: nil) do diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index ee08829d990..67f278a06f3 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -6,10 +6,10 @@ .row.mb-3 .home-panel-title-row.col-md-12.col-lg-6.d-flex .avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none - = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64) + = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo') .d-flex.flex-column.flex-wrap.align-items-baseline .d-inline-flex.align-items-baseline - %h1.home-panel-title.gl-mt-3.gl-mb-2 + %h1.home-panel-title.gl-mt-3.gl-mb-2{ itemprop: 'name' } = @group.name %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } = visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) @@ -34,7 +34,7 @@ - if @group.description.present? .group-home-desc.mt-1 .home-panel-description - .home-panel-description-markdown.read-more-container + .home-panel-description-markdown.read-more-container{ itemprop: 'description' } = markdown_field(@group, :description) %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } = _("Read more") diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml index cb15fe339e1..d9ab828a83b 100644 --- a/app/views/groups/_subgroups_and_projects.html.haml +++ b/app/views/groups/_subgroups_and_projects.html.haml @@ -3,6 +3,6 @@ = render "shared/groups/empty_state" %section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } - .js-groups-list-holder + .js-groups-list-holder{ data: { show_schema_markup: 'true'} } .loading-container.text-center.prepend-top-20 .spinner.spinner-md diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 9d5ec5008dc..109e7c3831e 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,5 +1,6 @@ -- breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout +- page_itemtype 'https://schema.org/Organization' +- @skip_current_level_breadcrumb = true - if show_thanks_for_purchase_banner? = render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index f6fc49393d8..c552454caa7 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -17,6 +17,7 @@ = render_account_recovery_regular_check = render_if_exists "layouts/header/ee_subscribable_banner" = render_if_exists "shared/namespace_storage_limit_alert" + = render_if_exists "shared/new_user_signups_cap_reached_alert" = yield :customize_homepage_banner - unless @hide_breadcrumbs = render "layouts/nav/breadcrumbs" diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml index f0cdb3d1a51..43f1011a85b 100644 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/_breadcrumbs.html.haml @@ -1,6 +1,7 @@ - container = @no_breadcrumb_container ? 'container-fluid' : container_class - hide_top_links = @hide_top_links || false -- push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) +- unless @skip_current_level_breadcrumb + - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link) %nav.breadcrumbs{ role: "navigation", class: [container, @content_class] } .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) } @@ -16,8 +17,10 @@ - @breadcrumbs_extra_links.each do |extra| = breadcrumb_list_item link_to(extra[:text], extra[:link]) = render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :after - %li - %h2.breadcrumbs-sub-title= link_to @breadcrumb_title, breadcrumb_title_link + - unless @skip_current_level_breadcrumb + %li + %h2.breadcrumbs-sub-title + = link_to @breadcrumb_title, breadcrumb_title_link %script{ type:'application/ld+json' } :plain #{schema_breadcrumb_json} diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 9c5cfe35cda..e1345a94fb1 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -3,10 +3,14 @@ %div - if @user.errors.any? - .gl-alert.gl-alert-danger - %ul - - @user.errors.full_messages.each do |msg| - %li= msg + .gl-alert.gl-alert-danger.gl-my-5 + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', css_class: 'gl-icon') + = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert-body + %ul + - @user.errors.full_messages.each do |msg| + %li= msg = hidden_field_tag :notification_type, 'global' .row.gl-mt-3 diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 569255ec2e5..ebb0dd8b39f 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -19,7 +19,7 @@ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project .home-panel-metadata.d-flex.flex-wrap.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal - if can?(current_user, :read_project, @project) - %span.text-secondary{ itemprop: 'identifier' } + %span.text-secondary{ itemprop: 'identifier', data: { qa_selector: 'project_id_content' } } = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id } - if current_user %span.access-request-links.gl-ml-3 @@ -63,7 +63,7 @@ .home-panel-home-desc.mt-1 - if @project.description.present? .home-panel-description.text-break - .home-panel-description-markdown.read-more-container{ itemprop: 'abstract' } + .home-panel-description-markdown.read-more-container{ itemprop: 'description' } = markdown_field(@project, :description) %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" } = _("Read more") diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml index aedfb64d3e4..db4b04eaeb8 100644 --- a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml +++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml @@ -1,4 +1,4 @@ -= icon('spinner spin fw') += loading_icon(css_class: "gl-vertical-align-text-bottom mr-1") = _('Metrics Dashboard YAML definition') + '…' = link_to _('Learn more'), help_page_path('operations/metrics/dashboards/yaml.md') diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index cf58cff7445..938dfc69500 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -2,7 +2,7 @@ - dropdown_class = local_assigns.fetch(:dropdown_class, '') .git-clone-holder.js-git-clone-holder - %a#clone-dropdown.gl-button.btn.btn-primary.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } + %a#clone-dropdown.gl-button.btn.btn-info.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } %span.gl-mr-2.js-clone-dropdown-label = _('Clone') = sprite_icon("chevron-down", css_class: "icon") @@ -12,7 +12,7 @@ %label.label-bold = _('Clone with SSH') .input-group - = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: 'Project clone URL' } + = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: _('Repository clone URL') } .input-group-append = clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' @@ -21,7 +21,7 @@ %label.label-bold = _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase } .input-group - = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: 'Project clone URL' } + = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: _('Repository clone URL') } .input-group-append = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml index 0e032f2575e..f1f8658fa3b 100644 --- a/app/views/projects/ci/pipeline_editor/show.html.haml +++ b/app/views/projects/ci/pipeline_editor/show.html.haml @@ -3,4 +3,6 @@ #js-pipeline-editor{ data: { "ci-config-path": @project.ci_config_path_or_default, "project-path" => @project.full_path, "default-branch" => @project.default_branch, + "commit-id" => @project.commit ? @project.commit.id : '', + "new-merge-request-path" => namespace_project_new_merge_request_path, } } diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml index 4964b1b8ee7..357ad467539 100644 --- a/app/views/projects/commit/_verified_signature_badge.html.haml +++ b/app/views/projects/commit/_verified_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = capture do - = _('This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.').html_safe + = html_escape(_('This commit was signed with a %{strong_open}verified%{strong_close} signature and the committer email is verified to belong to the same user.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true } diff --git a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml index 680cc32c7e6..6204a6977c0 100644 --- a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml +++ b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = capture do - = _('This commit was signed with an <strong>unverified</strong> signature.').html_safe + = html_escape(_('This commit was signed with an %{strong_open}unverified%{strong_close} signature.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true } diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index c6d39f5bba0..2e0bb3b8529 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,7 +1,6 @@ - @content_class = "limit-container-width" unless fluid_layout - default_branch_name = @project.default_branch || "master" -- breadcrumb_title _("Details") -- page_title _("Details") +- @skip_current_level_breadcrumb = true = render partial: 'flash_messages', locals: { project: @project } diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index 67dc07fb785..89c2c826067 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -15,7 +15,7 @@ = sort_options_hash[@sort] - else = sort_title_recently_created - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right %li - excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id] diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index d9ad171a6cc..3ce85fb46d5 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,67 +1,66 @@ -# DANGER: Any changes to this file need to be reflected in issuables_list/components/issuable.vue! %li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue_container', qa_issue_title: issue.title } } - .issue-box + .issuable-info-container - if @can_bulk_update .issue-check.hidden = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected-issuable" - .issuable-info-container - .issuable-main-info - .issue-title.title - %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" } - - if issue.confidential? - %span.has-tooltip{ title: _('Confidential') } - = confidential_icon(issue) - = link_to issue.title, issue_path(issue) - = render_if_exists 'projects/issues/subepic_flag', issue: issue - - if issue.tasks? - %span.task-status.d-none.d-sm-inline-block - - = issue.task_status + .issuable-main-info + .issue-title.title + %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" } + - if issue.confidential? + %span.has-tooltip{ title: _('Confidential') } + = confidential_icon(issue) + = link_to issue.title, issue_path(issue) + = render_if_exists 'projects/issues/subepic_flag', issue: issue + - if issue.tasks? + %span.task-status.d-none.d-sm-inline-block + + = issue.task_status - .issuable-info - %span.issuable-reference - #{issuable_reference(issue)} - %span.issuable-authored.d-none.d-sm-inline-block - · - opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} - by #{link_to_member(@project, issue.author, avatar: false)} - = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: issue.author} - - if issue.milestone - %span.issuable-milestone.d-none.d-sm-inline-block - - = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do - = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom') - = issue.milestone.title - - if issue.due_date - %span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') } - - = sprite_icon('calendar') - = issue.due_date.to_s(:medium) + .issuable-info + %span.issuable-reference + #{issuable_reference(issue)} + %span.issuable-authored.d-none.d-sm-inline-block + · + opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} + by #{link_to_member(@project, issue.author, avatar: false)} + = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: issue.author} + - if issue.milestone + %span.issuable-milestone.d-none.d-sm-inline-block + + = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do + = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom') + = issue.milestone.title + - if issue.due_date + %span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') } + + = sprite_icon('calendar') + = issue.due_date.to_s(:medium) - = render_if_exists "projects/issues/issue_weight", issue: issue - = render_if_exists "projects/issues/health_status", issue: issue + = render_if_exists "projects/issues/issue_weight", issue: issue + = render_if_exists "projects/issues/health_status", issue: issue - - if issue.labels.any? - - - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label| - = link_to_label(label, small: true) + - if issue.labels.any? + + - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label| + = link_to_label(label, small: true) - = render "projects/issues/issue_estimate", issue: issue + = render "projects/issues/issue_estimate", issue: issue - .issuable-meta - %ul.controls - - if issue.closed? && issue.moved? - %li.issuable-status - = _('CLOSED (MOVED)') - - elsif issue.closed? - %li.issuable-status - = _('CLOSED') - - if issue.assignees.any? - %li.gl-display-flex - = render 'shared/issuable/assignees', project: @project, issuable: issue + .issuable-meta + %ul.controls + - if issue.closed? && issue.moved? + %li.issuable-status + = _('CLOSED (MOVED)') + - elsif issue.closed? + %li.issuable-status + = _('CLOSED') + - if issue.assignees.any? + %li.gl-display-flex + = render 'shared/issuable/assignees', project: @project, issuable: issue - = render 'shared/issuable_meta_data', issuable: issue + = render 'shared/issuable_meta_data', issuable: issue - .float-right.issuable-updated-at.d-none.d-sm-inline-block - %span - = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago') } + .float-right.issuable-updated-at.d-none.d-sm-inline-block + %span + = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago') } diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml index e6205f24ae6..cb1cb41eb71 100644 --- a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml +++ b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml @@ -1,16 +1,11 @@ .content-block.oneline-block.files-changed{ "v-if" => "!isLoading && !hasError" } .inline-parallel-buttons{ "v-if" => "showDiffViewTypeSwitcher" } .btn-group - %button.btn{ ":class" => "{'active': !isParallel}", "@click" => "handleViewTypeChange('inline')" } - Inline - %button.btn{ ":class" => "{'active': isParallel}", "@click" => "handleViewTypeChange('parallel')" } - Side-by-side + %button.btn.gl-button{ ":class" => "{'active': !isParallel}", "@click" => "handleViewTypeChange('inline')" } + = _('Inline') + %button.btn.gl-button{ ":class" => "{'active': isParallel}", "@click" => "handleViewTypeChange('parallel')" } + = _('Side-by-side') .js-toggle-container .commit-stat-summary - Showing - %strong.cred {{conflictsCountText}} - between - %strong.ref-name {{conflictsData.sourceBranch}} - and - %strong.ref-name {{conflictsData.targetBranch}} + = _('Showing %{conflict_start}%{conflicts_text}%{strong_end} between %{ref_start}%{source_branch}%{strong_end} and %{ref_start}%{target_branch}%{strong_end}').html_safe % { conflict_start: '<strong class="cred">'.html_safe, ref_start: '<strong class="ref-name">'.html_safe, strong_end: '</strong>'.html_safe, conflicts_text: '{{conflictsCountText}}', source_branch: '{{conflictsData.sourceBranch}}', target_branch: '{{conflictsData.targetBranch}}' } diff --git a/app/views/projects/merge_requests/conflicts/_file_actions.html.haml b/app/views/projects/merge_requests/conflicts/_file_actions.html.haml index 0839880713f..220ddf1bad3 100644 --- a/app/views/projects/merge_requests/conflicts/_file_actions.html.haml +++ b/app/views/projects/merge_requests/conflicts/_file_actions.html.haml @@ -1,12 +1,12 @@ -.file-actions - .btn-group{ "v-if" => "file.type === 'text'" } - %button.btn{ ":class" => "{ 'active': file.resolveMode == 'interactive' }", +.file-actions.d-flex.align-items-center.gl-ml-auto.gl-align-self-start + .btn-group.gl-mr-3{ "v-if" => "file.type === 'text'" } + %button.btn.gl-button{ ":class" => "{ 'active': file.resolveMode == 'interactive' }", '@click' => "onClickResolveModeButton(file, 'interactive')", type: 'button' } - Interactive mode - %button.btn{ ':class' => "{ 'active': file.resolveMode == 'edit' }", + = _('Interactive mode') + %button.btn.gl-button{ ':class' => "{ 'active': file.resolveMode == 'edit' }", '@click' => "onClickResolveModeButton(file, 'edit')", type: 'button' } - Edit inline - %a.btn.view-file{ ":href" => "file.blobPath" } - View file @{{conflictsData.shortCommitSha}} + = _('Edit inline') + %a.btn.gl-button.view-file{ ":href" => "file.blobPath" } + = _('View file @%{commit_sha}') % { commit_sha: '{{conflictsData.shortCommitSha}}' } diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml index 94c262d300e..15655e2b162 100644 --- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml +++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml @@ -18,7 +18,7 @@ .offset-md-4.col-md-8 .row .col-6 - %button.btn.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" } + %button.btn.gl-button.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" } %span {{commitButtonText}} .col-6.text-right = link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "gl-button btn btn-cancel" diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index decdbce3fa7..827df540629 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -20,9 +20,10 @@ .files-wrapper{ "v-if" => "!isLoading && !hasError" } .files .diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" } - .js-file-title.file-title - %i.fa.fa-fw{ ":class" => "file.iconClass" } - %strong {{file.filePath}} + .js-file-title.file-title.file-title-flex-parent.cursor-default + .file-header-content + %file-icon{ ':file-name': 'file.filePath', ':size': '18', 'css-classes': 'gl-mr-2' } + %strong.file-title-name {{file.filePath}} = render partial: 'projects/merge_requests/conflicts/file_actions' .diff-content.diff-wrap-lines .file-content{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" } diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 6b506c38795..53ea9678c07 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -58,6 +58,8 @@ = render "projects/merge_requests/description" = render "projects/merge_requests/widget" = render "projects/merge_requests/awards_block" + - if mr_action === "show" + - add_page_startup_api_call discussions_path(@merge_request) #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json, noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'), noteable_type: 'MergeRequest', diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 4366676bd45..30ba22ba53c 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -8,7 +8,7 @@ = text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control input-mx-250 search-sha' = button_tag class: 'btn btn-success' do = sprite_icon('search') - .inline.prepend-left-20 + .inline.gl-ml-5 .form-check.light = check_box_tag :filter_ref, 1, @options[:filter_ref], class: 'form-check-input' = label_tag :filter_ref, class: 'form-check-label' do diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index f2972a9617b..a407aa9ac13 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -8,10 +8,9 @@ .project-edit-errors = render 'projects/errors' - - if experiment_enabled?(:new_create_project_ui) - .js-experiment-new-project-creation{ data: { is_ci_cd_available: ci_cd_projects_available?, has_errors: @project.errors.any? } } + .js-experiment-new-project-creation{ data: { is_ci_cd_available: (ci_cd_projects_available? if Gitlab.ee?), has_errors: @project.errors.any? } } - .row{ 'v-cloak': experiment_enabled?(:new_create_project_ui) } + .row{ 'v-cloak': true } .col-lg-3.profile-settings-sidebar %h4.gl-mt-0 = _('New project') diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml index 65c4232b240..d7853c1b466 100644 --- a/app/views/projects/no_repo.html.haml +++ b/app/views/projects/no_repo.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title _("Details") -- page_title _("Details") +- page_title _('No repository') +- @skip_current_level_breadcrumb = true %h2.gl-display-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml index c6fae2cc7a1..a4d4a1bb2dd 100644 --- a/app/views/projects/registry/settings/_index.haml +++ b/app/views/projects/registry/settings/_index.haml @@ -5,4 +5,5 @@ older_than_options: older_than_options.to_json, is_admin: current_user&.admin.to_s, admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), - enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s} } + enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s, + tags_regex_help_page_path: help_page_path('user/packages/container_registry/index', anchor: 'regex-pattern-examples') } } diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index 9d81fda68cb..549ca36cb6a 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -1,4 +1,4 @@ -- pretty_name = html_escape(@project&.full_name) || html_escape_once(_('<project name>')).html_safe +- pretty_name = @project&.full_name ? html_escape(@project&.full_name) : '<' + _('project name') + '>' - run_actions_text = html_escape(s_("ProjectService|Perform common operations on GitLab project: %{project_name}")) % { project_name: pretty_name } %p= s_("ProjectService|To set up this service:") diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 86486d95eb7..67c43bd2f33 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -1,4 +1,4 @@ -- pretty_name = @project&.full_name || _('<project name>') +- pretty_name = @project&.full_name ? html_escape(@project&.full_name) : '<' + _('project name') + '>' - run_actions_text = html_escape_once(s_("ProjectService|Perform common operations on GitLab project: %{project_name}") % { project_name: pretty_name }) .info-well diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index f7c51e9ada9..5b9f868a71a 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -1,5 +1,5 @@ -- breadcrumb_title _("Details") - @content_class = "limit-container-width" unless fluid_layout +- @skip_current_level_breadcrumb = true = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index e0def8cf155..2fe5c5888f5 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -16,7 +16,7 @@ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown'} } %span.light = tags_sort_options_hash[@sort] - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header = s_('TagsPage|Sort by') diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index fe42394d919..73b2a92dcc0 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -24,7 +24,7 @@ = hidden_field_tag :ref, default_ref = button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide js-branch-select monospace', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do .text-left.dropdown-toggle-text= default_ref - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') = render 'shared/ref_dropdown', dropdown_class: 'wide' .form-text.text-muted = s_('TagsPage|Existing branch name, tag, or commit SHA') diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml index c166642bae2..2542860c742 100644 --- a/app/views/projects/wikis/git_access.html.haml +++ b/app/views/projects/wikis/git_access.html.haml @@ -11,7 +11,7 @@ %strong= @wiki.full_path .pt-3.pt-lg-0.w-100 - = render "shared/clone_panel", project: @wiki + = render "shared/clone_panel", container: @wiki .wiki-git-access %h3= s_("WikiClone|Install Gollum") diff --git a/app/views/registrations/experience_levels/show.html.haml b/app/views/registrations/experience_levels/show.html.haml index 24b87790e18..f878245a48c 100644 --- a/app/views/registrations/experience_levels/show.html.haml +++ b/app/views/registrations/experience_levels/show.html.haml @@ -15,8 +15,8 @@ = image_tag 'novice.svg', width: '78', height: '78', alt: '' %div %p.gl-font-lg.gl-font-weight-bold.gl-mb-2= _('Novice') - %p= _('I’m not very familiar with the basics of project management and DevOps.') - = link_to _('Show me everything'), users_sign_up_experience_level_path(experience_level: :novice, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link' + %p= _('I’m not familiar with the basics of DevOps.') + = link_to _('Show me the basics'), users_sign_up_experience_level_path(experience_level: :novice, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link' .card .card-body.gl-display-flex.gl-py-8.gl-pr-5.gl-pl-7 @@ -24,5 +24,5 @@ = image_tag 'experienced.svg', width: '78', height: '78', alt: '' %div %p.gl-font-lg.gl-font-weight-bold.gl-mb-2= _('Experienced') - %p= _('I’m familiar with the basics of project management and DevOps.') - = link_to _('Show me more advanced stuff'), users_sign_up_experience_level_path(experience_level: :experienced, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link' + %p= _('I’m familiar with the basics of DevOps.') + = link_to _('Show me advanced features'), users_sign_up_experience_level_path(experience_level: :experienced, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link' diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml index 964a2a2772a..05895d83c2b 100644 --- a/app/views/search/_filter.html.haml +++ b/app/views/search/_filter.html.haml @@ -14,7 +14,7 @@ = @project&.full_name || _("Any") - if @project.present? = link_to sprite_icon("clear"), url_for(safe_params.except(:project_id)), class: 'search-clear js-search-clear has-tooltip', title: _('Clear') - = icon("chevron-down") + = sprite_icon("chevron-down", css_class: 'dropdown-menu-toggle-icon gl-top-3') .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right = dropdown_title(_("Filter results by project")) = dropdown_filter(_("Search projects")) diff --git a/app/views/search/_sort_dropdown.html.haml b/app/views/search/_sort_dropdown.html.haml index 085e2f348f7..c4c5a490ff5 100644 --- a/app/views/search/_sort_dropdown.html.haml +++ b/app/views/search/_sort_dropdown.html.haml @@ -8,7 +8,7 @@ .btn-group{ role: 'group' } %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } = sort_title - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort %li = render_if_exists('search/sort_by_relevancy', sort_title: sort_title) diff --git a/app/views/shared/_alert_info.html.haml b/app/views/shared/_alert_info.html.haml new file mode 100644 index 00000000000..e47c100909a --- /dev/null +++ b/app/views/shared/_alert_info.html.haml @@ -0,0 +1,6 @@ +.gl-alert.gl-alert-info + = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', css_class: 'gl-icon') + .gl-alert-body + = body diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index 9ec8d3c18cd..1ed37c7a5c4 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -1,11 +1,9 @@ -- project = project || @project - .git-clone-holder.js-git-clone-holder.input-group .input-group-prepend - if allowed_protocols_present? .input-group-text.clone-dropdown-btn.btn %span.js-clone-dropdown-label - = enabled_project_button(project, enabled_protocol) + = enabled_protocol_button(container, enabled_protocol) - else %a#clone-dropdown.input-group-text.btn.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } } %span.js-clone-dropdown-label @@ -13,12 +11,12 @@ = icon('caret-down') %ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown %li - = ssh_clone_button(project) + = ssh_clone_button(container) %li - = http_clone_button(project) - = render_if_exists 'shared/kerberos_clone_button', project: project + = http_clone_button(container) + = render_if_exists 'shared/kerberos_clone_button', container: container - = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Project clone URL') } + = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') } .input-group-append - = clipboard_button(target: '#project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") + = clipboard_button(target: '#clone_url', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml index 06da990e071..29c01343358 100644 --- a/app/views/shared/_milestones_sort_dropdown.html.haml +++ b/app/views/shared/_milestones_sort_dropdown.html.haml @@ -5,7 +5,7 @@ = milestone_sort_options_hash[@sort] - else = sort_title_due_date_soon - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort %li = link_to page_filter_path(sort: sort_value_due_date_soon) do diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml index 76ae63ca5e8..9c1e5a49b44 100644 --- a/app/views/shared/_no_password.html.haml +++ b/app/views/shared/_no_password.html.haml @@ -5,7 +5,7 @@ = sprite_icon('close', size: 16, css_class: 'gl-icon') .gl-alert-body - translation_params = { protocol: gitlab_config.protocol.upcase, set_password_link: link_to_set_password } - - set_password_message = _("You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account") % translation_params + - set_password_message = _("You won't be able to pull or push repositories via %{protocol} until you %{set_password_link} on your account") % translation_params = set_password_message.html_safe .gl-alert-actions = link_to _('Remind later'), '#', class: 'hide-no-password-message btn gl-alert-action btn-info btn-md gl-button' diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index a083a772233..0a7fa2a3c1e 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -4,7 +4,7 @@ %button{ class: 'gl-alert-dismiss hide-no-ssh-message', type: 'button', 'aria-label': _('Dismiss') } = sprite_icon('close', css_class: 'gl-icon s16') .gl-alert-body - = s_("MissingSSHKeyWarningLink|You won't be able to pull or push project code via SSH until you add an SSH key to your profile").html_safe + = s_("MissingSSHKeyWarningLink|You won't be able to pull or push repositories via SSH until you add an SSH key to your profile") .gl-alert-actions = link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "btn gl-alert-action btn-warning btn-md new-gl-button" = link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: {hide_no_ssh_key: true}), method: :put, role: 'button', class: 'btn gl-alert-action btn-md btn-warning gl-button btn-warning-secondary' diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml index 9d2d3ce20c7..75c34102935 100644 --- a/app/views/shared/groups/_dropdown.html.haml +++ b/app/views/shared/groups/_dropdown.html.haml @@ -1,24 +1,17 @@ - options_hash = local_assigns.fetch(:options_hash, groups_sort_options_hash) - show_archive_options = local_assigns.fetch(:show_archive_options, false) -- if @sort.present? - - default_sort_by = @sort -- else - - if params[:sort] - - default_sort_by = params[:sort] - - else - - default_sort_by = sort_value_recently_created .dropdown.inline.js-group-filter-dropdown-wrap.gl-mr-3 %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.dropdown-label - = options_hash[default_sort_by] - = icon('chevron-down') + = options_hash[project_list_sort_by] + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header = _("Sort by") - options_hash.each do |value, title| %li.js-filter-sort-order - = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do + = link_to filter_groups_path(sort: value), class: ("is-active" if project_list_sort_by == value) do = title - if show_archive_options %li.divider diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml index 60dc893d9f9..b437ee1ec5f 100644 --- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml @@ -1,4 +1,4 @@ -= form.label :assignee_id, "Assignee", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" += form.label :assignee_id, issuable.allows_multiple_assignees? ? _('Assignees') : _('Assignee'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" .col-sm-10{ class: ("col-md-8" if has_due_date) } .issuable-form-select-holder.selectbox - issuable.assignees.each do |assignee| diff --git a/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml b/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml index a8b033bba36..5ff3a6781ad 100644 --- a/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml +++ b/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml @@ -1,4 +1,4 @@ -= form.label :reviewer_id, "Reviewer", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" += form.label :reviewer_id, issuable.allows_multiple_reviewers? ? _('Reviewers') : _('Reviewer'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}" .col-sm-10{ class: ("col-md-8" if has_due_date) } .issuable-form-select-holder.selectbox - issuable.reviewers.each do |reviewer| diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml index 07e96eea062..cfc00bd41ca 100644 --- a/app/views/shared/labels/_sort_dropdown.html.haml +++ b/app/views/shared/labels/_sort_dropdown.html.haml @@ -2,7 +2,7 @@ .dropdown.inline %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } = sort_title - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort %li - label_sort_options_hash.each do |value, title| diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 42e12d92a7d..d98ba074687 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -27,7 +27,7 @@ data: { toggle: "dropdown", field_name: "group_link[group_access]" } } %span.dropdown-toggle-text = group_link.human_access - = icon("chevron-down") + = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3") .dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable = dropdown_title(_("Change permissions")) .dropdown-content diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index e294936f82c..79bbb74d601 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -79,7 +79,7 @@ data: { toggle: "dropdown", field_name: "#{f.object_name}[access_level]", qa_selector: "access_level_dropdown" } } %span.dropdown-toggle-text = member.human_access - = icon("chevron-down") + = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3") .dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable = dropdown_title(_("Change permissions")) .dropdown-content diff --git a/app/views/shared/projects/_sort_dropdown.html.haml b/app/views/shared/projects/_sort_dropdown.html.haml index f5f940db189..3e810dc6f08 100644 --- a/app/views/shared/projects/_sort_dropdown.html.haml +++ b/app/views/shared/projects/_sort_dropdown.html.haml @@ -5,7 +5,7 @@ .btn-group.w-100.dropdown.js-project-filter-dropdown-wrap{ role: "group" } %button#sort-projects-dropdown.btn.btn-default.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } } = toggle_text - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3') %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable %li.dropdown-header = _("Sort by") diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index c5234f14090..4dd434ad1db 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -10,7 +10,7 @@ = s_('Webhooks|Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header.') .form-group = form.label :url, s_('Webhooks|Trigger'), class: 'label-bold' - %ul.list-unstyled.prepend-left-20 + %ul.list-unstyled.gl-ml-6 %li = form.check_box :push_events, class: 'form-check-input' = form.label :push_events, class: 'list-label form-check-label ml-1' do diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml index dde1b3afa2d..b6504c7a17e 100644 --- a/app/views/shared/wikis/_form.html.haml +++ b/app/views/shared/wikis/_form.html.haml @@ -36,7 +36,7 @@ .col-sm-10 .select-wrapper = f.select :format, options_for_select(Wiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control select-control' - = icon('chevron-down') + = sprite_icon('chevron-down', css_class: 'gl-absolute gl-top-3 gl-right-3 gl-text-gray-200') .form-group.row .col-sm-2.col-form-label= f.label :content, class: 'control-label-full-width' diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index c0ed7b4c6f2..7022762840e 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -10,11 +10,14 @@ = sprite_icon('download', css_class: 'gl-mr-2') %span= _("Clone repository") + - if @sidebar_error.present? + = render 'shared/alert_info', body: s_('Wiki|The sidebar failed to load. You can reload the page to try again.') + .blocks-container .block.block-first.w-100 - if @sidebar_page = render_wiki_content(@sidebar_page) - - else + - elsif @sidebar_wiki_entries %ul.wiki-pages = render @sidebar_wiki_entries, context: 'sidebar' .block.w-100 diff --git a/app/views/shared/wikis/git_error.html.haml b/app/views/shared/wikis/git_error.html.haml new file mode 100644 index 00000000000..dab3b940b9a --- /dev/null +++ b/app/views/shared/wikis/git_error.html.haml @@ -0,0 +1,14 @@ +- if @page + - wiki_page_title @page + +- add_page_specific_style 'page_bundles/wiki' + +- git_access_url = wiki_path(@wiki, action: :git_access) + +.wiki-page-header.top-area.gl-flex-direction-column.gl-lg-flex-direction-row + .gl-mt-5.gl-mb-3 + .gl-display-flex.gl-justify-content-space-between + %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page ? @page.human_title : _('Failed to retrieve page') + .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content' } } + = _('The page could not be displayed because it timed out.') + = html_escape(_('You can view the source or %{linkStart}%{cloneIcon} clone the repository%{linkEnd}')) % { linkStart: "<a href=\"#{git_access_url}\">".html_safe, linkEnd: '</a>'.html_safe, cloneIcon: sprite_icon('download', css_class: 'gl-mr-2').html_safe } diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 6f080a97f7a..80204fd31b9 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -124,7 +124,7 @@ :idempotent: :tags: [] - :name: cronjob:analytics_instance_statistics_count_job_trigger - :feature_category: :instance_statistics + :feature_category: :devops_reports :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -323,6 +323,22 @@ :weight: 1 :idempotent: :tags: [] +- :name: cronjob:releases_create_evidence + :feature_category: :release_evidence + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] +- :name: cronjob:releases_manage_evidence + :feature_category: :release_evidence + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: cronjob:remove_expired_group_links :feature_category: :authentication_and_authorization :has_external_dependencies: @@ -1313,7 +1329,7 @@ :idempotent: true :tags: [] - :name: analytics_instance_statistics_counter_job - :feature_category: :instance_statistics + :feature_category: :devops_reports :has_external_dependencies: :urgency: :low :resource_boundary: :unknown @@ -1377,14 +1393,6 @@ :weight: 2 :idempotent: true :tags: [] -- :name: create_evidence - :feature_category: :release_evidence - :has_external_dependencies: - :urgency: :low - :resource_boundary: :unknown - :weight: 2 - :idempotent: - :tags: [] - :name: create_note_diff_file :feature_category: :source_code_management :has_external_dependencies: diff --git a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb index bf57619fc6e..81a765d5d08 100644 --- a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb +++ b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb @@ -8,7 +8,7 @@ module Analytics DEFAULT_DELAY = 3.minutes.freeze - feature_category :instance_statistics + feature_category :devops_reports urgency :low idempotent! diff --git a/app/workers/analytics/instance_statistics/counter_job_worker.rb b/app/workers/analytics/instance_statistics/counter_job_worker.rb index 7fc715419b8..c07b2569453 100644 --- a/app/workers/analytics/instance_statistics/counter_job_worker.rb +++ b/app/workers/analytics/instance_statistics/counter_job_worker.rb @@ -5,7 +5,7 @@ module Analytics class CounterJobWorker include ApplicationWorker - feature_category :instance_statistics + feature_category :devops_reports urgency :low idempotent! diff --git a/app/workers/create_evidence_worker.rb b/app/workers/create_evidence_worker.rb deleted file mode 100644 index b18028e4114..00000000000 --- a/app/workers/create_evidence_worker.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - feature_category :release_evidence - weight 2 - - # pipeline_id is optional for backward compatibility with existing jobs - # caller should always try to provide the pipeline and pass nil only - # if pipeline is absent - def perform(release_id, pipeline_id = nil) - release = Release.find_by_id(release_id) - return unless release - - pipeline = Ci::Pipeline.find_by_id(pipeline_id) - - ::Releases::CreateEvidenceService.new(release, pipeline: pipeline).execute - end -end diff --git a/app/workers/releases/create_evidence_worker.rb b/app/workers/releases/create_evidence_worker.rb new file mode 100644 index 00000000000..db75fae1639 --- /dev/null +++ b/app/workers/releases/create_evidence_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Releases + class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + feature_category :release_evidence + + # pipeline_id is optional for backward compatibility with existing jobs + # caller should always try to provide the pipeline and pass nil only + # if pipeline is absent + def perform(release_id, pipeline_id = nil) + release = Release.find_by_id(release_id) + + return unless release + + pipeline = Ci::Pipeline.find_by_id(pipeline_id) + + ::Releases::CreateEvidenceService.new(release, pipeline: pipeline).execute + end + end +end diff --git a/app/workers/releases/manage_evidence_worker.rb b/app/workers/releases/manage_evidence_worker.rb new file mode 100644 index 00000000000..8a925d22cea --- /dev/null +++ b/app/workers/releases/manage_evidence_worker.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Releases + class ManageEvidenceWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + + feature_category :release_evidence + + def perform + releases = Release.without_evidence.released_within_2hrs + + releases.each do |release| + project = release.project + params = { tag: release.tag } + + evidence_pipeline = Releases::EvidencePipelineFinder.new(project, params).execute + + # perform_at released_at + ::Releases::CreateEvidenceWorker.perform_async(release.id, evidence_pipeline&.id) + end + end + end +end diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb index eb1a7f4fef9..5876cfb1fe7 100644 --- a/app/workers/trending_projects_worker.rb +++ b/app/workers/trending_projects_worker.rb @@ -2,10 +2,6 @@ class TrendingProjectsWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - # rubocop:disable Scalability/CronWorkerContext - # This worker does not perform work scoped to a context - include CronjobQueue - # rubocop:enable Scalability/CronWorkerContext include CronjobQueue # rubocop:disable Scalability/CronWorkerContext feature_category :source_code_management |