diff options
Diffstat (limited to 'app/assets')
776 files changed, 16422 insertions, 6450 deletions
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue index d0932ad80e1..1fec186f2fa 100644 --- a/app/assets/javascripts/access_tokens/components/expires_at_field.vue +++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue @@ -1,14 +1,32 @@ <script> -import { GlDatepicker } from '@gitlab/ui'; +import { GlDatepicker, GlFormInput } from '@gitlab/ui'; export default { name: 'ExpiresAtField', - components: { GlDatepicker }, + components: { GlDatepicker, GlFormInput }, + props: { + inputAttrs: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + minDate: new Date(), + }; + }, }; </script> <template> - <gl-datepicker :target="null" :min-date="new Date()"> - <slot></slot> + <gl-datepicker :target="null" :min-date="minDate"> + <gl-form-input + v-bind="inputAttrs" + class="datepicker gl-datepicker-input" + autocomplete="off" + inputmode="none" + data-qa-selector="expiry_date_field" + /> </gl-datepicker> </template> diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index 9bdb2940956..319144193f1 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -1,11 +1,34 @@ import Vue from 'vue'; import ExpiresAtField from './components/expires_at_field.vue'; +const getInputAttrs = el => { + const input = el.querySelector('input'); + + return { + id: input.id, + name: input.name, + placeholder: input.placeholder, + }; +}; + const initExpiresAtField = () => { - // eslint-disable-next-line no-new - new Vue({ - el: document.querySelector('.js-access-tokens-expires-at'), - components: { ExpiresAtField }, + const el = document.querySelector('.js-access-tokens-expires-at'); + + if (!el) { + return null; + } + + const inputAttrs = getInputAttrs(el); + + return new Vue({ + el, + render(h) { + return h(ExpiresAtField, { + props: { + inputAttrs, + }, + }); + }, }); }; diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js b/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js new file mode 100644 index 00000000000..ae73033079d --- /dev/null +++ b/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js @@ -0,0 +1,2 @@ +// EE-specific feature. Find the implementation in the `ee/`-folder +export default () => {}; diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js b/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js new file mode 100644 index 00000000000..0cb8d9be0e4 --- /dev/null +++ b/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import UserCallout from '~/user_callout'; +import UsagePingDisabled from './components/usage_ping_disabled.vue'; + +export default () => { + // eslint-disable-next-line no-new + new UserCallout(); + + const emptyStateContainer = document.getElementById('js-devops-empty-state'); + + if (!emptyStateContainer) return false; + + const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset; + + return new Vue({ + el: emptyStateContainer, + provide: { + isAdmin: Boolean(isAdmin), + svgPath: emptyStateSvgPath, + primaryButtonPath: enableUsagePingLink, + docsLink, + }, + render(h) { + return h(UsagePingDisabled); + }, + }); +}; diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index f7a5d31b835..1f3fdd5eef2 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -30,7 +30,6 @@ import AlertSidebar from './alert_sidebar.vue'; import AlertMetrics from './alert_metrics.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import AlertSummaryRow from './alert_summary_row.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const containerEl = document.querySelector('.page-with-contextual-sidebar'); @@ -77,7 +76,6 @@ export default { SystemNote, AlertMetrics, }, - mixins: [glFeatureFlagsMixin()], inject: { projectPath: { default: '', @@ -150,13 +148,10 @@ export default { }, }, environmentName() { - return this.shouldDisplayEnvironment && this.alert?.environment?.name; + return this.alert?.environment?.name; }, environmentPath() { - return this.shouldDisplayEnvironment && this.alert?.environment?.path; - }, - shouldDisplayEnvironment() { - return this.glFeatures.exposeEnvironmentPathInAlertDetails; + return this.alert?.environment?.path; }, }, mounted() { diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue new file mode 100644 index 00000000000..f6474efcc1f --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -0,0 +1,221 @@ +<script> +import Vue from 'vue'; +import { + GlIcon, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +// Mocks will be removed when integrating with BE is ready +// data format is defined and will be the same as mocked (maybe with some minor changes) +// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171 +import gitlabFieldsMock from './mocks/gitlabFields.json'; + +export const i18n = { + columns: { + gitlabKeyTitle: s__('AlertMappingBuilder|GitLab alert key'), + payloadKeyTitle: s__('AlertMappingBuilder|Payload alert key'), + fallbackKeyTitle: s__('AlertMappingBuilder|Define fallback'), + }, + selectMappingKey: s__('AlertMappingBuilder|Select key'), + makeSelection: s__('AlertMappingBuilder|Make selection'), + fallbackTooltip: s__( + 'AlertMappingBuilder|Title is a required field for alerts in GitLab. Should the payload field you specified not be available, specifiy which field we should use instead. ', + ), + noResults: __('No matching results'), +}; + +export default { + i18n, + components: { + GlIcon, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + }, + directives: { + GlTooltip, + }, + props: { + payloadFields: { + type: Array, + required: false, + default: () => [], + }, + mapping: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + gitlabFields: this.gitlabAlertFields, + }; + }, + inject: { + gitlabAlertFields: { + default: gitlabFieldsMock, + }, + }, + computed: { + mappingData() { + return this.gitlabFields.map(gitlabField => { + const mappingFields = this.payloadFields.filter(({ type }) => + type.some(t => gitlabField.compatibleTypes.includes(t)), + ); + + const foundMapping = this.mapping.find( + ({ alertFieldName }) => alertFieldName === gitlabField.name, + ); + + const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; + + return { + mapping: payloadAlertPaths, + fallback: fallbackAlertPaths, + searchTerm: '', + fallbackSearchTerm: '', + mappingFields, + ...gitlabField, + }; + }); + }, + }, + methods: { + setMapping(gitlabKey, mappingKey, valueKey) { + const fieldIndex = this.gitlabFields.findIndex(field => field.name === gitlabKey); + const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } }; + Vue.set(this.gitlabFields, fieldIndex, updatedField); + }, + setSearchTerm(search = '', searchFieldKey, gitlabKey) { + const fieldIndex = this.gitlabFields.findIndex(field => field.name === gitlabKey); + const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [searchFieldKey]: search } }; + Vue.set(this.gitlabFields, fieldIndex, updatedField); + }, + filterFields(searchTerm = '', fields) { + const search = searchTerm.toLowerCase(); + + return fields.filter(field => field.label.toLowerCase().includes(search)); + }, + isSelected(fieldValue, mapping) { + return fieldValue === mapping; + }, + selectedValue(name) { + return ( + this.payloadFields.find(item => item.name === name)?.label || + this.$options.i18n.makeSelection + ); + }, + getFieldValue({ label, type }) { + return `${label} (${type.join(__(' or '))})`; + }, + noResults(searchTerm, fields) { + return !this.filterFields(searchTerm, fields).length; + }, + }, +}; +</script> + +<template> + <div class="gl-display-table gl-w-full gl-mt-5"> + <div class="gl-display-table-row"> + <h5 id="gitlabFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + {{ $options.i18n.columns.gitlabKeyTitle }} + </h5> + <h5 class="gl-display-table-cell gl-py-3 gl-pr-3"> </h5> + <h5 id="parsedFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + {{ $options.i18n.columns.payloadKeyTitle }} + </h5> + <h5 id="fallbackFieldsHeader" class="gl-display-table-cell gl-py-3 gl-pr-3"> + {{ $options.i18n.columns.fallbackKeyTitle }} + <gl-icon + v-gl-tooltip + name="question" + class="gl-text-gray-500" + :title="$options.i18n.fallbackTooltip" + /> + </h5> + </div> + + <div + v-for="(gitlabField, index) in mappingData" + :key="gitlabField.name" + class="gl-display-table-row" + > + <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle"> + <gl-form-input + aria-labelledby="gitlabFieldsHeader" + disabled + :value="getFieldValue(gitlabField)" + /> + </div> + + <div class="gl-display-table-cell gl-py-3 gl-pr-3"> + <div class="right-arrow" :class="{ 'gl-vertical-align-middle': index === 0 }"> + <i class="right-arrow-head"></i> + </div> + </div> + + <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle"> + <gl-dropdown + :disabled="!gitlabField.mappingFields.length" + aria-labelledby="parsedFieldsHeader" + :text="selectedValue(gitlabField.mapping)" + class="gl-w-full" + :header-text="$options.i18n.selectMappingKey" + > + <gl-search-box-by-type @input="setSearchTerm($event, 'searchTerm', gitlabField.name)" /> + <gl-dropdown-item + v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)" + :key="`${mappingField.name}__mapping`" + :is-checked="isSelected(gitlabField.mapping, mappingField.name)" + is-check-item + @click="setMapping(gitlabField.name, mappingField.name, 'mapping')" + > + {{ mappingField.label }} + </gl-dropdown-item> + <gl-dropdown-item v-if="noResults(gitlabField.searchTerm, gitlabField.mappingFields)"> + {{ $options.i18n.noResults }} + </gl-dropdown-item> + </gl-dropdown> + </div> + + <div class="gl-display-table-cell gl-py-3 w-30p"> + <gl-dropdown + v-if="Boolean(gitlabField.numberOfFallbacks)" + :disabled="!gitlabField.mappingFields.length" + aria-labelledby="fallbackFieldsHeader" + :text="selectedValue(gitlabField.fallback)" + class="gl-w-full" + :header-text="$options.i18n.selectMappingKey" + > + <gl-search-box-by-type + @input="setSearchTerm($event, 'fallbackSearchTerm', gitlabField.name)" + /> + <gl-dropdown-item + v-for="mappingField in filterFields( + gitlabField.fallbackSearchTerm, + gitlabField.mappingFields, + )" + :key="`${mappingField.name}__fallback`" + :is-checked="isSelected(gitlabField.fallback, mappingField.name)" + is-check-item + @click="setMapping(gitlabField.name, mappingField.name, 'fallback')" + > + {{ mappingField.label }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="noResults(gitlabField.fallbackSearchTerm, gitlabField.mappingFields)" + > + {{ $options.i18n.noResults }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue b/app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue new file mode 100644 index 00000000000..35b7fe84c5f --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alert_settings_form_help_block.vue @@ -0,0 +1,32 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; + +export default { + components: { + GlLink, + GlSprintf, + }, + props: { + message: { + type: String, + required: true, + }, + link: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span> + <gl-sprintf :message="message"> + <template #link="{ content }"> + <gl-link class="gl-display-inline-block" :href="link" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </span> +</template> 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 217442e6131..12c0409629f 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -1,8 +1,24 @@ <script> -import { GlTable, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + GlButtonGroup, + GlButton, + GlIcon, + GlLoadingIcon, + GlModal, + GlModalDirective, + GlTable, + GlTooltipDirective, + GlSprintf, +} from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import Tracking from '~/tracking'; -import { trackAlertIntergrationsViewsOptions } from '../constants'; +import { + trackAlertIntegrationsViewsOptions, + integrationToDeleteDefault, + typeSet, +} from '../constants'; +import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; export const i18n = { title: s__('AlertsIntegrations|Current integrations'), @@ -24,23 +40,36 @@ const bodyTrClass = export default { i18n, + typeSet, components: { - GlTable, + GlButtonGroup, + GlButton, GlIcon, + GlLoadingIcon, + GlModal, + GlTable, + GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, }, + mixins: [glFeatureFlagsMixin()], props: { integrations: { type: Array, required: false, default: () => [], }, + loading: { + type: Boolean, + required: false, + default: false, + }, }, fields: [ { - key: 'activated', + key: 'active', label: __('Status'), }, { @@ -51,22 +80,56 @@ export default { key: 'type', label: __('Type'), }, + { + key: 'actions', + thClass: `gl-text-center`, + tdClass: `gl-text-center`, + label: __('Actions'), + }, ], - computed: { - tbodyTrClass() { - return { - [bodyTrClass]: this.integrations.length, - }; + apollo: { + currentIntegration: { + query: getCurrentIntegrationQuery, }, }, + data() { + return { + integrationToDelete: integrationToDeleteDefault, + currentIntegration: null, + }; + }, mounted() { - this.trackPageViews(); + const callback = entries => { + const isVisible = entries.some(entry => entry.isIntersecting); + + if (isVisible) { + this.trackPageViews(); + this.observer.disconnect(); + } + }; + + this.observer = new IntersectionObserver(callback); + this.observer.observe(this.$el); }, methods: { + tbodyTrClass(item) { + return { + [bodyTrClass]: this.integrations.length, + 'gl-bg-blue-50': (item !== null && item.id) === this.currentIntegration?.id, + }; + }, trackPageViews() { - const { category, action } = trackAlertIntergrationsViewsOptions; + const { category, action } = trackAlertIntegrationsViewsOptions; Tracking.event(category, action); }, + setIntegrationToDelete({ name, id }) { + this.integrationToDelete.id = id; + this.integrationToDelete.name = name; + }, + deleteIntegration() { + this.$emit('delete-integration', { id: this.integrationToDelete.id }); + this.integrationToDelete = { ...integrationToDeleteDefault }; + }, }, }; </script> @@ -75,15 +138,16 @@ export default { <div class="incident-management-list"> <h5 class="gl-font-lg">{{ $options.i18n.title }}</h5> <gl-table - :empty-text="$options.i18n.emptyState" + class="integration-list" :items="integrations" :fields="$options.fields" + :busy="loading" stacked="md" :tbody-tr-class="tbodyTrClass" show-empty > - <template #cell(activated)="{ item }"> - <span v-if="item.activated" data-testid="integration-activated-status"> + <template #cell(active)="{ item }"> + <span v-if="item.active" data-testid="integration-activated-status"> <gl-icon v-gl-tooltip name="check-circle-filled" @@ -104,6 +168,47 @@ export default { {{ $options.i18n.status.disabled.name }} </span> </template> + + <template #cell(actions)="{ item }"> + <gl-button-group v-if="glFeatures.httpIntegrationsList" class="gl-ml-3"> + <gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" /> + <gl-button + v-gl-modal.deleteIntegration + :disabled="item.type === $options.typeSet.prometheus" + icon="remove" + @click="setIntegrationToDelete(item)" + /> + </gl-button-group> + </template> + + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + + <template #empty> + <div + class="gl-border-t-solid gl-border-b-solid gl-border-1 gl-border gl-border-gray-100 mt-n3 gl-px-5" + > + <p class="gl-text-gray-400 gl-py-3 gl-my-3">{{ $options.i18n.emptyState }}</p> + </div> + </template> </gl-table> + <gl-modal + modal-id="deleteIntegration" + :title="s__('AlertSettings|Delete integration')" + :ok-title="s__('AlertSettings|Delete integration')" + ok-variant="danger" + @ok="deleteIntegration" + > + <gl-sprintf + :message=" + s__( + 'AlertsIntegrations|You have opted to delete the %{integrationName} integration. Do you want to proceed? It means you will no longer receive alerts from this endpoint in your alert list, and this action cannot be undone.', + ) + " + > + <template #integrationName>{{ integrationToDelete.name }}</template> + </gl-sprintf> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue new file mode 100644 index 00000000000..3656fc4d7ec --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue @@ -0,0 +1,661 @@ +<script> +import { + GlButton, + GlCollapse, + GlForm, + GlFormGroup, + GlFormSelect, + GlFormInput, + GlFormInputGroup, + GlFormTextarea, + GlModal, + GlModalDirective, + GlToggle, +} from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import MappingBuilder from './alert_mapping_builder.vue'; +import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue'; +import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; +import service from '../services'; +import { + integrationTypesNew, + JSON_VALIDATE_DELAY, + targetPrometheusUrlPlaceholder, + targetOpsgenieUrlPlaceholder, + typeSet, + sectionHash, +} from '../constants'; +// Mocks will be removed when integrating with BE is ready +// data format is defined and will be the same as mocked (maybe with some minor changes) +// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171 +import mockedCustomMapping from './mocks/parsedMapping.json'; + +export default { + placeholders: { + prometheus: targetPrometheusUrlPlaceholder, + opsgenie: targetOpsgenieUrlPlaceholder, + }, + JSON_VALIDATE_DELAY, + typeSet, + i18n: { + integrationFormSteps: { + step1: { + label: s__('AlertSettings|1. Select integration type'), + enterprise: s__( + 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.', + ), + }, + step2: { + label: s__('AlertSettings|2. Name integration'), + placeholder: s__('AlertSettings|Enter integration name'), + }, + step3: { + label: s__('AlertSettings|3. Set up webhook'), + help: s__( + "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", + ), + prometheusHelp: s__( + 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.', + ), + info: s__('AlertSettings|Authorization key'), + reset: s__('AlertSettings|Reset Key'), + }, + step4: { + label: s__('AlertSettings|4. Sample alert payload (optional)'), + help: s__( + 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).', + ), + prometheusHelp: s__( + 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).', + ), + placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'), + resetHeader: s__('AlertSettings|Reset the mapping'), + resetBody: s__( + "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.", + ), + resetOk: s__('AlertSettings|Proceed with editing'), + editPayload: s__('AlertSettings|Edit payload'), + submitPayload: s__('AlertSettings|Submit payload'), + payloadParsedSucessMsg: s__( + 'AlertSettings|Sample payload has been parsed. You can now map the fields.', + ), + }, + step5: { + label: s__('AlertSettings|5. Map fields (optional)'), + intro: s__( + "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.", + ), + }, + prometheusFormUrl: { + label: s__('AlertSettings|Prometheus API base URL'), + help: s__('AlertSettings|URL cannot be blank and must start with http or https'), + }, + restKeyInfo: { + label: s__( + 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', + ), + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + opsgenie: { + label: s__('AlertSettings|2. Add link to your Opsgenie alert list'), + info: s__( + 'AlertSettings|Utilizing this option will link the GitLab Alerts navigation item to your existing Opsgenie instance. By selecting this option, you cannot receive alerts from any other source in GitLab; it will effectively be turning Alerts within GitLab off as a feature.', + ), + }, + }, + }, + components: { + ClipboardButton, + GlButton, + GlCollapse, + GlForm, + GlFormGroup, + GlFormInput, + GlFormInputGroup, + GlFormTextarea, + GlFormSelect, + GlModal, + GlToggle, + AlertSettingsFormHelpBlock, + MappingBuilder, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: { + generic: { + default: {}, + }, + prometheus: { + default: {}, + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + opsgenie: { + default: {}, + }, + }, + mixins: [glFeatureFlagsMixin()], + props: { + loading: { + type: Boolean, + required: true, + }, + canAddIntegration: { + type: Boolean, + required: true, + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + canManageOpsgenie: { + type: Boolean, + required: false, + default: false, + }, + }, + apollo: { + currentIntegration: { + query: getCurrentIntegrationQuery, + }, + }, + data() { + return { + selectedIntegration: integrationTypesNew[0].value, + active: false, + formVisible: false, + integrationTestPayload: { + json: null, + error: null, + }, + resetSamplePayloadConfirmed: false, + customMapping: null, + parsingPayload: false, + currentIntegration: null, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + isManagingOpsgenie: false, + }; + }, + computed: { + isPrometheus() { + return this.selectedIntegration === this.$options.typeSet.prometheus; + }, + jsonIsValid() { + return this.integrationTestPayload.error === null; + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + disabledIntegrations() { + const options = []; + if (this.opsgenie.active) { + options.push(typeSet.http, typeSet.prometheus); + } else if (!this.canManageOpsgenie) { + options.push(typeSet.opsgenie); + } + + return options; + }, + options() { + return integrationTypesNew.map(el => ({ + ...el, + disabled: this.disabledIntegrations.includes(el.value), + })); + }, + selectedIntegrationType() { + switch (this.selectedIntegration) { + case typeSet.http: + return this.generic; + case typeSet.prometheus: + return this.prometheus; + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + case typeSet.opsgenie: + return this.opsgenie; + default: + return {}; + } + }, + integrationForm() { + return { + name: this.currentIntegration?.name || '', + active: this.currentIntegration?.active || false, + token: this.currentIntegration?.token || this.selectedIntegrationType.token, + url: this.currentIntegration?.url || this.selectedIntegrationType.url, + apiUrl: this.currentIntegration?.apiUrl || '', + }; + }, + testAlertPayload() { + return { + data: this.integrationTestPayload.json, + endpoint: this.integrationForm.url, + token: this.integrationForm.token, + }; + }, + showMappingBuilder() { + return ( + this.glFeatures.multipleHttpIntegrationsCustomMapping && + this.selectedIntegration === typeSet.http + ); + }, + mappingBuilderFields() { + return this.customMapping?.samplePayload?.payloadAlerFields?.nodes; + }, + mappingBuilderMapping() { + return this.customMapping?.storedMapping?.nodes; + }, + hasSamplePayload() { + return Boolean(this.customMapping?.samplePayload); + }, + canEditPayload() { + return this.hasSamplePayload && !this.resetSamplePayloadConfirmed; + }, + isPayloadEditDisabled() { + return !this.active || this.canEditPayload; + }, + }, + watch: { + currentIntegration(val) { + if (val === null) { + return this.reset(); + } + this.selectedIntegration = val.type; + this.active = val.active; + if (val.type === typeSet.http) this.getIntegrationMapping(val.id); + return this.integrationTypeSelect(); + }, + }, + methods: { + integrationTypeSelect() { + if (this.selectedIntegration === integrationTypesNew[0].value) { + this.formVisible = false; + } else { + this.formVisible = true; + } + + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + if (this.canManageOpsgenie && this.selectedIntegration === typeSet.opsgenie) { + this.isManagingOpsgenie = true; + this.active = this.opsgenie.active; + this.integrationForm.apiUrl = this.opsgenie.opsgenieMvcTargetUrl; + } else { + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + this.isManagingOpsgenie = false; + } + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + submitWithOpsgenie() { + return service + .updateGenericActive({ + endpoint: this.opsgenie.formPath, + params: { + service: { + opsgenie_mvc_target_url: this.integrationForm.apiUrl, + opsgenie_mvc_enabled: this.active, + }, + }, + }) + .then(() => { + window.location.hash = sectionHash; + window.location.reload(); + }); + }, + submitWithTestPayload() { + return service + .updateTestAlert(this.testAlertPayload) + .then(() => { + this.submit(); + }) + .catch(() => { + this.$emit('test-payload-failure'); + }); + }, + submit() { + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + if (this.isManagingOpsgenie) { + return this.submitWithOpsgenie(); + } + + const { name, apiUrl } = this.integrationForm; + const variables = + this.selectedIntegration === typeSet.http + ? { name, active: this.active } + : { apiUrl, active: this.active }; + const integrationPayload = { type: this.selectedIntegration, variables }; + + if (this.currentIntegration) { + return this.$emit('update-integration', integrationPayload); + } + + return this.$emit('create-new-integration', integrationPayload); + }, + reset() { + this.selectedIntegration = integrationTypesNew[0].value; + this.integrationTypeSelect(); + + if (this.currentIntegration) { + return this.$emit('clear-current-integration'); + } + + return this.resetFormValues(); + }, + resetFormValues() { + this.integrationForm.name = ''; + this.integrationForm.apiUrl = ''; + this.integrationTestPayload = { + json: null, + error: null, + }; + this.active = false; + + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + this.isManagingOpsgenie = false; + }, + resetAuthKey() { + if (!this.currentIntegration) { + return; + } + + this.$emit('reset-token', { + type: this.selectedIntegration, + variables: { id: this.currentIntegration.id }, + }); + }, + validateJson() { + this.integrationTestPayload.error = null; + if (this.integrationTestPayload.json === '') { + return; + } + + try { + JSON.parse(this.integrationTestPayload.json); + } catch (e) { + this.integrationTestPayload.error = JSON.stringify(e.message); + } + }, + parseMapping() { + // TODO: replace with real BE mutation when ready; + this.parsingPayload = true; + + return new Promise(resolve => { + setTimeout(() => resolve(mockedCustomMapping), 1000); + }) + .then(res => { + const mapping = { ...res }; + delete mapping.storedMapping; + this.customMapping = res; + this.integrationTestPayload.json = res?.samplePayload.body; + this.resetSamplePayloadConfirmed = false; + + this.$toast.show(this.$options.i18n.integrationFormSteps.step4.payloadParsedSucessMsg); + }) + .finally(() => { + this.parsingPayload = false; + }); + }, + getIntegrationMapping() { + // TODO: replace with real BE mutation when ready; + return Promise.resolve(mockedCustomMapping).then(res => { + this.customMapping = res; + this.integrationTestPayload.json = res?.samplePayload.body; + }); + }, + }, +}; +</script> + +<template> + <gl-form class="gl-mt-6" @submit.prevent="submit" @reset.prevent="reset"> + <h5 class="gl-font-lg gl-my-5">{{ s__('AlertSettings|Add new integrations') }}</h5> + <gl-form-group + id="integration-type" + :label="$options.i18n.integrationFormSteps.step1.label" + label-for="integration-type" + > + <gl-form-select + v-model="selectedIntegration" + :disabled="currentIntegration !== null || !canAddIntegration" + :options="options" + @change="integrationTypeSelect" + /> + + <div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported"> + <alert-settings-form-help-block + :message="$options.i18n.integrationFormSteps.step1.enterprise" + link="https://about.gitlab.com/pricing" + /> + </div> + </gl-form-group> + <gl-collapse v-model="formVisible" class="gl-mt-3"> + <!-- TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 --> + <div v-if="isManagingOpsgenie"> + <gl-form-group + id="integration-webhook" + :label="$options.i18n.integrationFormSteps.opsgenie.label" + label-for="integration-webhook" + > + <span class="gl-my-4"> + {{ $options.i18n.integrationFormSteps.opsgenie.info }} + </span> + + <gl-toggle + v-model="active" + :is-loading="loading" + :label="__('Active')" + class="gl-my-4 gl-font-weight-normal" + /> + + <gl-form-input + id="opsgenie-opsgenieMvcTargetUrl" + v-model="integrationForm.apiUrl" + type="text" + :placeholder="$options.placeholders.opsgenie" + /> + + <span class="gl-text-gray-400 gl-my-1"> + {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }} + </span> + </gl-form-group> + </div> + <div v-else> + <gl-form-group + id="name-integration" + :label="$options.i18n.integrationFormSteps.step2.label" + label-for="name-integration" + > + <gl-form-input + v-model="integrationForm.name" + type="text" + :placeholder="$options.i18n.integrationFormSteps.step2.placeholder" + /> + </gl-form-group> + <gl-form-group + id="integration-webhook" + :label="$options.i18n.integrationFormSteps.step3.label" + label-for="integration-webhook" + > + <alert-settings-form-help-block + :message=" + isPrometheus + ? $options.i18n.integrationFormSteps.step3.prometheusHelp + : $options.i18n.integrationFormSteps.step3.help + " + link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" + /> + + <gl-toggle + v-model="active" + :is-loading="loading" + :label="__('Active')" + class="gl-my-4 gl-font-weight-normal" + /> + + <div v-if="isPrometheus" class="gl-my-4"> + <span class="gl-font-weight-bold"> + {{ $options.i18n.integrationFormSteps.prometheusFormUrl.label }} + </span> + + <gl-form-input + id="integration-apiUrl" + v-model="integrationForm.apiUrl" + type="text" + :placeholder="$options.placeholders.prometheus" + /> + + <span class="gl-text-gray-400"> + {{ $options.i18n.integrationFormSteps.prometheusFormUrl.help }} + </span> + </div> + + <div class="gl-my-4"> + <span class="gl-font-weight-bold"> + {{ s__('AlertSettings|Webhook URL') }} + </span> + + <gl-form-input-group id="url" readonly :value="integrationForm.url"> + <template #append> + <clipboard-button + :text="integrationForm.url || ''" + :title="__('Copy')" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + </div> + + <div class="gl-my-4"> + <span class="gl-font-weight-bold"> + {{ $options.i18n.integrationFormSteps.step3.info }} + </span> + + <gl-form-input-group + id="authorization-key" + class="gl-mb-3" + readonly + :value="integrationForm.token" + > + <template #append> + <clipboard-button + :text="integrationForm.token || ''" + :title="__('Copy')" + class="gl-m-0!" + /> + </template> + </gl-form-input-group> + + <gl-button v-gl-modal.authKeyModal :disabled="!active"> + {{ $options.i18n.integrationFormSteps.step3.reset }} + </gl-button> + <gl-modal + modal-id="authKeyModal" + :title="$options.i18n.integrationFormSteps.step3.reset" + :ok-title="$options.i18n.integrationFormSteps.step3.reset" + ok-variant="danger" + @ok="resetAuthKey" + > + {{ $options.i18n.integrationFormSteps.restKeyInfo.label }} + </gl-modal> + </div> + </gl-form-group> + + <gl-form-group + id="test-integration" + :label="$options.i18n.integrationFormSteps.step4.label" + label-for="test-integration" + :class="{ 'gl-mb-0!': showMappingBuilder }" + :invalid-feedback="integrationTestPayload.error" + > + <alert-settings-form-help-block + :message=" + isPrometheus || !showMappingBuilder + ? $options.i18n.integrationFormSteps.step4.prometheusHelp + : $options.i18n.integrationFormSteps.step4.help + " + :link="generic.alertsUsageUrl" + /> + + <gl-form-textarea + id="test-payload" + v-model.trim="integrationTestPayload.json" + :disabled="isPayloadEditDisabled" + :state="jsonIsValid" + :placeholder="$options.i18n.integrationFormSteps.step4.placeholder" + class="gl-my-3" + :debounce="$options.JSON_VALIDATE_DELAY" + rows="6" + max-rows="10" + @input="validateJson" + /> + </gl-form-group> + + <template v-if="showMappingBuilder"> + <gl-button + v-if="canEditPayload" + v-gl-modal.resetPayloadModal + data-testid="payload-action-btn" + :disabled="!active" + class="gl-mt-3" + > + {{ $options.i18n.integrationFormSteps.step4.editPayload }} + </gl-button> + + <gl-button + v-else + data-testid="payload-action-btn" + :class="{ 'gl-mt-3': integrationTestPayload.error }" + :disabled="!active" + :loading="parsingPayload" + @click="parseMapping" + > + {{ $options.i18n.integrationFormSteps.step4.submitPayload }} + </gl-button> + <gl-modal + modal-id="resetPayloadModal" + :title="$options.i18n.integrationFormSteps.step4.resetHeader" + :ok-title="$options.i18n.integrationFormSteps.step4.resetOk" + ok-variant="danger" + @ok="resetSamplePayloadConfirmed = true" + > + {{ $options.i18n.integrationFormSteps.step4.resetBody }} + </gl-modal> + </template> + + <gl-form-group + v-if="showMappingBuilder" + id="mapping-builder" + class="gl-mt-5" + :label="$options.i18n.integrationFormSteps.step5.label" + label-for="mapping-builder" + > + <span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span> + <mapping-builder + :payload-fields="mappingBuilderFields" + :mapping="mappingBuilderMapping" + /> + </gl-form-group> + </div> + <div class="gl-display-flex gl-justify-content-start gl-py-3"> + <gl-button + type="submit" + variant="success" + class="js-no-auto-disable" + data-testid="integration-form-submit" + >{{ s__('AlertSettings|Save integration') }} + </gl-button> + <!-- TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 --> + <gl-button + v-if="!isManagingOpsgenie" + data-testid="integration-test-and-submit" + :disabled="Boolean(integrationTestPayload.error)" + category="secondary" + variant="success" + class="gl-mx-3 js-no-auto-disable" + @click="submitWithTestPayload" + >{{ s__('AlertSettings|Save and test payload') }}</gl-button + > + <gl-button + type="reset" + class="js-no-auto-disable" + :class="{ 'gl-ml-3': isManagingOpsgenie }" + >{{ __('Cancel') }}</gl-button + > + </div> + </gl-collapse> + </gl-form> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue index f885afae378..0246315bdc5 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue @@ -14,16 +14,14 @@ import { GlFormSelect, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import { s__ } from '~/locale'; 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 IntegrationsList from './alerts_integrations_list.vue'; import csrf from '~/lib/utils/csrf'; import service from '../services'; import { i18n, - serviceOptions, + integrationTypes, JSON_VALIDATE_DELAY, targetPrometheusUrlPlaceholder, targetOpsgenieUrlPlaceholder, @@ -50,7 +48,6 @@ export default { GlSprintf, ClipboardButton, ToggleButton, - IntegrationsList, }, directives: { 'gl-modal': GlModalDirective, @@ -59,10 +56,10 @@ export default { data() { return { loading: false, - selectedEndpoint: serviceOptions[0].value, - options: serviceOptions, + selectedIntegration: integrationTypes[0].value, + options: integrationTypes, active: false, - authKey: '', + token: '', targetUrl: '', feedback: { variant: 'danger', @@ -91,34 +88,34 @@ export default { ]; }, isPrometheus() { - return this.selectedEndpoint === 'prometheus'; + return this.selectedIntegration === 'PROMETHEUS'; }, isOpsgenie() { - return this.selectedEndpoint === 'opsgenie'; + return this.selectedIntegration === 'OPSGENIE'; }, - selectedService() { - switch (this.selectedEndpoint) { - case 'generic': { + selectedIntegrationType() { + switch (this.selectedIntegration) { + case 'HTTP': { return { url: this.generic.url, - authKey: this.generic.authorizationKey, - activated: this.generic.activated, + token: this.generic.token, + active: this.generic.active, resetKey: this.resetKey.bind(this), }; } - case 'prometheus': { + case 'PROMETHEUS': { return { - url: this.prometheus.prometheusUrl, - authKey: this.prometheus.authorizationKey, - activated: this.prometheus.activated, - resetKey: this.resetKey.bind(this, 'prometheus'), + url: this.prometheus.url, + token: this.prometheus.token, + active: this.prometheus.active, + resetKey: this.resetKey.bind(this, 'PROMETHEUS'), targetUrl: this.prometheus.prometheusApiUrl, }; } - case 'opsgenie': { + case 'OPSGENIE': { return { targetUrl: this.opsgenie.opsgenieMvcTargetUrl, - activated: this.opsgenie.activated, + active: this.opsgenie.active, }; } default: { @@ -152,43 +149,25 @@ export default { ? this.$options.targetOpsgenieUrlPlaceholder : this.$options.targetPrometheusUrlPlaceholder; }, - integrations() { - return [ - { - name: s__('AlertSettings|HTTP endpoint'), - type: s__('AlertsIntegrations|HTTP endpoint'), - activated: this.generic.activated, - }, - { - name: s__('AlertSettings|External Prometheus'), - type: s__('AlertsIntegrations|Prometheus'), - activated: this.prometheus.activated, - }, - ]; - }, }, watch: { 'testAlert.json': debounce(function debouncedJsonValidate() { this.validateJson(); }, JSON_VALIDATE_DELAY), targetUrl(oldVal, newVal) { - if (newVal && oldVal !== this.selectedService.targetUrl) { + if (newVal && oldVal !== this.selectedIntegrationType.targetUrl) { this.canSaveForm = true; } }, }, mounted() { - if ( - this.prometheus.activated || - this.generic.activated || - !this.opsgenie.opsgenieMvcIsAvailable - ) { + if (this.prometheus.active || this.generic.active || !this.opsgenie.opsgenieMvcIsAvailable) { this.removeOpsGenieOption(); - } else if (this.opsgenie.activated) { + } else if (this.opsgenie.active) { this.setOpsgenieAsDefault(); } - this.active = this.selectedService.activated; - this.authKey = this.selectedService.authKey ?? ''; + this.active = this.selectedIntegrationType.active; + this.token = this.selectedIntegrationType.token ?? ''; }, methods: { createUserErrorMessage(errors = {}) { @@ -200,19 +179,19 @@ export default { }, setOpsgenieAsDefault() { this.options = this.options.map(el => { - if (el.value !== 'opsgenie') { + if (el.value !== 'OPSGENIE') { return { ...el, disabled: true }; } return { ...el, disabled: false }; }); - this.selectedEndpoint = this.options.find(({ value }) => value === 'opsgenie').value; + this.selectedIntegration = this.options.find(({ value }) => value === 'OPSGENIE').value; if (this.targetUrl === null) { - this.targetUrl = this.selectedService.targetUrl; + this.targetUrl = this.selectedIntegrationType.targetUrl; } }, removeOpsGenieOption() { this.options = this.options.map(el => { - if (el.value !== 'opsgenie') { + if (el.value !== 'OPSGENIE') { return { ...el, disabled: false }; } return { ...el, disabled: true }; @@ -220,8 +199,8 @@ export default { }, resetFormValues() { this.testAlert.json = null; - this.targetUrl = this.selectedService.targetUrl; - this.active = this.selectedService.activated; + this.targetUrl = this.selectedIntegrationType.targetUrl; + this.active = this.selectedIntegrationType.active; }, dismissFeedback() { this.serverError = null; @@ -229,12 +208,12 @@ export default { this.isFeedbackDismissed = false; }, resetKey(key) { - const fn = key === 'prometheus' ? this.resetPrometheusKey() : this.resetGenericKey(); + const fn = key === 'PROMETHEUS' ? this.resetPrometheusKey() : this.resetGenericKey(); return fn .then(({ data: { token } }) => { - this.authKey = token; - this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' }); + this.token = token; + this.setFeedback({ feedbackMessage: this.$options.i18n.tokenRest, variant: 'success' }); }) .catch(() => { this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); @@ -259,9 +238,10 @@ export default { }, toggleActivated(value) { this.loading = true; + const path = this.isOpsgenie ? this.opsgenie.formPath : this.generic.formPath; return service .updateGenericActive({ - endpoint: this[this.selectedEndpoint].formPath, + endpoint: path, params: this.isOpsgenie ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } } : { service: { active: value } }, @@ -331,9 +311,9 @@ export default { this.validateJson(); return service .updateTestAlert({ - endpoint: this.selectedService.url, + endpoint: this.selectedIntegrationType.url, data: this.testAlert.json, - authKey: this.selectedService.authKey, + token: this.selectedIntegrationType.token, }) .then(() => { this.setFeedback({ @@ -358,11 +338,11 @@ export default { onReset() { this.testAlert.json = null; this.dismissFeedback(); - this.targetUrl = this.selectedService.targetUrl; + this.targetUrl = this.selectedIntegrationType.targetUrl; if (this.canSaveForm) { this.canSaveForm = false; - this.active = this.selectedService.activated; + this.active = this.selectedIntegrationType.active; } }, }, @@ -370,153 +350,145 @@ export default { </script> <template> - <div> - <integrations-list :integrations="integrations" /> - - <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset"> - <h5 class="gl-font-lg gl-my-5">{{ $options.i18n.integrationsLabel }}</h5> + <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> + <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> + <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="selectedEndpoint" - :options="options" - data-testid="alert-settings-select" - @change="resetFormValues" - /> + <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"> - <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> + {{ prometheusInfo }} </span> </gl-form-group> - <gl-form-group :label="$options.i18n.activeLabel" label-for="activated"> - <toggle-button - id="activated" - :disabled-input="loading" - :is-loading="loading" - :value="active" - @change="toggleService" - /> + <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 - v-if="isOpsgenie || isPrometheus" - :label="$options.i18n.apiBaseUrlLabel" - label-for="api-url" + :label="$options.i18n.alertJson" + label-for="alert-json" + :invalid-feedback="testAlert.error" > - <gl-form-input - id="api-url" - v-model="targetUrl" - type="url" - :placeholder="baseUrlPlaceholder" + <gl-form-textarea + id="alert-json" + v-model.trim="testAlert.json" :disabled="!active" + :state="jsonIsValid" + :placeholder="$options.i18n.alertJsonPlaceholder" + rows="6" + max-rows="10" /> - <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="selectedService.url"> - <template #append> - <clipboard-button - :text="selectedService.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.authKeyLabel" label-for="authorization-key"> - <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="authKey"> - <template #append> - <clipboard-button - :text="authKey" - :title="$options.i18n.copyToClipboard" - class="gl-m-0!" - /> - </template> - </gl-form-input-group> - <gl-button v-gl-modal.authKeyModal :disabled="!active" class="gl-mt-3">{{ - $options.i18n.resetKey - }}</gl-button> - <gl-modal - modal-id="authKeyModal" - :title="$options.i18n.resetKey" - :ok-title="$options.i18n.resetKey" - ok-variant="danger" - @ok="selectedService.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> - </div> + + <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 new file mode 100644 index 00000000000..1ffc2f80148 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -0,0 +1,331 @@ +<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'; +import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; +import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql'; +import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql'; +import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql'; +import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql'; +import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql'; +import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql'; +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 { typeSet } from '../constants'; +import { + updateStoreAfterIntegrationDelete, + updateStoreAfterIntegrationAdd, +} from '../utils/cache_updates'; +import { + DELETE_INTEGRATION_ERROR, + ADD_INTEGRATION_ERROR, + RESET_INTEGRATION_TOKEN_ERROR, + UPDATE_INTEGRATION_ERROR, + INTEGRATION_PAYLOAD_TEST_ERROR, +} from '../utils/error_messages'; + +export default { + typeSet, + i18n: { + changesSaved: s__( + 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.', + ), + integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'), + }, + components: { + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + GlAlert, + GlLink, + GlSprintf, + IntegrationsList, + SettingsFormOld, + SettingsFormNew, + }, + mixins: [glFeatureFlagsMixin()], + inject: { + generic: { + default: {}, + }, + prometheus: { + default: {}, + }, + // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 + opsgenie: { + default: {}, + }, + projectPath: { + default: '', + }, + multiIntegrations: { + default: false, + }, + }, + apollo: { + integrations: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: getIntegrationsQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + const { alertManagementIntegrations: { nodes: list = [] } = {} } = data.project || {}; + + return { + list, + }; + }, + error(err) { + createFlash({ message: err }); + }, + }, + currentIntegration: { + query: getCurrentIntegrationQuery, + }, + }, + data() { + return { + isUpdating: false, + integrations: {}, + currentIntegration: null, + }; + }, + computed: { + 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; + }, + canManageOpsgenie() { + return ( + this.integrations?.list?.every(({ active }) => active === false) || + this.integrations?.list?.length === 0 + ); + }, + }, + methods: { + createNewIntegration({ type, variables }) { + const { projectPath } = this; + + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: + type === this.$options.typeSet.http + ? createHttpIntegrationMutation + : createPrometheusIntegrationMutation, + variables: { + ...variables, + projectPath, + }, + update(store, { data }) { + updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath }); + }, + }) + .then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => { + const error = httpIntegrationCreate?.errors[0] || prometheusIntegrationCreate?.errors[0]; + if (error) { + return createFlash({ message: error }); + } + return createFlash({ + message: this.$options.i18n.changesSaved, + type: FLASH_TYPES.SUCCESS, + }); + }) + .catch(() => { + createFlash({ message: ADD_INTEGRATION_ERROR }); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + updateIntegration({ type, variables }) { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: + type === this.$options.typeSet.http + ? updateHttpIntegrationMutation + : updatePrometheusIntegrationMutation, + variables: { + ...variables, + id: this.currentIntegration.id, + }, + }) + .then(({ data: { httpIntegrationUpdate, prometheusIntegrationUpdate } = {} } = {}) => { + const error = httpIntegrationUpdate?.errors[0] || prometheusIntegrationUpdate?.errors[0]; + if (error) { + return createFlash({ message: error }); + } + return createFlash({ + message: this.$options.i18n.changesSaved, + type: FLASH_TYPES.SUCCESS, + }); + }) + .catch(() => { + createFlash({ message: UPDATE_INTEGRATION_ERROR }); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + resetToken({ type, variables }) { + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: + type === this.$options.typeSet.http + ? resetHttpTokenMutation + : resetPrometheusTokenMutation, + variables, + }) + .then( + ({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => { + const error = + httpIntegrationResetToken?.errors[0] || prometheusIntegrationResetToken?.errors[0]; + if (error) { + return createFlash({ message: error }); + } + + const integration = + httpIntegrationResetToken?.integration || + prometheusIntegrationResetToken?.integration; + this.currentIntegration = integration; + + return createFlash({ + message: this.$options.i18n.changesSaved, + type: FLASH_TYPES.SUCCESS, + }); + }, + ) + .catch(() => { + createFlash({ message: RESET_INTEGRATION_TOKEN_ERROR }); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + editIntegration({ id }) { + const currentIntegration = this.integrations.list.find(integration => integration.id === id); + this.$apollo.mutate({ + mutation: updateCurrentIntergrationMutation, + variables: { + id: currentIntegration.id, + name: currentIntegration.name, + active: currentIntegration.active, + token: currentIntegration.token, + type: currentIntegration.type, + url: currentIntegration.url, + apiUrl: currentIntegration.apiUrl, + }, + }); + }, + deleteIntegration({ id }) { + const { projectPath } = this; + + this.isUpdating = true; + this.$apollo + .mutate({ + mutation: destroyHttpIntegrationMutation, + variables: { + id, + }, + update(store, { data }) { + updateStoreAfterIntegrationDelete(store, getIntegrationsQuery, data, { projectPath }); + }, + }) + .then(({ data: { httpIntegrationDestroy } = {} } = {}) => { + const error = httpIntegrationDestroy?.errors[0]; + if (error) { + return createFlash({ message: error }); + } + this.clearCurrentIntegration(); + return createFlash({ + message: this.$options.i18n.integrationRemoved, + type: FLASH_TYPES.SUCCESS, + }); + }) + .catch(() => { + createFlash({ message: DELETE_INTEGRATION_ERROR }); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + clearCurrentIntegration() { + this.$apollo.mutate({ + mutation: updateCurrentIntergrationMutation, + variables: {}, + }); + }, + testPayloadFailure() { + createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); + }, + }, +}; +</script> + +<template> + <div> + <!-- TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657 --> + <gl-alert v-if="opsgenie.active" :dismissible="false" variant="tip"> + <gl-sprintf + :message=" + s__( + 'AlertSettings|We will soon be introducing the ability to create multiple unique HTTP endpoints. When this functionality is live, you will be able to configure an integration with Opsgenie to surface Opsgenie alerts in GitLab. This will replace the current Opsgenie integration which will be deprecated. %{linkStart}More Information%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link + class="gl-display-inline-block" + href="https://gitlab.com/gitlab-org/gitlab/-/issues/273657" + target="_blank" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </gl-alert> + <integrations-list + v-else + :integrations="glFeatures.httpIntegrationsList ? integrations.list : integrationsOptionsOld" + :loading="loading" + @edit-integration="editIntegration" + @delete-integration="deleteIntegration" + /> + <settings-form-new + v-if="glFeatures.httpIntegrationsList" + :loading="isUpdating" + :can-add-integration="canAddIntegration" + :can-manage-opsgenie="canManageOpsgenie" + @create-new-integration="createNewIntegration" + @update-integration="updateIntegration" + @reset-token="resetToken" + @clear-current-integration="clearCurrentIntegration" + @test-payload-failure="testPayloadFailure" + /> + <settings-form-old v-else /> + </div> +</template> diff --git a/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json new file mode 100644 index 00000000000..ac559a30eda --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/mocks/gitlabFields.json @@ -0,0 +1,112 @@ +[ + { + "name": "title", + "label": "Title", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ], + "numberOfFallbacks": 1 + }, + { + "name": "description", + "label": "Description", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "startTime", + "label": "Start time", + "type": [ + "DateTime" + ], + "compatibleTypes": [ + "Number", + "DateTime" + ] + }, + { + "name": "service", + "label": "Service", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "monitoringTool", + "label": "Monitoring tool", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "hosts", + "label": "Hosts", + "type": [ + "String", + "Array" + ], + "compatibleTypes": [ + "String", + "Array", + "Number", + "DateTime" + ] + }, + { + "name": "severity", + "label": "Severity", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "fingerprint", + "label": "Fingerprint", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + }, + { + "name": "environment", + "label": "Environment", + "type": [ + "String" + ], + "compatibleTypes": [ + "String", + "Number", + "DateTime" + ] + } +] diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json new file mode 100644 index 00000000000..5326678155d --- /dev/null +++ b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json @@ -0,0 +1,121 @@ +{ + "samplePayload": { + "body": "{\n \"dashboardId\":1,\n \"evalMatches\":[\n {\n \"value\":1,\n \"metric\":\"Count\",\n \"tags\":{}\n }\n ],\n \"imageUrl\":\"https://grafana.com/static/assets/img/blog/mixed_styles.png\",\n \"message\":\"Notification Message\",\n \"orgId\":1,\n \"panelId\":2,\n \"ruleId\":1,\n \"ruleName\":\"Panel Title alert\",\n \"ruleUrl\":\"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\\u0026edit\\u0026tab=alert\\u0026panelId=2\\u0026orgId=1\",\n \"state\":\"alerting\",\n \"tags\":{\n \"tag name\":\"tag value\"\n },\n \"title\":\"[Alerting] Panel Title alert\"\n}\n", + "payloadAlerFields": { + "nodes": [ + { + "name": "dashboardId", + "label": "Dashboard Id", + "type": [ + "Number" + ] + }, + { + "name": "evalMatches", + "label": "Eval Matches", + "type": [ + "Array" + ] + }, + { + "name": "createdAt", + "label": "Created At", + "type": [ + "DateTime" + ] + }, + { + "name": "imageUrl", + "label": "Image Url", + "type": [ + "String" + ] + }, + { + "name": "message", + "label": "Message", + "type": [ + "String" + ] + }, + { + "name": "orgId", + "label": "Org Id", + "type": [ + "Number" + ] + }, + { + "name": "panelId", + "label": "Panel Id", + "type": [ + "String" + ] + }, + { + "name": "ruleId", + "label": "Rule Id", + "type": [ + "Number" + ] + }, + { + "name": "ruleName", + "label": "Rule Name", + "type": [ + "String" + ] + }, + { + "name": "ruleUrl", + "label": "Rule Url", + "type": [ + "String" + ] + }, + { + "name": "state", + "label": "State", + "type": [ + "String" + ] + }, + { + "name": "title", + "label": "Title", + "type": [ + "String" + ] + }, + { + "name": "tags", + "label": "Tags", + "type": [ + "Object" + ] + } + ] + } + }, + "storedMapping": { + "nodes": [ + { + "alertFieldName": "title", + "payloadAlertPaths": "title", + "fallbackAlertPaths": "ruleUrl" + }, + { + "alertFieldName": "description", + "payloadAlertPaths": "message" + }, + { + "alertFieldName": "hosts", + "payloadAlertPaths": "evalMatches" + }, + { + "alertFieldName": "startTime", + "payloadAlertPaths": "createdAt" + } + ] + } +} diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index 4220dbde0c7..e30dc2ad553 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -1,5 +1,6 @@ import { s__ } from '~/locale'; +// TODO: Remove this as part of the form old removal export const i18n = { usageSection: s__( 'AlertSettings|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.', @@ -17,11 +18,10 @@ export const i18n = { changesSaved: s__('AlertSettings|Your integration was successfully updated.'), prometheusInfo: s__('AlertSettings|Add URL and auth key to your Prometheus config file'), integrationsInfo: s__( - 'AlertSettings|Learn more about our improvements for %{linkStart}integrations%{linkEnd}', + 'AlertSettings|Learn more about our our upcoming %{linkStart}integrations%{linkEnd}', ), resetKey: s__('AlertSettings|Reset key'), copyToClipboard: s__('AlertSettings|Copy'), - integrationsLabel: s__('AlertSettings|Add new integrations'), apiBaseUrlLabel: s__('AlertSettings|API URL'), authKeyLabel: s__('AlertSettings|Authorization key'), urlLabel: s__('AlertSettings|Webhook URL'), @@ -40,12 +40,26 @@ export const i18n = { integration: s__('AlertSettings|Integration'), }; -export const serviceOptions = [ - { value: 'generic', text: s__('AlertSettings|HTTP Endpoint') }, - { value: 'prometheus', text: s__('AlertSettings|External Prometheus') }, - { value: 'opsgenie', text: s__('AlertSettings|Opsgenie') }, +// TODO: Delete as part of old form removal in 13.6 +export const integrationTypes = [ + { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') }, + { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') }, + { value: 'OPSGENIE', text: s__('AlertSettings|Opsgenie') }, ]; +export const integrationTypesNew = [ + { value: '', text: s__('AlertSettings|Select integration type') }, + ...integrationTypes, +]; + +export const typeSet = { + http: 'HTTP', + prometheus: 'PROMETHEUS', + opsgenie: 'OPSGENIE', +}; + +export const integrationToDeleteDefault = { id: null, name: '' }; + export const JSON_VALIDATE_DELAY = 250; export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/'; @@ -56,9 +70,9 @@ export const sectionHash = 'js-alert-management-settings'; /* eslint-disable @gitlab/require-i18n-strings */ /** - * Tracks snowplow event when user views alerts intergration list + * Tracks snowplow event when user views alerts integration list */ -export const trackAlertIntergrationsViewsOptions = { - category: 'Alert Intergrations', +export const trackAlertIntegrationsViewsOptions = { + category: 'Alert Integrations', action: 'view_alert_integrations_list', }; diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js new file mode 100644 index 00000000000..02c2def87fa --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql.js @@ -0,0 +1,44 @@ +import Vue from 'vue'; +import produce from 'immer'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import getCurrentIntegrationQuery from './graphql/queries/get_current_integration.query.graphql'; + +Vue.use(VueApollo); + +const resolvers = { + Mutation: { + updateCurrentIntegration: ( + _, + { id = null, name, active, token, type, url, apiUrl }, + { cache }, + ) => { + const sourceData = cache.readQuery({ query: getCurrentIntegrationQuery }); + const data = produce(sourceData, draftData => { + if (id === null) { + // eslint-disable-next-line no-param-reassign + draftData.currentIntegration = null; + } else { + // eslint-disable-next-line no-param-reassign + draftData.currentIntegration = { + id, + name, + active, + token, + type, + url, + apiUrl, + }; + } + }); + cache.writeQuery({ query: getCurrentIntegrationQuery, data }); + }, + }, +}; + +export default new VueApollo({ + defaultClient: createDefaultClient(resolvers, { + cacheConfig: {}, + assumeImmutableResults: true, + }), +}); diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql new file mode 100644 index 00000000000..6d9307959df --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/fragments/integration_item.fragment.graphql @@ -0,0 +1,9 @@ +fragment IntegrationItem on AlertManagementIntegration { + id + type + active + name + url + token + apiUrl +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql new file mode 100644 index 00000000000..d1dacbad40a --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) { + httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql new file mode 100644 index 00000000000..bb22795ddd5 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql @@ -0,0 +1,12 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation createPrometheusIntegration($projectPath: ID!, $apiUrl: String!, $active: Boolean!) { + prometheusIntegrationCreate( + input: { projectPath: $projectPath, apiUrl: $apiUrl, active: $active } + ) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql new file mode 100644 index 00000000000..0a49c140e6a --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation destroyHttpIntegration($id: ID!) { + httpIntegrationDestroy(input: { id: $id }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql new file mode 100644 index 00000000000..178d1e13047 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation resetHttpIntegrationToken($id: ID!) { + httpIntegrationResetToken(input: { id: $id }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql new file mode 100644 index 00000000000..8f34521b9fd --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation resetPrometheusIntegrationToken($id: ID!) { + prometheusIntegrationResetToken(input: { id: $id }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql new file mode 100644 index 00000000000..3505241309e --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql @@ -0,0 +1,19 @@ +mutation updateCurrentIntegration( + $id: String + $name: String + $active: Boolean + $token: String + $type: String + $url: String + $apiUrl: String +) { + updateCurrentIntegration( + id: $id + name: $name + active: $active + token: $token + type: $type + url: $url + apiUrl: $apiUrl + ) @client +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql new file mode 100644 index 00000000000..bb5b334deeb --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) { + httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql new file mode 100644 index 00000000000..62761730bd2 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/integration_item.fragment.graphql" + +mutation updatePrometheusIntegration($id: ID!, $apiUrl: String!, $active: Boolean!) { + prometheusIntegrationUpdate(input: { id: $id, apiUrl: $apiUrl, active: $active }) { + errors + integration { + ...IntegrationItem + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql new file mode 100644 index 00000000000..4f22849a618 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_current_integration.query.graphql @@ -0,0 +1,3 @@ +query currentIntegration { + currentIntegration @client +} diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql new file mode 100644 index 00000000000..228dd5fb176 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_integrations.query.graphql @@ -0,0 +1,11 @@ +#import "../fragments/integration_item.fragment.graphql" + +query getIntegrations($projectPath: ID!) { + project(fullPath: $projectPath) { + alertManagementIntegrations { + nodes { + ...IntegrationItem + } + } + } +} diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js index 8d1d342d229..41b19a675c5 100644 --- a/app/assets/javascripts/alerts_settings/index.js +++ b/app/assets/javascripts/alerts_settings/index.js @@ -1,6 +1,15 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import { parseBoolean } from '~/lib/utils/common_utils'; -import AlertSettingsForm from './components/alerts_settings_form.vue'; +import AlertSettingsWrapper from './components/alerts_settings_wrapper.vue'; +import apolloProvider from './graphql'; + +apolloProvider.clients.defaultClient.cache.writeData({ + data: { + currentIntegration: null, + }, +}); +Vue.use(GlToast); export default el => { if (!el) { @@ -24,20 +33,17 @@ export default el => { opsgenieMvcFormPath, opsgenieMvcEnabled, opsgenieMvcTargetUrl, + projectPath, + multiIntegrations, } = el.dataset; - const genericActivated = parseBoolean(activatedStr); - const prometheusIsActivated = parseBoolean(prometheusActivated); - const opsgenieMvcActivated = parseBoolean(opsgenieMvcEnabled); - const opsgenieMvcIsAvailable = parseBoolean(opsgenieMvcAvailable); - return new Vue({ el, provide: { prometheus: { - activated: prometheusIsActivated, - prometheusUrl, - authorizationKey: prometheusAuthorizationKey, + active: parseBoolean(prometheusActivated), + url: prometheusUrl, + token: prometheusAuthorizationKey, prometheusFormPath, prometheusResetKeyPath, prometheusApiUrl, @@ -45,23 +51,26 @@ export default el => { generic: { alertsSetupUrl, alertsUsageUrl, - activated: genericActivated, + active: parseBoolean(activatedStr), formPath, - authorizationKey, + token: authorizationKey, url, }, opsgenie: { formPath: opsgenieMvcFormPath, - activated: opsgenieMvcActivated, + active: parseBoolean(opsgenieMvcEnabled), opsgenieMvcTargetUrl, - opsgenieMvcIsAvailable, + opsgenieMvcIsAvailable: parseBoolean(opsgenieMvcAvailable), }, + projectPath, + multiIntegrations: parseBoolean(multiIntegrations), }, + apolloProvider, components: { - AlertSettingsForm, + AlertSettingsWrapper, }, render(createElement) { - return createElement('alert-settings-form'); + return createElement('alert-settings-wrapper'); }, }); }; diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js index c49992d4f57..1835d6b46aa 100644 --- a/app/assets/javascripts/alerts_settings/services/index.js +++ b/app/assets/javascripts/alerts_settings/services/index.js @@ -2,6 +2,7 @@ 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); }, @@ -25,11 +26,11 @@ export default { }, }); }, - updateTestAlert({ endpoint, data, authKey }) { + updateTestAlert({ endpoint, data, token }) { return axios.post(endpoint, data, { headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${authKey}`, + Authorization: `Bearer ${token}`, }, }); }, diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js new file mode 100644 index 00000000000..18054b29fe9 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js @@ -0,0 +1,84 @@ +import produce from 'immer'; +import createFlash from '~/flash'; + +import { DELETE_INTEGRATION_ERROR, ADD_INTEGRATION_ERROR } from './error_messages'; + +const deleteIntegrationFromStore = (store, query, { httpIntegrationDestroy }, variables) => { + const integration = httpIntegrationDestroy?.integration; + if (!integration) { + return; + } + + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, draftData => { + // eslint-disable-next-line no-param-reassign + draftData.project.alertManagementIntegrations.nodes = draftData.project.alertManagementIntegrations.nodes.filter( + ({ id }) => id !== integration.id, + ); + }); + + store.writeQuery({ + query, + variables, + data, + }); +}; + +const addIntegrationToStore = ( + store, + query, + { httpIntegrationCreate, prometheusIntegrationCreate }, + variables, +) => { + const integration = + httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration; + if (!integration) { + return; + } + + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, draftData => { + // eslint-disable-next-line no-param-reassign + draftData.project.alertManagementIntegrations.nodes = [ + integration, + ...draftData.project.alertManagementIntegrations.nodes, + ]; + }); + + store.writeQuery({ + query, + variables, + data, + }); +}; + +const onError = (data, message) => { + createFlash({ message }); + throw new Error(data.errors); +}; + +export const hasErrors = ({ errors = [] }) => errors?.length; + +export const updateStoreAfterIntegrationDelete = (store, query, data, variables) => { + if (hasErrors(data)) { + onError(data, DELETE_INTEGRATION_ERROR); + } else { + deleteIntegrationFromStore(store, query, data, variables); + } +}; + +export const updateStoreAfterIntegrationAdd = (store, query, data, variables) => { + if (hasErrors(data)) { + onError(data, ADD_INTEGRATION_ERROR); + } else { + addIntegrationToStore(store, query, data, variables); + } +}; diff --git a/app/assets/javascripts/alerts_settings/utils/error_messages.js b/app/assets/javascripts/alerts_settings/utils/error_messages.js new file mode 100644 index 00000000000..979d1ca3ccc --- /dev/null +++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js @@ -0,0 +1,21 @@ +import { s__ } from '~/locale'; + +export const DELETE_INTEGRATION_ERROR = s__( + 'AlertsIntegrations|The integration could not be deleted. Please try again.', +); + +export const ADD_INTEGRATION_ERROR = s__( + 'AlertsIntegrations|The integration could not be added. Please try again.', +); + +export const UPDATE_INTEGRATION_ERROR = s__( + 'AlertsIntegrations|The current integration could not be updated. Please try again.', +); + +export const RESET_INTEGRATION_TOKEN_ERROR = s__( + 'AlertsIntegrations|The integration token could not be reset. Please try again.', +); + +export const INTEGRATION_PAYLOAD_TEST_ERROR = s__( + 'AlertsIntegrations|Integration payload is invalid. You can still save your changes.', +); diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue index 7aa5c98aa0b..8df4d2e2524 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/app.vue +++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue @@ -1,19 +1,23 @@ <script> import InstanceCounts from './instance_counts.vue'; -import PipelinesChart from './pipelines_chart.vue'; +import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue'; import UsersChart from './users_chart.vue'; +import ProjectsAndGroupsChart from './projects_and_groups_chart.vue'; +import ChartsConfig from './charts_config'; import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; export default { name: 'InstanceStatisticsApp', components: { InstanceCounts, - PipelinesChart, + InstanceStatisticsCountChart, UsersChart, + ProjectsAndGroupsChart, }, TOTAL_DAYS_TO_SHOW, START_DATE, TODAY, + configs: ChartsConfig, }; </script> @@ -25,6 +29,20 @@ export default { :end-date="$options.TODAY" :total-data-points="$options.TOTAL_DAYS_TO_SHOW" /> - <pipelines-chart /> + <projects-and-groups-chart + :start-date="$options.START_DATE" + :end-date="$options.TODAY" + :total-data-points="$options.TOTAL_DAYS_TO_SHOW" + /> + <instance-statistics-count-chart + v-for="chartOptions in $options.configs" + :key="chartOptions.chartTitle" + :queries="chartOptions.queries" + :x-axis-title="chartOptions.xAxisTitle" + :y-axis-title="chartOptions.yAxisTitle" + :load-chart-error-message="chartOptions.loadChartError" + :no-data-message="chartOptions.noDataMessage" + :chart-title="chartOptions.chartTitle" + /> </div> </template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js new file mode 100644 index 00000000000..6fba3c56cfe --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js @@ -0,0 +1,87 @@ +import { s__, __, sprintf } from '~/locale'; +import query from '../graphql/queries/instance_count.query.graphql'; + +const noDataMessage = s__('InstanceStatistics|No data available.'); + +export default [ + { + loadChartError: sprintf( + s__( + 'InstanceStatistics|Could not load the pipelines chart. Please refresh the page to try again.', + ), + ), + noDataMessage, + chartTitle: s__('InstanceStatistics|Pipelines'), + yAxisTitle: s__('InstanceStatistics|Items'), + xAxisTitle: s__('InstanceStatistics|Month'), + queries: [ + { + query, + title: s__('InstanceStatistics|Pipelines total'), + identifier: 'PIPELINES', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the total pipelines'), + ), + }, + { + query, + title: s__('InstanceStatistics|Pipelines succeeded'), + identifier: 'PIPELINES_SUCCEEDED', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the successful pipelines'), + ), + }, + { + query, + title: s__('InstanceStatistics|Pipelines failed'), + identifier: 'PIPELINES_FAILED', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the failed pipelines'), + ), + }, + { + query, + title: s__('InstanceStatistics|Pipelines canceled'), + identifier: 'PIPELINES_CANCELED', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the cancelled pipelines'), + ), + }, + { + query, + title: s__('InstanceStatistics|Pipelines skipped'), + identifier: 'PIPELINES_SKIPPED', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the skipped pipelines'), + ), + }, + ], + }, + { + loadChartError: sprintf( + s__( + 'InstanceStatistics|Could not load the issues and merge requests chart. Please refresh the page to try again.', + ), + ), + noDataMessage, + chartTitle: s__('InstanceStatistics|Issues & Merge Requests'), + yAxisTitle: s__('InstanceStatistics|Items'), + xAxisTitle: s__('InstanceStatistics|Month'), + queries: [ + { + query, + title: __('Issues'), + identifier: 'ISSUES', + loadError: sprintf(s__('InstanceStatistics|There was an error fetching the issues')), + }, + { + query, + title: __('Merge requests'), + identifier: 'MERGE_REQUESTS', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the merge requests'), + ), + }, + ], + }, +]; diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue new file mode 100644 index 00000000000..a9bd1bb2f41 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue @@ -0,0 +1,206 @@ +<script> +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import { some, every } from 'lodash'; +import * as Sentry from '~/sentry/wrapper'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { + differenceInMonths, + formatDateAsMonth, + getDayDifference, +} from '~/lib/utils/datetime_utility'; +import { getAverageByMonth, getEarliestDate, generateDataKeys } from '../utils'; +import { TODAY, START_DATE } from '../constants'; + +const QUERY_DATA_KEY = 'instanceStatisticsMeasurements'; + +export default { + name: 'InstanceStatisticsCountChart', + components: { + GlLineChart, + GlAlert, + ChartSkeletonLoader, + }, + startDate: START_DATE, + endDate: TODAY, + props: { + chartTitle: { + type: String, + required: true, + }, + loadChartErrorMessage: { + type: String, + required: true, + }, + noDataMessage: { + type: String, + required: true, + }, + xAxisTitle: { + type: String, + required: true, + }, + yAxisTitle: { + type: String, + required: true, + }, + queries: { + type: Array, + required: true, + }, + }, + data() { + return { + errors: { ...generateDataKeys(this.queries, '') }, + ...generateDataKeys(this.queries, []), + }; + }, + computed: { + errorMessages() { + return Object.values(this.errors); + }, + isLoading() { + return some(this.$apollo.queries, query => query?.loading); + }, + allQueriesFailed() { + return every(this.errorMessages, message => message.length); + }, + hasLoadingErrors() { + return some(this.errorMessages, message => message.length); + }, + errorMessage() { + // show the generic loading message if all requests fail + return this.allQueriesFailed ? this.loadChartErrorMessage : this.errorMessages.join('\n\n'); + }, + hasEmptyDataSet() { + return this.chartData.every(({ data }) => data.length === 0); + }, + totalDaysToShow() { + return getDayDifference(this.$options.startDate, this.$options.endDate); + }, + chartData() { + const options = { shouldRound: true }; + return this.queries.map(({ identifier, title }) => ({ + name: title, + data: getAverageByMonth(this[identifier]?.nodes, options), + })); + }, + range() { + return { + min: this.$options.startDate, + max: this.$options.endDate, + }; + }, + chartOptions() { + const { endDate, startDate } = this.$options; + return { + xAxis: { + ...this.range, + name: this.xAxisTitle, + type: 'time', + splitNumber: differenceInMonths(startDate, endDate) + 1, + axisLabel: { + interval: 0, + showMinLabel: false, + showMaxLabel: false, + align: 'right', + formatter: formatDateAsMonth, + }, + }, + yAxis: { + name: this.yAxisTitle, + }, + }; + }, + }, + created() { + this.queries.forEach(({ query, identifier, loadError }) => { + this.$apollo.addSmartQuery(identifier, { + query, + variables() { + return { + identifier, + first: this.totalDaysToShow, + after: null, + }; + }, + update(data) { + const { nodes = [], pageInfo } = data[QUERY_DATA_KEY] || {}; + return { + nodes, + pageInfo, + }; + }, + result() { + const { pageInfo, nodes } = this[identifier]; + if (pageInfo?.hasNextPage && this.calculateDaysToFetch(getEarliestDate(nodes)) > 0) { + this.fetchNextPage({ + query: this.$apollo.queries[identifier], + errorMessage: loadError, + pageInfo, + identifier, + }); + } + }, + error(error) { + this.handleError({ + message: loadError, + identifier, + error, + }); + }, + }); + }); + }, + methods: { + calculateDaysToFetch(firstDataPointDate = null) { + return firstDataPointDate + ? Math.max(0, getDayDifference(this.$options.startDate, new Date(firstDataPointDate))) + : 0; + }, + handleError({ identifier, error, message }) { + this.loadingError = true; + this.errors = { ...this.errors, [identifier]: message }; + Sentry.captureException(error); + }, + fetchNextPage({ query, pageInfo, identifier, errorMessage }) { + query + .fetchMore({ + variables: { + identifier, + first: this.calculateDaysToFetch(getEarliestDate(this[identifier].nodes)), + after: pageInfo.endCursor, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const { nodes, ...rest } = fetchMoreResult[QUERY_DATA_KEY]; + const { nodes: previousNodes } = previousResult[QUERY_DATA_KEY]; + return { + [QUERY_DATA_KEY]: { ...rest, nodes: [...previousNodes, ...nodes] }, + }; + }, + }) + .catch(error => this.handleError({ identifier, error, message: errorMessage })); + }, + }, +}; +</script> +<template> + <div> + <h3>{{ chartTitle }}</h3> + <gl-alert v-if="hasLoadingErrors" variant="danger" :dismissible="false" class="gl-mt-3"> + {{ errorMessage }} + </gl-alert> + <div v-if="!allQueriesFailed"> + <chart-skeleton-loader v-if="isLoading" /> + <gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3"> + {{ noDataMessage }} + </gl-alert> + <gl-line-chart + v-else + :option="chartOptions" + :include-legend-avg-max="true" + :data="chartData" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue deleted file mode 100644 index b16d960402b..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue +++ /dev/null @@ -1,215 +0,0 @@ -<script> -import { GlLineChart } from '@gitlab/ui/dist/charts'; -import { GlAlert } from '@gitlab/ui'; -import { mapKeys, mapValues, pick, some, sum } from 'lodash'; -import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; -import { s__ } from '~/locale'; -import { - differenceInMonths, - formatDateAsMonth, - getDayDifference, -} from '~/lib/utils/datetime_utility'; -import { getAverageByMonth, sortByDate, extractValues } from '../utils'; -import pipelineStatsQuery from '../graphql/queries/pipeline_stats.query.graphql'; -import { TODAY, START_DATE } from '../constants'; - -const DATA_KEYS = [ - 'pipelinesTotal', - 'pipelinesSucceeded', - 'pipelinesFailed', - 'pipelinesCanceled', - 'pipelinesSkipped', -]; -const PREFIX = 'pipelines'; - -export default { - name: 'PipelinesChart', - components: { - GlLineChart, - GlAlert, - ChartSkeletonLoader, - }, - startDate: START_DATE, - endDate: TODAY, - i18n: { - loadPipelineChartError: s__( - 'InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again.', - ), - noDataMessage: s__('InstanceAnalytics|There is no data available.'), - total: s__('InstanceAnalytics|Total'), - succeeded: s__('InstanceAnalytics|Succeeded'), - failed: s__('InstanceAnalytics|Failed'), - canceled: s__('InstanceAnalytics|Canceled'), - skipped: s__('InstanceAnalytics|Skipped'), - chartTitle: s__('InstanceAnalytics|Pipelines'), - yAxisTitle: s__('InstanceAnalytics|Items'), - xAxisTitle: s__('InstanceAnalytics|Month'), - }, - data() { - return { - loading: true, - loadingError: null, - }; - }, - apollo: { - pipelineStats: { - query: pipelineStatsQuery, - variables() { - return { - firstTotal: this.totalDaysToShow, - firstSucceeded: this.totalDaysToShow, - firstFailed: this.totalDaysToShow, - firstCanceled: this.totalDaysToShow, - firstSkipped: this.totalDaysToShow, - }; - }, - update(data) { - const allData = extractValues(data, DATA_KEYS, PREFIX, 'nodes'); - const allPageInfo = extractValues(data, DATA_KEYS, PREFIX, 'pageInfo'); - - return { - ...mapValues(allData, sortByDate), - ...allPageInfo, - }; - }, - result() { - if (this.hasNextPage) { - this.fetchNextPage(); - } - }, - error() { - this.handleError(); - }, - }, - }, - computed: { - isLoading() { - return this.$apollo.queries.pipelineStats.loading; - }, - totalDaysToShow() { - return getDayDifference(this.$options.startDate, this.$options.endDate); - }, - firstVariables() { - const allData = pick(this.pipelineStats, [ - 'nodesTotal', - 'nodesSucceeded', - 'nodesFailed', - 'nodesCanceled', - 'nodesSkipped', - ]); - const allDayDiffs = mapValues(allData, data => { - const firstdataPoint = data[0]; - if (!firstdataPoint) { - return 0; - } - - return Math.max( - 0, - getDayDifference(this.$options.startDate, new Date(firstdataPoint.recordedAt)), - ); - }); - - return mapKeys(allDayDiffs, (value, key) => key.replace('nodes', 'first')); - }, - cursorVariables() { - const pageInfoKeys = [ - 'pageInfoTotal', - 'pageInfoSucceeded', - 'pageInfoFailed', - 'pageInfoCanceled', - 'pageInfoSkipped', - ]; - - return extractValues(this.pipelineStats, pageInfoKeys, 'pageInfo', 'endCursor'); - }, - hasNextPage() { - return ( - sum(Object.values(this.firstVariables)) > 0 && - some(this.pipelineStats, ({ hasNextPage }) => hasNextPage) - ); - }, - hasEmptyDataSet() { - return this.chartData.every(({ data }) => data.length === 0); - }, - chartData() { - const allData = pick(this.pipelineStats, [ - 'nodesTotal', - 'nodesSucceeded', - 'nodesFailed', - 'nodesCanceled', - 'nodesSkipped', - ]); - const options = { shouldRound: true }; - return Object.keys(allData).map(key => { - const i18nName = key.slice('nodes'.length).toLowerCase(); - return { - name: this.$options.i18n[i18nName], - data: getAverageByMonth(allData[key], options), - }; - }); - }, - range() { - return { - min: this.$options.startDate, - max: this.$options.endDate, - }; - }, - chartOptions() { - const { endDate, startDate, i18n } = this.$options; - return { - xAxis: { - ...this.range, - name: i18n.xAxisTitle, - type: 'time', - splitNumber: differenceInMonths(startDate, endDate) + 1, - axisLabel: { - interval: 0, - showMinLabel: false, - showMaxLabel: false, - align: 'right', - formatter: formatDateAsMonth, - }, - }, - yAxis: { - name: i18n.yAxisTitle, - }, - }; - }, - }, - methods: { - handleError() { - this.loadingError = true; - }, - fetchNextPage() { - this.$apollo.queries.pipelineStats - .fetchMore({ - variables: { - ...this.firstVariables, - ...this.cursorVariables, - }, - updateQuery: (previousResult, { fetchMoreResult }) => { - return Object.keys(fetchMoreResult).reduce((memo, key) => { - const { nodes, ...rest } = fetchMoreResult[key]; - const previousNodes = previousResult[key].nodes; - return { ...memo, [key]: { ...rest, nodes: [...previousNodes, ...nodes] } }; - }, {}); - }, - }) - .catch(this.handleError); - }, - }, -}; -</script> -<template> - <div> - <h3>{{ $options.i18n.chartTitle }}</h3> - <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3"> - {{ this.$options.i18n.loadPipelineChartError }} - </gl-alert> - <chart-skeleton-loader v-else-if="isLoading" /> - <gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3"> - {{ $options.i18n.noDataMessage }} - </gl-alert> - <gl-line-chart v-else :option="chartOptions" :include-legend-avg-max="true" :data="chartData" /> - </div> -</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue new file mode 100644 index 00000000000..e8e35c22fe1 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue @@ -0,0 +1,224 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import produce from 'immer'; +import { sortBy } from 'lodash'; +import * as Sentry from '~/sentry/wrapper'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { s__, __ } from '~/locale'; +import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; +import latestGroupsQuery from '../graphql/queries/groups.query.graphql'; +import latestProjectsQuery from '../graphql/queries/projects.query.graphql'; +import { getAverageByMonth } from '../utils'; + +const sortByDate = data => sortBy(data, item => new Date(item[0]).getTime()); + +const averageAndSortData = (data = [], maxDataPoints) => { + const averaged = getAverageByMonth( + data.length > maxDataPoints ? data.slice(0, maxDataPoints) : data, + { shouldRound: true }, + ); + return sortByDate(averaged); +}; + +export default { + name: 'ProjectsAndGroupsChart', + components: { GlAlert, GlLineChart, ChartSkeletonLoader }, + props: { + startDate: { + type: Date, + required: true, + }, + endDate: { + type: Date, + required: true, + }, + totalDataPoints: { + type: Number, + required: true, + }, + }, + data() { + return { + loadingError: false, + errorMessage: '', + groups: [], + projects: [], + groupsPageInfo: null, + projectsPageInfo: null, + }; + }, + apollo: { + groups: { + query: latestGroupsQuery, + variables() { + return { + first: this.totalDataPoints, + after: null, + }; + }, + update(data) { + return data.groups?.nodes || []; + }, + result({ data }) { + const { + groups: { pageInfo }, + } = data; + this.groupsPageInfo = pageInfo; + this.fetchNextPage({ + query: this.$apollo.queries.groups, + pageInfo: this.groupsPageInfo, + dataKey: 'groups', + errorMessage: this.$options.i18n.loadGroupsDataError, + }); + }, + error(error) { + this.handleError({ + message: this.$options.i18n.loadGroupsDataError, + error, + dataKey: 'groups', + }); + }, + }, + projects: { + query: latestProjectsQuery, + variables() { + return { + first: this.totalDataPoints, + after: null, + }; + }, + update(data) { + return data.projects?.nodes || []; + }, + result({ data }) { + const { + projects: { pageInfo }, + } = data; + this.projectsPageInfo = pageInfo; + this.fetchNextPage({ + query: this.$apollo.queries.projects, + pageInfo: this.projectsPageInfo, + dataKey: 'projects', + errorMessage: this.$options.i18n.loadProjectsDataError, + }); + }, + error(error) { + this.handleError({ + message: this.$options.i18n.loadProjectsDataError, + error, + dataKey: 'projects', + }); + }, + }, + }, + i18n: { + yAxisTitle: s__('InstanceStatistics|Total projects & groups'), + xAxisTitle: __('Month'), + loadChartError: s__( + 'InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again.', + ), + loadProjectsDataError: s__('InstanceStatistics|There was an error while loading the projects'), + loadGroupsDataError: s__('InstanceStatistics|There was an error while loading the groups'), + noDataMessage: s__('InstanceStatistics|No data available.'), + }, + computed: { + isLoadingGroups() { + return this.$apollo.queries.groups.loading || this.groupsPageInfo?.hasNextPage; + }, + isLoadingProjects() { + return this.$apollo.queries.projects.loading || this.projectsPageInfo?.hasNextPage; + }, + isLoading() { + return this.isLoadingProjects && this.isLoadingGroups; + }, + groupChartData() { + return averageAndSortData(this.groups, this.totalDataPoints); + }, + projectChartData() { + return averageAndSortData(this.projects, this.totalDataPoints); + }, + hasNoData() { + const { projectChartData, groupChartData } = this; + return Boolean(!projectChartData.length && !groupChartData.length); + }, + options() { + return { + xAxis: { + name: this.$options.i18n.xAxisTitle, + type: 'category', + axisLabel: { + formatter: value => { + return formatDateAsMonth(value); + }, + }, + }, + yAxis: { + name: this.$options.i18n.yAxisTitle, + }, + }; + }, + chartData() { + return [ + { + name: s__('InstanceStatistics|Total projects'), + data: this.projectChartData, + }, + { + name: s__('InstanceStatistics|Total groups'), + data: this.groupChartData, + }, + ]; + }, + }, + methods: { + handleError({ error, message = this.$options.i18n.loadChartError, dataKey = null }) { + this.loadingError = true; + this.errorMessage = message; + if (!dataKey) { + this.projects = []; + this.groups = []; + } else { + this[dataKey] = []; + } + Sentry.captureException(error); + }, + fetchNextPage({ pageInfo, query, dataKey, errorMessage }) { + if (pageInfo?.hasNextPage) { + query + .fetchMore({ + variables: { first: this.totalDataPoints, after: pageInfo.endCursor }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const results = produce(fetchMoreResult, newData => { + // eslint-disable-next-line no-param-reassign + newData[dataKey].nodes = [ + ...previousResult[dataKey].nodes, + ...newData[dataKey].nodes, + ]; + }); + return results; + }, + }) + .catch(error => { + this.handleError({ error, message: errorMessage, dataKey }); + }); + } + }, + }, +}; +</script> +<template> + <div> + <h3>{{ $options.i18n.yAxisTitle }}</h3> + <chart-skeleton-loader v-if="isLoading" /> + <gl-alert v-else-if="hasNoData" variant="info" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.noDataMessage }} + </gl-alert> + <div v-else> + <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">{{ + errorMessage + }}</gl-alert> + <gl-line-chart :option="options" :include-legend-avg-max="true" :data="chartData" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql new file mode 100644 index 00000000000..ec56d91ffaa --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getGroupsCount($first: Int, $after: String) { + groups: instanceStatisticsMeasurements(identifier: GROUPS, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql new file mode 100644 index 00000000000..dd22a16cd51 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) { + instanceStatisticsMeasurements(identifier: $identifier, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql deleted file mode 100644 index 3bf40403f91..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql +++ /dev/null @@ -1,76 +0,0 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" -#import "./count.fragment.graphql" - -query pipelineStats( - $firstTotal: Int - $firstSucceeded: Int - $firstFailed: Int - $firstCanceled: Int - $firstSkipped: Int - $endCursorTotal: String - $endCursorSucceeded: String - $endCursorFailed: String - $endCursorCanceled: String - $endCursorSkipped: String -) { - pipelinesTotal: instanceStatisticsMeasurements( - identifier: PIPELINES - first: $firstTotal - after: $endCursorTotal - ) { - nodes { - ...Count - } - pageInfo { - ...PageInfo - } - } - pipelinesSucceeded: instanceStatisticsMeasurements( - identifier: PIPELINES_SUCCEEDED - first: $firstSucceeded - after: $endCursorSucceeded - ) { - nodes { - ...Count - } - pageInfo { - ...PageInfo - } - } - pipelinesFailed: instanceStatisticsMeasurements( - identifier: PIPELINES_FAILED - first: $firstFailed - after: $endCursorFailed - ) { - nodes { - ...Count - } - pageInfo { - ...PageInfo - } - } - pipelinesCanceled: instanceStatisticsMeasurements( - identifier: PIPELINES_CANCELED - first: $firstCanceled - after: $endCursorCanceled - ) { - nodes { - ...Count - } - pageInfo { - ...PageInfo - } - } - pipelinesSkipped: instanceStatisticsMeasurements( - identifier: PIPELINES_SKIPPED - first: $firstSkipped - after: $endCursorSkipped - ) { - nodes { - ...Count - } - pageInfo { - ...PageInfo - } - } -} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql new file mode 100644 index 00000000000..0845b703435 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getProjectsCount($first: Int, $after: String) { + projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/utils.js b/app/assets/javascripts/analytics/instance_statistics/utils.js index 907482c0c72..e1fa5d155a2 100644 --- a/app/assets/javascripts/analytics/instance_statistics/utils.js +++ b/app/assets/javascripts/analytics/instance_statistics/utils.js @@ -1,5 +1,5 @@ import { masks } from 'dateformat'; -import { mapKeys, mapValues, pick, sortBy } from 'lodash'; +import { get } from 'lodash'; import { formatDate } from '~/lib/utils/datetime_utility'; const { isoDate } = masks; @@ -41,29 +41,28 @@ export function getAverageByMonth(items = [], options = {}) { } /** - * Extracts values given a data set and a set of keys - * @example - * const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' }; - * extractValues(data, ['fooBar'], 'foo', 'baz') => { bazBar: 'quis' } - * @param {Object} data set to extract values from - * @param {Array} dataKeys keys describing where to look for values in the data set - * @param {String} replaceKey name key to be replaced in the data set - * @param {String} nestedKey key nested in the data set to be extracted, - * this is also used to rename the newly created data set - * @return {Object} the newly created data set with the extracted values + * Takes an array of instance counts and returns the last item in the list + * @param {Array} arr array of instance counts in the form { count: Number, recordedAt: date String } + * @return {String} the 'recordedAt' value of the earliest item */ -export function extractValues(data, dataKeys = [], replaceKey, nestedKey) { - return mapKeys(pick(mapValues(data, nestedKey), dataKeys), (value, key) => - key.replace(replaceKey, nestedKey), - ); -} +export const getEarliestDate = (arr = []) => { + const len = arr.length; + return get(arr, `[${len - 1}].recordedAt`, null); +}; /** - * Creates a new array of items sorted by the date string of each item - * @param {Array} items [description] - * @param {String} items[0] date string - * @return {Array} the new sorted array. + * Takes an array of queries and produces an object with the query identifier as key + * and a supplied defaultValue as its value + * @param {Array} queries array of chart query configs, + * see ./analytics/instance_statistics/components/charts_config.js + * @param {any} defaultValue value to set each identifier to + * @return {Object} key value pair of the form { queryIdentifier: defaultValue } */ -export function sortByDate(items = []) { - return sortBy(items, ({ recordedAt }) => new Date(recordedAt).getTime()); -} +export const generateDataKeys = (queries, defaultValue) => + queries.reduce( + (acc, { identifier }) => ({ + ...acc, + [identifier]: defaultValue, + }), + {}, + ); diff --git a/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue b/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue index a475ff8fd25..2be9ebda87a 100644 --- a/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue +++ b/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue @@ -17,10 +17,13 @@ export default { }, }, computed: { - seriesData() { - return { - full: this.formattedData.keys.map((val, idx) => [val, this.formattedData.values[idx]]), - }; + barSeriesData() { + return [ + { + name: 'full', + data: this.formattedData.keys.map((val, idx) => [val, this.formattedData.values[idx]]), + }, + ]; }, }, }; @@ -30,7 +33,7 @@ export default { <div class="gl-xs-w-full"> <gl-column-chart v-if="formattedData.keys" - :data="seriesData" + :bars="barSeriesData" :x-axis-title="__('Value')" :y-axis-title="__('Number of events')" :x-axis-type="'category'" diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue index cee186c057c..e6e12821bec 100644 --- a/app/assets/javascripts/analytics/shared/components/metric_card.vue +++ b/app/assets/javascripts/analytics/shared/components/metric_card.vue @@ -43,7 +43,7 @@ export default { }; </script> <template> - <gl-card> + <gl-card class="gl-mb-5"> <template #header> <strong ref="title">{{ title }}</strong> </template> diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 63b75cdb734..f469f49ce20 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -6,6 +6,7 @@ import { __ } from '~/locale'; const DEFAULT_PER_PAGE = 20; const Api = { + DEFAULT_PER_PAGE, groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', groupMembersPath: '/api/:version/groups/:id/members', @@ -22,6 +23,7 @@ const Api = { projectLabelsPath: '/:namespace_path/:project_path/-/labels', projectFileSchemaPath: '/:namespace_path/:project_path/-/schema/:ref/:filename', projectUsersPath: '/api/:version/projects/:id/users', + projectMembersPath: '/api/:version/projects/:id/members', projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests', projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', @@ -34,6 +36,7 @@ const Api = { mergeRequestsPath: '/api/:version/merge_requests', groupLabelsPath: '/groups/:namespace_path/-/labels', issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key', + issuableTemplatesPath: '/:namespace_path/:project_path/templates/:type', projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key', projectTemplatesPath: '/api/:version/projects/:id/templates/:type', userCountsPath: '/api/:version/user_counts', @@ -70,6 +73,7 @@ const Api = { featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists', featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid', billableGroupMembersPath: '/api/:version/groups/:id/billable_members', + containerRegistryDetailsPath: '/api/:version/registry/repositories/:id/', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -106,6 +110,11 @@ const Api = { return axios.delete(url); }, + containerRegistryDetails(registryId, options = {}) { + const url = Api.buildUrl(this.containerRegistryDetailsPath).replace(':id', registryId); + return axios.get(url, options); + }, + groupMembers(id, options) { const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); @@ -207,6 +216,12 @@ const Api = { .then(({ data }) => data); }, + inviteProjectMembers(id, data) { + const url = Api.buildUrl(this.projectMembersPath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, data); + }, + // Return single project project(projectPath) { const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath)); @@ -454,17 +469,38 @@ const Api = { }, issueTemplate(namespacePath, projectPath, key, type, callback) { - const url = Api.buildUrl(Api.issuableTemplatePath) - .replace(':key', encodeURIComponent(key)) - .replace(':type', type) - .replace(':project_path', projectPath) - .replace(':namespace_path', namespacePath); + const url = this.buildIssueTemplateUrl( + Api.issuableTemplatePath, + type, + projectPath, + namespacePath, + ).replace(':key', encodeURIComponent(key)); + return axios + .get(url) + .then(({ data }) => callback(null, data)) + .catch(callback); + }, + + issueTemplates(namespacePath, projectPath, type, callback) { + const url = this.buildIssueTemplateUrl( + Api.issuableTemplatesPath, + type, + projectPath, + namespacePath, + ); return axios .get(url) .then(({ data }) => callback(null, data)) .catch(callback); }, + buildIssueTemplateUrl(path, type, projectPath, namespacePath) { + return Api.buildUrl(path) + .replace(':type', type) + .replace(':project_path', projectPath) + .replace(':namespace_path', namespacePath); + }, + users(query, options) { const url = Api.buildUrl(this.usersPath); return axios.get(url, { @@ -530,12 +566,13 @@ const Api = { }); }, - postUserStatus({ emoji, message }) { + postUserStatus({ emoji, message, availability }) { const url = Api.buildUrl(this.userPostStatusPath); return axios.put(url, { emoji, message, + availability, }); }, @@ -610,12 +647,12 @@ const Api = { return axios.get(url); }, - pipelineJobs(projectId, pipelineId) { + pipelineJobs(projectId, pipelineId, params) { const url = Api.buildUrl(this.pipelineJobsPath) .replace(':id', encodeURIComponent(projectId)) .replace(':pipeline_id', encodeURIComponent(pipelineId)); - return axios.get(url); + return axios.get(url, { params }); }, // Return all pipelines for a project or filter by query params @@ -737,6 +774,12 @@ const Api = { return axios.get(url, { params: { page } }); }, + searchFeatureFlagUserLists(id, search) { + const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id); + + return axios.get(url, { params: { search } }); + }, + createFeatureFlagUserList(id, list) { const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 7055cd42978..17e6255700a 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -5,11 +5,11 @@ import { uniq } from 'lodash'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import { __ } from './locale'; -import { updateTooltipTitle } from './lib/utils/common_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import * as Emoji from '~/emoji'; +import { dispose, fixTitle } from '~/tooltips'; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; @@ -374,7 +374,7 @@ export class AwardsHandler { counter.text(counterNumber - 1); this.removeYouFromUserList($emojiButton); } else if (emoji === 'thumbsup' || emoji === 'thumbsdown') { - $emojiButton.tooltip('dispose'); + dispose($emojiButton); counter.text('0'); this.removeYouFromUserList($emojiButton); if ($emojiButton.parents('.note').length) { @@ -387,7 +387,8 @@ export class AwardsHandler { } removeEmoji($emojiButton) { - $emojiButton.tooltip('dispose'); + dispose($emojiButton); + $emojiButton.remove(); const $votesBlock = this.getVotesBlock(); if ($votesBlock.find('.js-emoji-btn').length === 0) { @@ -415,13 +416,17 @@ export class AwardsHandler { const originalTitle = this.getAwardTooltip(awardBlock); const authors = originalTitle.split(FROM_SENTENCE_REGEX); authors.splice(authors.indexOf('You'), 1); - return awardBlock + + awardBlock .closest('.js-emoji-btn') .removeData('title') .removeAttr('data-title') .removeAttr('data-original-title') - .attr('title', this.toSentence(authors)) - .tooltip('_fixTitle'); + .attr('title', this.toSentence(authors)); + + fixTitle(awardBlock); + + return awardBlock; } addYouToUserList(votesBlock, emoji) { @@ -432,7 +437,12 @@ export class AwardsHandler { users = origTitle.trim().split(FROM_SENTENCE_REGEX); } users.unshift('You'); - return awardBlock.attr('title', this.toSentence(users)).tooltip('_fixTitle'); + + awardBlock.attr('title', this.toSentence(users)); + + fixTitle(awardBlock); + + return awardBlock; } createAwardButtonForVotesBlock(votesBlock, emojiName) { @@ -448,7 +458,7 @@ export class AwardsHandler { .find('.emoji-icon') .data('name', emojiName); this.animateEmoji($emojiButton); - $('.award-control').tooltip(); + votesBlock.removeClass('current'); } @@ -487,17 +497,6 @@ export class AwardsHandler { return votesBlock.find(`.js-emoji-btn [data-name="${emoji}"]`); } - userAuthored($emojiButton) { - const oldTitle = this.getAwardTooltip($emojiButton); - const newTitle = 'You cannot vote on your own issue, MR and note'; - updateTooltipTitle($emojiButton, newTitle).tooltip('show'); - // Restore tooltip back to award list - return setTimeout(() => { - $emojiButton.tooltip('hide'); - updateTooltipTitle($emojiButton, oldTitle); - }, 2800); - } - scrollToAwards() { const options = { scrollTop: $('.awards').offset().top - 110, diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index 3dd05f73841..0b8c6aff219 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlTooltipDirective, GlIcon, GlButton } from '@gitlab/ui'; export default { // name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25 @@ -8,6 +8,7 @@ export default { components: { GlIcon, GlLoadingIcon, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -90,15 +91,16 @@ export default { </div> </div> - <button + <gl-button v-show="hasError" v-gl-tooltip.hover :title="s__('Badges|Reload badge image')" - class="btn btn-transparent btn-sm text-primary" + category="tertiary" + variant="success" type="button" + icon="retry" + size="small" @click="reloadImage" - > - <gl-icon :size="16" name="retry" /> - </button> + /> </div> </template> diff --git a/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue deleted file mode 100644 index 385725cd109..00000000000 --- a/app/assets/javascripts/batch_comments/components/inline_draft_comment_row.vue +++ /dev/null @@ -1,32 +0,0 @@ -<script> -import DraftNote from './draft_note.vue'; - -export default { - components: { - DraftNote, - }, - props: { - draft: { - type: Object, - required: true, - }, - diffFile: { - type: Object, - required: true, - }, - line: { - type: Object, - required: false, - default: null, - }, - }, -}; -</script> - -<template> - <tr class="notes_holder js-temp-notes-holder"> - <td class="notes-content" colspan="4"> - <div class="content"><draft-note :draft="draft" :diff-file="diffFile" :line="line" /></div> - </td> - </tr> -</template> diff --git a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue b/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue deleted file mode 100644 index b0916623cd2..00000000000 --- a/app/assets/javascripts/batch_comments/components/parallel_draft_comment_row.vue +++ /dev/null @@ -1,49 +0,0 @@ -<script> -import { mapGetters } from 'vuex'; -import DraftNote from './draft_note.vue'; - -export default { - components: { - DraftNote, - }, - props: { - line: { - type: Object, - required: true, - }, - diffFileContentSha: { - type: String, - required: true, - }, - }, - computed: { - ...mapGetters('batchComments', ['draftForLine']), - className() { - return this.leftDraft > 0 || this.rightDraft > 0 ? '' : 'js-temp-notes-holder'; - }, - leftDraft() { - return this.draftForLine(this.diffFileContentSha, this.line, 'left'); - }, - rightDraft() { - return this.draftForLine(this.diffFileContentSha, this.line, 'right'); - }, - }, -}; -</script> - -<template> - <tr :class="className" class="notes_holder"> - <td class="notes_line old"></td> - <td class="notes-content parallel old" colspan="2"> - <div v-if="leftDraft.isDraft" class="content"> - <draft-note :draft="leftDraft" :line="line.left" /> - </div> - </td> - <td class="notes_line new"></td> - <td class="notes-content parallel new" colspan="2"> - <div v-if="rightDraft.isDraft" class="content"> - <draft-note :draft="rightDraft" :line="line.right" /> - </div> - </td> - </tr> -</template> diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js index 2517fb198f0..0b085da1ff9 100644 --- a/app/assets/javascripts/batch_comments/mixins/resolved_status.js +++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js @@ -1,7 +1,9 @@ import { mapGetters } from 'vuex'; import { sprintf, s__, __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { + mixins: [glFeatureFlagsMixin()], props: { discussionId: { type: String, @@ -54,6 +56,10 @@ export default { let title = __('Mark as resolved'); + if (this.glFeatures.removeResolveNote) { + title = __('Resolve thread'); + } + if (this.resolvedBy) { title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name }); } diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index 430a8c38387..e822072d669 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -79,3 +79,33 @@ export default function initCopyToClipboard() { clipboardData.setData('text/x-gfm', json.gfm); }); } + +/** + * Programmatically triggers a click event on a + * "copy to clipboard" button, causing its + * contents to be copied. Handles some of the messiniess + * around managing the button's tooltip. + * @param {HTMLElement} btnElement + */ +export function clickCopyToClipboardButton(btnElement) { + const $btnElement = $(btnElement); + + // Ensure the button has already been tooltip'd. + // If the use hasn't yet interacted (i.e. hovered or clicked) + // with the button, Bootstrap hasn't yet initialized + // the tooltip, and its `data-original-title` will be `undefined`. + // This value is used in the functions above. + $btnElement.tooltip(); + btnElement.dispatchEvent(new MouseEvent('mouseover')); + + btnElement.click(); + + // Manually trigger the necessary events to hide the + // button's tooltip and allow the button to perform its + // tooltip cleanup (updating the title from "Copied" back + // to its original title, "Copy branch name"). + setTimeout(() => { + btnElement.dispatchEvent(new MouseEvent('mouseout')); + $btnElement.tooltip('hide'); + }, 2000); +} diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js deleted file mode 100644 index 9bdfc21c7e4..00000000000 --- a/app/assets/javascripts/behaviors/details_behavior.js +++ /dev/null @@ -1,28 +0,0 @@ -import $ from 'jquery'; - -$(() => { - $('body').on('click', '.js-details-target', function target() { - $(this) - .closest('.js-details-container') - .toggleClass('open'); - }); - - // Show details content. Hides link after click. - // - // %div - // %a.js-details-expand - // %div.js-details-content - // - $('body').on('click', '.js-details-expand', function expand(e) { - e.preventDefault(); - $(this) - .next('.js-details-content') - .removeClass('hide'); - $(this).hide(); - - const truncatedItem = $(this).siblings('.js-details-short'); - if (truncatedItem.length) { - truncatedItem.addClass('hide'); - } - }); -}); diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index 613309a1c5a..75659bbf685 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -4,7 +4,6 @@ import './bind_in_out'; import './markdown/render_gfm'; import initCopyAsGFM from './markdown/copy_as_gfm'; import initCopyToClipboard from './copy_to_clipboard'; -import './details_behavior'; import installGlEmojiElement from './gl_emoji'; import './quick_submit'; import './requires_input'; diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js index 83f2ca0bdc2..d712c90242c 100644 --- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js +++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js @@ -14,6 +14,7 @@ export default function initGFMInput($els) { milestones: enableGFM, mergeRequests: enableGFM, labels: enableGFM, + vulnerabilities: enableGFM, }); }); } diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index cb0e6345059..233c5f84340 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -25,7 +25,7 @@ function importMermaidModule() { return import(/* webpackChunkName: 'mermaid' */ 'mermaid') .then(mermaid => { let theme = 'neutral'; - const ideDarkThemes = ['dark', 'solarized-dark']; + const ideDarkThemes = ['dark', 'solarized-dark', 'monokai']; if ( ideDarkThemes.includes(window.gon?.user_color_scheme) && diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index 49eab3e4f09..907cfc06e28 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import '../commons/bootstrap'; import { isInIssuePage } from '../lib/utils/common_utils'; import { __ } from '~/locale'; +import { add, show, hide } from '~/tooltips'; // Quick Submit behavior // @@ -65,18 +66,17 @@ $(document).on( return; } - const $this = $(this); + const $el = $(this); const title = isMac() - ? __('You can also press ⌘-Enter') + ? __('You can also press \u{2318}-Enter') : __('You can also press Ctrl-Enter'); - $this.tooltip({ - container: 'body', - html: true, - placement: 'top', + add($el, { + triggers: 'manual', + show: true, title, - trigger: 'manual', }); - $this.tooltip('show').one('blur click', () => $this.tooltip('hide')); + $el.one('blur click', () => hide($el)); + show($el); }, ); diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js index f7b327b2af1..5a5a67334d3 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js @@ -4,6 +4,8 @@ import Sidebar from '../../right_sidebar'; import Shortcuts from './shortcuts'; import { CopyAsGFM } from '../markdown/copy_as_gfm'; import { getSelectedFragment } from '~/lib/utils/common_utils'; +import { isElementVisible } from '~/lib/utils/dom_utils'; +import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; export default class ShortcutsIssuable extends Shortcuts { constructor() { @@ -14,6 +16,7 @@ export default class ShortcutsIssuable extends Shortcuts { Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels')); Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText); Mousetrap.bind('e', ShortcutsIssuable.editIssue); + Mousetrap.bind('b', ShortcutsIssuable.copyBranchName); } static replyWithSelectedText() { @@ -98,4 +101,18 @@ export default class ShortcutsIssuable extends Shortcuts { Sidebar.instance.openDropdown(name); return false; } + + static copyBranchName() { + // There are two buttons - one that is shown when the sidebar + // is expanded, and one that is shown when it's collapsed. + const allCopyBtns = Array.from(document.querySelectorAll('.sidebar-source-branch button')); + + // Select whichever button is currently visible so that + // the "Copied" tooltip is shown when a click is simulated. + const visibleBtn = allCopyBtns.find(isElementVisible); + + if (visibleBtn) { + clickCopyToClipboardButton(visibleBtn); + } + } } diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index ef8b8788abf..4b63143c4ba 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -12,11 +12,19 @@ import { getLocationHash } from '../lib/utils/url_utility'; $(() => { function toggleContainer(container, toggleState) { const $container = $(container); - - $container - .find('.js-toggle-button .fa-chevron-up, .js-toggle-button .fa-chevron-down') - .toggleClass('fa-chevron-up', toggleState) - .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined); + const isExpanded = $container.data('is-expanded'); + const $collapseIcon = $container.find('.js-sidebar-collapse'); + const $expandIcon = $container.find('.js-sidebar-expand'); + + if (isExpanded && !toggleState) { + $container.data('is-expanded', false); + $collapseIcon.addClass('hidden'); + $expandIcon.removeClass('hidden'); + } else { + $container.data('is-expanded', true); + $expandIcon.addClass('hidden'); + $collapseIcon.removeClass('hidden'); + } $container.find('.js-toggle-content').toggle(toggleState); } diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue index a013d637c1d..73ccc3289b9 100644 --- a/app/assets/javascripts/blob/components/blob_edit_content.vue +++ b/app/assets/javascripts/blob/components/blob_edit_content.vue @@ -1,7 +1,7 @@ <script> import { debounce } from 'lodash'; import { initEditorLite } from '~/blob/utils'; -import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants'; +import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants'; import eventHub from './eventhub'; diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue index 2cbbbddceeb..5715635fd13 100644 --- a/app/assets/javascripts/blob/components/blob_edit_header.vue +++ b/app/assets/javascripts/blob/components/blob_edit_header.vue @@ -50,6 +50,7 @@ export default { variant="danger" category="secondary" :disabled="!canDelete" + data-qa-selector="delete_file_button" @click="$emit('delete')" >{{ s__('Snippets|Delete file') }}</gl-button > diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index fd40c51fec1..a4a43b7a94e 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -66,7 +66,7 @@ export default { <template> <div class="js-file-title file-title-flex-parent"> <blob-filepath :blob="blob"> - <template #filepathPrepend> + <template #filepath-prepend> <slot name="prepend"></slot> </template> </blob-filepath> diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue index daade611651..6eddec31166 100644 --- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue +++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue @@ -32,6 +32,7 @@ export default { default: false, }, }, + inject: ['blobHash'], computed: { downloadUrl() { return `${this.rawPath}?inline=false`; @@ -39,6 +40,9 @@ export default { copyDisabled() { return this.activeViewer === RICH_BLOB_VIEWER; }, + getBlobHashTarget() { + return `[data-blob-hash="${this.blobHash}"]`; + }, }, BTN_COPY_CONTENTS_TITLE, BTN_DOWNLOAD_TITLE, @@ -53,7 +57,7 @@ export default { :aria-label="$options.BTN_COPY_CONTENTS_TITLE" :title="$options.BTN_COPY_CONTENTS_TITLE" :disabled="copyDisabled" - data-clipboard-target="#blob-code-content" + :data-clipboard-target="getBlobHashTarget" data-testid="copyContentsButton" icon="copy-to-clipboard" category="primary" diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue index f99ecba2324..eb8068a8ad7 100644 --- a/app/assets/javascripts/blob/components/blob_header_filepath.vue +++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue @@ -26,7 +26,7 @@ export default { </script> <template> <div class="file-header-content d-flex align-items-center lh-100"> - <slot name="filepathPrepend"></slot> + <slot name="filepath-prepend"></slot> <template v-if="blob.path"> <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" /> diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index 1412e49836d..02a522dda9d 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -9,8 +9,6 @@ const trackingMixin = Tracking.mixin(); export default { beginnerLink: 'https://about.gitlab.com/blog/2018/01/22/a-beginners-guide-to-continuous-integration/', - exampleLink: 'https://docs.gitlab.com/ee/ci/examples/', - codeQualityLink: 'https://docs.gitlab.com/ee/user/project/merge_requests/code_quality.html', goToTrackValuePipelines: 10, goToTrackValueMergeRequest: 20, trackEvent: 'click_button', @@ -39,6 +37,14 @@ export default { type: String, required: true, }, + exampleLink: { + type: String, + required: true, + }, + codeQualityLink: { + type: String, + required: true, + }, }, data() { return { @@ -93,7 +99,7 @@ export default { <p> <gl-sprintf :message="$options.i18n.bodyMessage"> <template #codeQualityLink="{content}"> - <gl-link :href="$options.codeQualityLink" target="_blank" class="font-size-inherit">{{ + <gl-link :href="codeQualityLink" target="_blank" class="font-size-inherit">{{ content }}</gl-link> </template> @@ -106,7 +112,7 @@ export default { </gl-link> </template> <template #exampleLink="{content}"> - <gl-link :href="$options.exampleLink" target="_blank"> + <gl-link :href="exampleLink" target="_blank"> {{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 2d426ee663a..f84e39baa53 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -8,11 +8,40 @@ import initPopover from '~/blob/suggest_gitlab_ci_yml'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; +const initPopovers = () => { + const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml'); + + if (suggestEl) { + const commitButton = document.querySelector('#commit-changes'); + + initPopover(suggestEl); + + if (commitButton) { + const { dismissKey, humanAccess } = suggestEl.dataset; + const urlParams = new URLSearchParams(window.location.search); + const mergeRequestPath = urlParams.get('mr_path') || true; + + const commitCookieName = `suggest_gitlab_ci_yml_commit_${dismissKey}`; + const commitTrackLabel = 'suggest_gitlab_ci_yml_commit_changes'; + const commitTrackValue = '20'; + + commitButton.addEventListener('click', () => { + setCookie(commitCookieName, mergeRequestPath); + + Tracking.event(undefined, 'click_button', { + label: commitTrackLabel, + property: humanAccess, + value: commitTrackValue, + }); + }); + } + } +}; + export default () => { const editBlobForm = $('.js-edit-blob-form'); const uploadBlobForm = $('.js-upload-blob-form'); const deleteBlobForm = $('.js-delete-blob-form'); - const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml'); if (editBlobForm.length) { const urlRoot = editBlobForm.data('relativeUrlRoot'); @@ -33,6 +62,7 @@ export default () => { projectId, isMarkdown, }); + initPopovers(); }) .catch(e => createFlash(e)); @@ -62,30 +92,4 @@ export default () => { if (deleteBlobForm.length) { new NewCommitForm(deleteBlobForm); } - - if (suggestEl) { - const commitButton = document.querySelector('#commit-changes'); - - initPopover(suggestEl); - - if (commitButton) { - const { dismissKey, humanAccess } = suggestEl.dataset; - const urlParams = new URLSearchParams(window.location.search); - const mergeRequestPath = urlParams.get('mr_path') || true; - - const commitCookieName = `suggest_gitlab_ci_yml_commit_${dismissKey}`; - const commitTrackLabel = 'suggest_gitlab_ci_yml_commit_changes'; - const commitTrackValue = '20'; - - commitButton.addEventListener('click', () => { - setCookie(commitCookieName, mergeRequestPath); - - Tracking.event(undefined, 'click_button', { - label: commitTrackLabel, - property: humanAccess, - value: commitTrackValue, - }); - }); - } - } }; diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue new file mode 100644 index 00000000000..c81f171af2b --- /dev/null +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -0,0 +1,178 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import { + GlDropdownItem, + GlDropdownDivider, + GlAvatarLabeled, + GlAvatarLink, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { __, n__ } from '~/locale'; +import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; +import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql'; +import searchUsers from '~/boards/queries/users_search.query.graphql'; + +export default { + noSearchDelay: 0, + searchDelay: 250, + i18n: { + unassigned: __('Unassigned'), + assignee: __('Assignee'), + assignees: __('Assignees'), + assignTo: __('Assign to'), + }, + components: { + BoardEditableItem, + IssuableAssignees, + MultiSelectDropdown, + GlDropdownItem, + GlDropdownDivider, + GlAvatarLabeled, + GlAvatarLink, + GlSearchBoxByType, + }, + data() { + return { + search: '', + participants: [], + selected: this.$store.getters.activeIssue.assignees, + }; + }, + apollo: { + participants: { + query() { + return this.isSearchEmpty ? getIssueParticipants : searchUsers; + }, + variables() { + if (this.isSearchEmpty) { + return { + id: `gid://gitlab/Issue/${this.activeIssue.iid}`, + }; + } + + return { + search: this.search, + }; + }, + update(data) { + if (this.isSearchEmpty) { + return data.issue?.participants?.nodes || []; + } + + return data.users?.nodes || []; + }, + debounce() { + const { noSearchDelay, searchDelay } = this.$options; + + return this.isSearchEmpty ? noSearchDelay : searchDelay; + }, + }, + }, + computed: { + ...mapGetters(['activeIssue']), + assigneeText() { + return n__('Assignee', '%d Assignees', this.selected.length); + }, + unSelectedFiltered() { + return this.participants.filter(({ username }) => { + return !this.selectedUserNames.includes(username); + }); + }, + selectedIsEmpty() { + return this.selected.length === 0; + }, + selectedUserNames() { + return this.selected.map(({ username }) => username); + }, + isSearchEmpty() { + return this.search === ''; + }, + }, + methods: { + ...mapActions(['setAssignees']), + clearSelected() { + this.selected = []; + }, + selectAssignee(name) { + if (name === undefined) { + this.clearSelected(); + return; + } + + this.selected = this.selected.concat(name); + }, + unselect(name) { + this.selected = this.selected.filter(user => user.username !== name); + }, + saveAssignees() { + this.setAssignees(this.selectedUserNames); + }, + isChecked(id) { + return this.selectedUserNames.includes(id); + }, + }, +}; +</script> + +<template> + <board-editable-item :title="assigneeText" @close="saveAssignees"> + <template #collapsed> + <issuable-assignees :users="activeIssue.assignees" /> + </template> + + <template #default> + <multi-select-dropdown + class="w-100" + :text="$options.i18n.assignees" + :header-text="$options.i18n.assignTo" + > + <template #search> + <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> + </template> + </multi-select-dropdown> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue index 072dd87861a..f796acd2303 100644 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ b/app/assets/javascripts/boards/components/board_card_layout.vue @@ -44,9 +44,6 @@ export default { multiSelectVisible() { return this.multiSelect.list.findIndex(issue => issue.id === this.issue.id) > -1; }, - canMultiSelect() { - return gon.features && gon.features.multiSelectBoard; - }, }, methods: { mouseDown() { @@ -59,7 +56,7 @@ export default { // Don't do anything if this happened on a no trigger element if (e.target.classList.contains('js-no-trigger')) return; - const isMultiSelect = this.canMultiSelect && (e.ctrlKey || e.metaKey); + const isMultiSelect = e.ctrlKey || e.metaKey; if (this.showDetail || isMultiSelect) { this.showDetail = false; diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 9295065b7b7..cb93340bcf8 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,14 +1,10 @@ <script> -import { mapGetters, mapActions } from 'vuex'; +// This component is being replaced in favor of './board_column_new.vue' for GraphQL boards import Sortable from 'sortablejs'; import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; -import Tooltip from '~/vue_shared/directives/tooltip'; import EmptyComponent from '~/vue_shared/components/empty_component'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import BoardList from './board_list.vue'; -import BoardListNew from './board_list_new.vue'; import boardsStore from '../stores/boards_store'; -import eventHub from '../eventhub'; import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options'; import { ListType } from '../constants'; @@ -16,12 +12,8 @@ export default { components: { BoardPromotionState: EmptyComponent, BoardListHeader, - BoardList: gon.features?.graphqlBoardLists ? BoardListNew : BoardList, + BoardList, }, - directives: { - Tooltip, - }, - mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -50,44 +42,25 @@ export default { }; }, computed: { - ...mapGetters(['getIssues']), showBoardListAndBoardInfo() { return this.list.type !== ListType.promotion; }, - uniqueKey() { - // eslint-disable-next-line @gitlab/require-i18n-strings - return `boards.${this.boardId}.${this.list.type}.${this.list.id}`; - }, listIssues() { - if (!this.glFeatures.graphqlBoardLists) { - return this.list.issues; - } - return this.getIssues(this.list.id); - }, - shouldFetchIssues() { - return this.glFeatures.graphqlBoardLists && this.list.type !== ListType.blank; + return this.list.issues; }, }, watch: { filter: { handler() { - if (this.shouldFetchIssues) { - this.fetchIssuesForList({ listId: this.list.id }); - } else { - this.list.page = 1; - this.list.getIssues(true).catch(() => { - // TODO: handle request error - }); - } + this.list.page = 1; + this.list.getIssues(true).catch(() => { + // TODO: handle request error + }); }, deep: true, }, }, mounted() { - if (this.shouldFetchIssues) { - this.fetchIssuesForList({ listId: this.list.id }); - } - const instance = this; const sortableOptions = getBoardSortableDefaultOptions({ @@ -113,12 +86,6 @@ export default { Sortable.create(this.$el.parentNode, sortableOptions); }, - methods: { - ...mapActions(['fetchIssuesForList']), - showListNewIssueForm(listId) { - eventHub.$emit('showForm', listId); - }, - }, }; </script> @@ -131,7 +98,7 @@ export default { 'board-type-assignee': list.type === 'assignee', }" :data-id="list.id" - class="board gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" + class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" data-qa-selector="board_list" > <div diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue new file mode 100644 index 00000000000..8a59355eb83 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_column_new.vue @@ -0,0 +1,94 @@ +<script> +import { mapGetters, mapActions, mapState } from 'vuex'; +import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue'; +import BoardPromotionState from 'ee_else_ce/boards/components/board_promotion_state'; +import BoardList from './board_list_new.vue'; +import { ListType } from '../constants'; + +export default { + components: { + BoardPromotionState, + BoardListHeader, + BoardList, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + disabled: { + type: Boolean, + required: true, + }, + canAdminList: { + type: Boolean, + required: false, + default: false, + }, + }, + inject: { + boardId: { + default: '', + }, + }, + computed: { + ...mapState(['filterParams']), + ...mapGetters(['getIssuesByList']), + showBoardListAndBoardInfo() { + return this.list.type !== ListType.promotion; + }, + listIssues() { + return this.getIssuesByList(this.list.id); + }, + shouldFetchIssues() { + return this.list.type !== ListType.blank; + }, + }, + watch: { + filterParams: { + handler() { + if (this.shouldFetchIssues) { + this.fetchIssuesForList({ listId: this.list.id }); + } + }, + deep: true, + immediate: true, + }, + }, + methods: { + ...mapActions(['fetchIssuesForList']), + // TODO: Reordering of lists https://gitlab.com/gitlab-org/gitlab/-/issues/280515 + }, +}; +</script> + +<template> + <div + :class="{ + 'is-draggable': !list.preset, + 'is-expandable': list.isExpandable, + 'is-collapsed': !list.isExpanded, + 'board-type-assignee': list.type === 'assignee', + }" + :data-id="list.id" + class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal" + data-qa-selector="board_list" + > + <div + class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base" + > + <board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> + <board-list + v-if="showBoardListAndBoardInfo" + ref="board-list" + :disabled="disabled" + :issues="listIssues" + :list="list" + /> + + <!-- Will be only available in EE --> + <board-promotion-state v-if="list.id === 'promotion'" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue index ad3d653b905..754b00b54e0 100644 --- a/app/assets/javascripts/boards/components/board_configuration_options.vue +++ b/app/assets/javascripts/boards/components/board_configuration_options.vue @@ -43,7 +43,7 @@ export default { <template> <div class="append-bottom-20"> - <label class="form-section-title label-bold" for="board-new-name"> + <label class="label-bold gl-font-lg" for="board-new-name"> {{ __('List options') }} </label> <p class="text-secondary gl-mb-3"> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 2515f471379..92976574efb 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,13 +1,14 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; import { sortBy } from 'lodash'; -import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; import { GlAlert } from '@gitlab/ui'; +import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; +import BoardColumnNew from './board_column_new.vue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { - BoardColumn, + BoardColumn: gon.features?.graphqlBoardLists ? BoardColumnNew : BoardColumn, BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, @@ -38,12 +39,11 @@ export default { }, mounted() { if (this.glFeatures.graphqlBoardLists) { - this.fetchLists(); this.showPromotionList(); } }, methods: { - ...mapActions(['fetchLists', 'showPromotionList']), + ...mapActions(['showPromotionList']), }, }; </script> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 793c594cf16..e4ef3600ff9 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -196,9 +196,7 @@ export default { <p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p> <form v-else class="js-board-config-modal" @submit.prevent> <div v-if="!readonly" class="append-bottom-20"> - <label class="form-section-title label-bold" for="board-new-name">{{ - __('Title') - }}</label> + <label class="label-bold gl-font-lg" for="board-new-name">{{ __('Title') }}</label> <input id="board-new-name" ref="name" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index d01df44e7e4..53989e2d9de 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -16,9 +16,7 @@ import { // This component is being replaced in favor of './board_list_new.vue' for GraphQL boards -if (gon.features && gon.features.multiSelectBoard) { - Sortable.mount(new MultiDrag()); -} +Sortable.mount(new MultiDrag()); export default { name: 'BoardList', @@ -100,12 +98,11 @@ export default { mounted() { // TODO: Use Draggable in ./board_list_new.vue to drag & drop issue // https://gitlab.com/gitlab-org/gitlab/-/issues/218164 - const multiSelectOpts = {}; - if (gon.features && gon.features.multiSelectBoard) { - multiSelectOpts.multiDrag = true; - multiSelectOpts.selectedClass = 'js-multi-select'; - multiSelectOpts.animation = 500; - } + const multiSelectOpts = { + multiDrag: true, + selectedClass: 'js-multi-select', + animation: 500, + }; const options = getBoardSortableDefaultOptions({ scroll: true, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index bb9a1b79d91..d85ba2038a7 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -17,7 +17,6 @@ import eventHub from '../eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; import { inactiveId, LIST, ListType } from '../constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -32,7 +31,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], props: { list: { type: Object, @@ -121,12 +119,9 @@ export default { collapsedTooltipTitle() { return this.listTitle || this.listAssignee; }, - shouldDisplaySwimlanes() { - return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn; - }, }, methods: { - ...mapActions(['updateList', 'setActiveId']), + ...mapActions(['setActiveId']), openSidebarSettings() { if (this.activeId === inactiveId) { sidebarEventHub.$emit('sidebar.closeAll'); @@ -160,11 +155,7 @@ export default { } }, updateListFunction() { - if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { - this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded }); - } else { - this.list.update(); - } + this.list.update(); }, }, }; @@ -188,8 +179,9 @@ export default { 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader, 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, 'gl-py-2': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-direction-column': !list.isExpanded, }" - class="board-title gl-m-0 gl-display-flex js-board-handle" + class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" > <gl-button v-if="list.isExpandable" @@ -202,7 +194,15 @@ export default { @click="toggleExpanded" /> <!-- The following is only true in EE and if it is a milestone --> - <span v-if="showMilestoneListDetails" aria-hidden="true" class="gl-mr-2 milestone-icon"> + <span + v-if="showMilestoneListDetails" + aria-hidden="true" + class="milestone-icon" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + 'gl-mr-2': list.isExpanded, + }" + > <gl-icon name="timer" /> </span> @@ -210,6 +210,9 @@ export default { v-if="showAssigneeListDetails" :href="list.assignee.path" class="user-avatar-link js-no-trigger" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + }" > <img v-gl-tooltip.hover.bottom @@ -223,20 +226,28 @@ export default { </a> <div class="board-title-text" - :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }" + :class="{ + 'gl-display-none': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded, + 'gl-flex-grow-1': list.isExpanded, + }" > <span v-if="list.type !== 'label'" v-gl-tooltip.hover :class="{ - 'gl-display-inline-block': list.type === 'milestone', + 'gl-display-block': !list.isExpanded || list.type === 'milestone', }" :title="listTitle" - class="board-title-main-text block-truncated" + class="board-title-main-text gl-text-truncate" > {{ list.title }} </span> - <span v-if="list.type === 'assignee'" class="board-title-sub-text gl-ml-2"> + <span + v-if="list.type === 'assignee'" + class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" + :class="{ 'gl-display-none': !list.isExpanded }" + > @{{ listAssignee }} </span> <gl-label @@ -279,7 +290,10 @@ export default { <div v-if="showBoardListAndBoardInfo" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" - :class="{ 'gl-display-none!': !list.isExpanded && isSwimlanesHeader }" + :class="{ + 'gl-display-none!': !list.isExpanded && isSwimlanesHeader, + 'gl-p-0': !list.isExpanded, + }" > <span class="gl-display-inline-flex"> <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_new.vue new file mode 100644 index 00000000000..99347a4cd4d --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list_header_new.vue @@ -0,0 +1,358 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { + GlButton, + GlButtonGroup, + GlLabel, + GlTooltip, + GlIcon, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { n__, s__ } from '~/locale'; +import AccessorUtilities from '../../lib/utils/accessor'; +import IssueCount from './issue_count.vue'; +import eventHub from '../eventhub'; +import sidebarEventHub from '~/sidebar/event_hub'; +import { inactiveId, LIST, ListType } from '../constants'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + GlButtonGroup, + GlButton, + GlLabel, + GlTooltip, + GlIcon, + GlSprintf, + IssueCount, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + disabled: { + type: Boolean, + required: true, + }, + isSwimlanesHeader: { + type: Boolean, + required: false, + default: false, + }, + }, + inject: { + boardId: { + default: '', + }, + weightFeatureAvailable: { + default: false, + }, + scopedLabelsAvailable: { + default: false, + }, + currentUserId: { + default: null, + }, + }, + computed: { + ...mapState(['activeId']), + isLoggedIn() { + return Boolean(this.currentUserId); + }, + listType() { + return this.list.type; + }, + listAssignee() { + return this.list?.assignee?.username || ''; + }, + listTitle() { + return this.list?.label?.description || this.list.title || ''; + }, + showListHeaderButton() { + return ( + !this.disabled && + this.listType !== ListType.closed && + this.listType !== ListType.blank && + this.listType !== ListType.promotion + ); + }, + showMilestoneListDetails() { + return ( + this.list.type === ListType.milestone && + this.list.milestone && + (this.list.isExpanded || !this.isSwimlanesHeader) + ); + }, + showAssigneeListDetails() { + return ( + this.list.type === ListType.assignee && (this.list.isExpanded || !this.isSwimlanesHeader) + ); + }, + issuesCount() { + return this.list.issuesSize; + }, + issuesTooltipLabel() { + return n__(`%d issue`, `%d issues`, this.issuesCount); + }, + chevronTooltip() { + return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand'); + }, + chevronIcon() { + return this.list.isExpanded ? 'chevron-right' : 'chevron-down'; + }, + isNewIssueShown() { + return this.listType === ListType.backlog || this.showListHeaderButton; + }, + isSettingsShown() { + return ( + this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded + ); + }, + showBoardListAndBoardInfo() { + return this.listType !== ListType.blank && this.listType !== ListType.promotion; + }, + uniqueKey() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `boards.${this.boardId}.${this.listType}.${this.list.id}`; + }, + collapsedTooltipTitle() { + return this.listTitle || this.listAssignee; + }, + headerStyle() { + return { borderTopColor: this.list?.label?.color }; + }, + }, + methods: { + ...mapActions(['updateList', 'setActiveId']), + openSidebarSettings() { + if (this.activeId === inactiveId) { + sidebarEventHub.$emit('sidebar.closeAll'); + } + + this.setActiveId({ id: this.list.id, sidebarType: LIST }); + }, + showScopedLabels(label) { + return this.scopedLabelsAvailable && isScopedLabel(label); + }, + + showNewIssueForm() { + eventHub.$emit(`toggle-issue-form-${this.list.id}`); + }, + toggleExpanded() { + this.list.isExpanded = !this.list.isExpanded; + + if (!this.isLoggedIn) { + this.addToLocalStorage(); + } else { + this.updateListFunction(); + } + + // When expanding/collapsing, the tooltip on the caret button sometimes stays open. + // Close all tooltips manually to prevent dangling tooltips. + this.$root.$emit('bv::hide::tooltip'); + }, + addToLocalStorage() { + if (AccessorUtilities.isLocalStorageAccessSafe()) { + localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded); + } + }, + updateListFunction() { + this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded }); + }, + }, +}; +</script> + +<template> + <header + :class="{ + 'has-border': list.label && list.label.color, + 'gl-h-full': !list.isExpanded, + 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader, + }" + :style="headerStyle" + class="board-header gl-relative" + data-qa-selector="board_list_header" + data-testid="board-list-header" + > + <h3 + :class="{ + 'user-can-drag': !disabled && !list.preset, + 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader, + 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader, + 'gl-py-2': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-direction-column': !list.isExpanded, + }" + class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle" + > + <gl-button + v-if="list.isExpandable" + v-gl-tooltip.hover + :aria-label="chevronTooltip" + :title="chevronTooltip" + :icon="chevronIcon" + class="board-title-caret no-drag gl-cursor-pointer" + variant="link" + @click="toggleExpanded" + /> + <!-- EE start --> + <span + v-if="showMilestoneListDetails" + aria-hidden="true" + class="milestone-icon" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + 'gl-mr-2': list.isExpanded, + }" + > + <gl-icon name="timer" /> + </span> + + <a + v-if="showAssigneeListDetails" + :href="list.assignee.path" + class="user-avatar-link js-no-trigger" + :class="{ + 'gl-mt-3 gl-rotate-90': !list.isExpanded, + }" + > + <img + v-gl-tooltip.hover.bottom + :title="listAssignee" + :alt="list.assignee.name" + :src="list.assignee.avatar" + class="avatar s20" + height="20" + width="20" + /> + </a> + <!-- EE end --> + <div + class="board-title-text" + :class="{ + 'gl-display-none': !list.isExpanded && isSwimlanesHeader, + 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded, + 'gl-flex-grow-1': list.isExpanded, + }" + > + <!-- EE start --> + <span + v-if="listType !== 'label'" + v-gl-tooltip.hover + :class="{ + 'gl-display-block': !list.isExpanded || listType === 'milestone', + }" + :title="listTitle" + class="board-title-main-text gl-text-truncate" + > + {{ list.title }} + </span> + <span + v-if="listType === 'assignee'" + v-show="list.isExpanded" + class="gl-ml-2 gl-font-weight-normal gl-text-gray-500" + > + @{{ listAssignee }} + </span> + <!-- EE end --> + <gl-label + v-if="listType === 'label'" + v-gl-tooltip.hover.bottom + :background-color="list.label.color" + :description="list.label.description" + :scoped="showScopedLabels(list.label)" + :size="!list.isExpanded ? 'sm' : ''" + :title="list.label.title" + /> + </div> + + <!-- EE start --> + <span + v-if="isSwimlanesHeader && !list.isExpanded" + ref="collapsedInfo" + aria-hidden="true" + class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500" + > + <gl-icon name="information" /> + </span> + <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo"> + <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div> + <div v-if="list.maxIssueCount !== 0"> + • + <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> + <template #issuesSize>{{ issuesTooltipLabel }}</template> + <template #maxIssueCount>{{ list.maxIssueCount }}</template> + </gl-sprintf> + </div> + <div v-else>• {{ issuesTooltipLabel }}</div> + <div v-if="weightFeatureAvailable"> + • + <gl-sprintf :message="__('%{totalWeight} total weight')"> + <template #totalWeight>{{ list.totalWeight }}</template> + </gl-sprintf> + </div> + </gl-tooltip> + <!-- EE end --> + + <div + v-if="showBoardListAndBoardInfo" + class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500" + :class="{ + 'gl-display-none!': !list.isExpanded && isSwimlanesHeader, + 'gl-p-0': !list.isExpanded, + }" + > + <span class="gl-display-inline-flex"> + <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> + <span ref="issueCount" class="issue-count-badge-count"> + <gl-icon class="gl-mr-2" name="issues" /> + <issue-count :issues-size="issuesCount" :max-issue-count="list.maxIssueCount" /> + </span> + <!-- EE start --> + <template v-if="weightFeatureAvailable"> + <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" /> + <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3"> + <gl-icon class="gl-mr-2" name="weight" /> + {{ list.totalWeight }} + </span> + </template> + <!-- EE end --> + </span> + </div> + <gl-button-group + v-if="isNewIssueShown || isSettingsShown" + class="board-list-button-group pl-2" + > + <gl-button + v-if="isNewIssueShown" + v-show="list.isExpanded" + ref="newIssueBtn" + v-gl-tooltip.hover + :aria-label="__('New issue')" + :title="__('New issue')" + class="issue-count-badge-add-button no-drag" + icon="plus" + @click="showNewIssueForm" + /> + + <gl-button + v-if="isSettingsShown" + ref="settingsBtn" + v-gl-tooltip.hover + :aria-label="__('List settings')" + class="no-drag js-board-settings-button" + :title="__('List settings')" + icon="settings" + @click="openSidebarSettings" + /> + <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip> + </gl-button-group> + </h3> + </header> +</template> diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue index 0a495d05122..396aedcc557 100644 --- a/app/assets/javascripts/boards/components/board_list_new.vue +++ b/app/assets/javascripts/boards/components/board_list_new.vue @@ -1,7 +1,7 @@ <script> import { mapActions, mapState } from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; -import BoardNewIssue from './board_new_issue.vue'; +import BoardNewIssue from './board_new_issue_new.vue'; import BoardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 0a665b82880..a9e6d768656 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,6 +1,4 @@ <script> -import $ from 'jquery'; -import { mapActions, mapGetters } from 'vuex'; import { GlButton } from '@gitlab/ui'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import ListIssue from 'ee_else_ce/boards/models/issue'; @@ -9,6 +7,8 @@ import ProjectSelect from './project_select.vue'; import boardsStore from '../stores/boards_store'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +// This component is being replaced in favor of './board_new_issue_new.vue' for GraphQL boards + export default { name: 'BoardNewIssue', components: { @@ -31,23 +31,18 @@ export default { }; }, computed: { - ...mapGetters(['isSwimlanesOn']), disabled() { if (this.groupId) { return this.title === '' || !this.selectedProject.name; } return this.title === ''; }, - shouldDisplaySwimlanes() { - return this.glFeatures.boardsWithSwimlanes && this.isSwimlanesOn; - }, }, mounted() { this.$refs.input.focus(); eventHub.$on('setSelectedProject', this.setSelectedProject); }, methods: { - ...mapActions(['addListIssue', 'addListIssueFailure']), submit(e) { e.preventDefault(); if (this.title.trim() === '') return Promise.resolve(); @@ -74,31 +69,14 @@ export default { eventHub.$emit(`scroll-board-list-${this.list.id}`); this.cancel(); - if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { - this.addListIssue({ list: this.list, issue, position: 0 }); - } - return this.list .newIssue(issue) .then(() => { - // Need this because our jQuery very kindly disables buttons on ALL form submissions - $(this.$refs.submitButton).enable(); - - if (!this.shouldDisplaySwimlanes && !this.glFeatures.graphqlBoardLists) { - boardsStore.setIssueDetail(issue); - boardsStore.setListDetail(this.list); - } + boardsStore.setIssueDetail(issue); + boardsStore.setListDetail(this.list); }) .catch(() => { - // Need this because our jQuery very kindly disables buttons on ALL form submissions - $(this.$refs.submitButton).enable(); - - // Remove the issue - if (this.shouldDisplaySwimlanes || this.glFeatures.graphqlBoardLists) { - this.addListIssueFailure({ list: this.list, issue }); - } else { - this.list.removeIssue(issue); - } + this.list.removeIssue(issue); // Show error message this.error = true; @@ -137,7 +115,7 @@ export default { <gl-button ref="submitButton" :disabled="disabled" - class="float-left" + class="float-left js-no-auto-disable" variant="success" category="primary" type="submit" diff --git a/app/assets/javascripts/boards/components/board_new_issue_new.vue b/app/assets/javascripts/boards/components/board_new_issue_new.vue new file mode 100644 index 00000000000..969c84ddb59 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_new_issue_new.vue @@ -0,0 +1,129 @@ +<script> +import { mapActions } from 'vuex'; +import { GlButton } from '@gitlab/ui'; +import { getMilestone } from 'ee_else_ce/boards/boards_util'; +import eventHub from '../eventhub'; +import ProjectSelect from './project_select.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { __ } from '~/locale'; + +export default { + name: 'BoardNewIssue', + i18n: { + submit: __('Submit issue'), + cancel: __('Cancel'), + }, + components: { + ProjectSelect, + GlButton, + }, + mixins: [glFeatureFlagMixin()], + props: { + list: { + type: Object, + required: true, + }, + }, + inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], + data() { + return { + title: '', + selectedProject: {}, + }; + }, + computed: { + disabled() { + if (this.groupId) { + return this.title === '' || !this.selectedProject.name; + } + return this.title === ''; + }, + inputFieldId() { + // eslint-disable-next-line @gitlab/require-i18n-strings + return `${this.list.id}-title`; + }, + }, + mounted() { + this.$refs.input.focus(); + eventHub.$on('setSelectedProject', this.setSelectedProject); + }, + methods: { + ...mapActions(['addListNewIssue']), + submit(e) { + e.preventDefault(); + + const labels = this.list.label ? [this.list.label] : []; + const assignees = this.list.assignee ? [this.list.assignee] : []; + const milestone = getMilestone(this.list); + + const weight = this.weightFeatureAvailable ? this.boardWeight : undefined; + + const { title } = this; + + eventHub.$emit(`scroll-board-list-${this.list.id}`); + + return this.addListNewIssue({ + issueInput: { + title, + labelIds: labels?.map(l => l.id), + assigneeIds: assignees?.map(a => a?.id), + milestoneId: milestone?.id, + projectPath: this.selectedProject.path, + weight: weight >= 0 ? weight : null, + }, + list: this.list, + }).then(() => { + this.reset(); + }); + }, + reset() { + this.title = ''; + eventHub.$emit(`toggle-issue-form-${this.list.id}`); + }, + setSelectedProject(selectedProject) { + this.selectedProject = selectedProject; + }, + }, +}; +</script> + +<template> + <div class="board-new-issue-form"> + <div class="board-card position-relative p-3 rounded"> + <form ref="submitForm" @submit="submit"> + <label :for="inputFieldId" class="label-bold">{{ __('Title') }}</label> + <input + :id="inputFieldId" + ref="input" + v-model="title" + class="form-control" + type="text" + name="issue_title" + autocomplete="off" + /> + <project-select v-if="groupId" :group-id="groupId" :list="list" /> + <div class="clearfix gl-mt-3"> + <gl-button + ref="submitButton" + :disabled="disabled" + class="float-left js-no-auto-disable" + variant="success" + category="primary" + type="submit" + > + {{ $options.i18n.submit }} + </gl-button> + <gl-button + ref="cancelButton" + class="float-right" + type="button" + variant="default" + @click="reset" + > + {{ $options.i18n.cancel }} + </gl-button> + </div> + </form> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_promotion_state.js b/app/assets/javascripts/boards/components/board_promotion_state.js new file mode 100644 index 00000000000..ff8b4c56321 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_promotion_state.js @@ -0,0 +1 @@ +export default {}; diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue index 392e056dcbf..80070b25bd0 100644 --- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -36,6 +36,9 @@ export default { computed: { ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL']), ...mapState(['activeId', 'sidebarType', 'boardLists']), + isWipLimitsOn() { + return this.glFeatures.wipLimits; + }, activeList() { /* Warning: Though a computed property it is not reactive because we are @@ -66,14 +69,18 @@ export default { eventHub.$off('sidebar.closeAll', this.unsetActiveId); }, methods: { - ...mapActions(['unsetActiveId']), + ...mapActions(['unsetActiveId', 'removeList']), showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); }, deleteBoard() { // eslint-disable-next-line no-alert - if (window.confirm(__('Are you sure you want to delete this list?'))) { - this.activeList.destroy(); + if (window.confirm(__('Are you sure you want to remove this list?'))) { + if (this.shouldUseGraphQL) { + this.removeList(this.activeId); + } else { + this.activeList.destroy(); + } this.unsetActiveId(); } }, @@ -105,7 +112,10 @@ export default { :active-list="activeList" :board-list-type="boardListType" /> - <board-settings-sidebar-wip-limit :max-issue-count="activeList.maxIssueCount" /> + <board-settings-sidebar-wip-limit + v-if="isWipLimitsOn" + :max-issue-count="activeList.maxIssueCount" + /> <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-m-4"> <gl-button variant="danger" diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 271e1fc4b5f..0b079c78209 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -261,7 +261,7 @@ export default { > <gl-deprecated-dropdown-item v-show="filteredBoards.length === 0" - class="no-pointer-events text-secondary" + class="gl-pointer-events-none text-secondary" > {{ s__('IssueBoards|No matching boards found') }} </gl-deprecated-dropdown-item> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index a181ea51c4a..45ce1e51489 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -3,7 +3,7 @@ import { sortBy } from 'lodash'; import { mapState } from 'vuex'; import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; -import { sprintf, __ } from '~/locale'; +import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueDueDate from './issue_due_date.vue'; @@ -89,6 +89,12 @@ export default { orderedLabels() { return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title'); }, + blockedLabel() { + if (this.issue.blockedByCount) { + return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount); + } + return __('Blocked issue'); + }, }, methods: { isIndexLessThanlimit(index) { @@ -133,15 +139,16 @@ export default { </script> <template> <div> - <div class="d-flex board-card-header" dir="auto"> + <div class="gl-display-flex" dir="auto"> <h4 class="board-card-title gl-mb-0 gl-mt-0"> <gl-icon v-if="issue.blocked" v-gl-tooltip name="issue-block" - :title="__('Blocked issue')" + :title="blockedLabel" class="issue-blocked-icon gl-mr-2" - :aria-label="__('Blocked issue')" + :aria-label="blockedLabel" + data-testid="issue-blocked-icon" /> <gl-icon v-if="issue.confidential" @@ -156,7 +163,7 @@ export default { }}</a> </h4> </div> - <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 d-flex flex-wrap"> + <div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap"> <template v-for="label in orderedLabels"> <gl-label :key="label.id" @@ -169,24 +176,26 @@ export default { /> </template> </div> - <div class="board-card-footer d-flex justify-content-between align-items-end"> + <div + class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end" + > <div - class="d-flex align-items-start flex-wrap-reverse board-card-number-container overflow-hidden js-board-card-number-container" + class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container" > <span v-if="issue.referencePath" - class="board-card-number overflow-hidden d-flex gl-mr-3 gl-mt-3" + class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" > <tooltip-on-truncate v-if="issueReferencePath" :title="issueReferencePath" placement="bottom" - class="board-issue-path block-truncated bold" + class="board-issue-path gl-text-truncate gl-font-weight-bold" >{{ issueReferencePath }}</tooltip-on-truncate > #{{ issue.iid }} </span> - <span class="board-info-items gl-mt-3 d-inline-block"> + <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-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight @@ -196,20 +205,20 @@ export default { /> </span> </div> - <div class="board-card-assignee d-flex"> + <div class="board-card-assignee gl-display-flex"> <user-avatar-link v-for="(assignee, index) in issue.assignees" v-if="shouldRenderAssignee(index)" :key="assignee.id" :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" - :img-src="assignee.avatar || assignee.avatar_url" + :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url" :img-size="24" class="js-no-trigger" tooltip-placement="bottom" > <span class="js-assignee-tooltip"> - <span class="bold d-block">{{ __('Assignee') }}</span> + <span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span> {{ assignee.name }} <span class="text-white-50">@{{ assignee.username }}</span> </span> diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue index cd4512f320f..eb2db260717 100644 --- a/app/assets/javascripts/boards/components/modal/empty_state.vue +++ b/app/assets/javascripts/boards/components/modal/empty_state.vue @@ -1,13 +1,13 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlButton } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; import ModalStore from '../../stores/modal_store'; import modalMixin from '../../mixins/modal_mixins'; export default { components: { GlButton, + GlSprintf, }, mixins: [modalMixin], props: { @@ -34,11 +34,8 @@ export default { if (this.activeTab === 'selected') { obj.title = __("You haven't selected any issues yet"); - obj.content = sprintf( - __( - 'Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board.', - ), - { startTag: '<strong>', endTag: '</strong>' }, + obj.content = __( + 'Go back to %{tagStart}Open issues%{tagEnd} and select some issues to add to your board.', ); } @@ -57,7 +54,13 @@ export default { <div class="col-12 col-md-6 order-md-first"> <div class="text-content"> <h4>{{ contents.title }}</h4> - <p v-html="contents.content"></p> + <p> + <gl-sprintf :message="contents.content"> + <template #tag="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> <gl-button v-if="activeTab === 'all'" :href="newIssuePath" diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index c8926c5ef2a..47eee5306da 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -7,6 +7,7 @@ import { deprecatedCreateFlash as flash } from '~/flash'; import CreateLabelDropdown from '../../create_label'; import boardsStore from '../stores/boards_store'; import { fullLabelId } from '../boards_util'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import store from '~/boards/stores'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; @@ -61,7 +62,7 @@ export default function initNewListDropdown() { const active = boardsStore.findListByLabelId(label.id); const $li = $('<li />'); const $a = $('<a />', { - class: active ? `is-active js-board-list-${active.id}` : '', + class: active ? `is-active js-board-list-${getIdFromGraphQLId(active.id)}` : '', text: label.title, href: '#', }); diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 566c0081b9d..f90fe582566 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -44,6 +44,7 @@ export default { this.selectedProject = { id: $el.data('project-id'), name: $el.data('project-name'), + path: $el.data('project-path'), }; eventHub.$emit('setSelectedProject', this.selectedProject); }, @@ -75,11 +76,12 @@ export default { renderRow(project) { return ` <li> - <a href='#' class='dropdown-menu-link' data-project-id="${ - project.id - }" data-project-name="${project.name}" data-project-name-with-namespace="${ - project.name_with_namespace - }"> + <a href='#' class='dropdown-menu-link' + data-project-id="${project.id}" + data-project-name="${project.name}" + data-project-name-with-namespace="${project.name_with_namespace}" + data-project-path="${project.path_with_namespace}" + > ${escape(project.name_with_namespace)} </a> </li> 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 new file mode 100644 index 00000000000..6935ead2706 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue @@ -0,0 +1,111 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlButton, GlDatepicker } from '@gitlab/ui'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import { dateInWords, formatDate, parsePikadayDate } from '~/lib/utils/datetime_utility'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +export default { + components: { + BoardEditableItem, + GlButton, + GlDatepicker, + }, + data() { + return { + loading: false, + }; + }, + computed: { + ...mapGetters({ issue: 'activeIssue', projectPathForActiveIssue: 'projectPathForActiveIssue' }), + hasDueDate() { + return this.issue.dueDate != null; + }, + parsedDueDate() { + if (!this.hasDueDate) { + return null; + } + + return parsePikadayDate(this.issue.dueDate); + }, + formattedDueDate() { + if (!this.hasDueDate) { + return ''; + } + + return dateInWords(this.parsedDueDate, true); + }, + }, + methods: { + ...mapActions(['setActiveIssueDueDate']), + async openDatePicker() { + await this.$nextTick(); + this.$refs.datePicker.calendar.show(); + }, + async setDueDate(date) { + this.loading = true; + this.$refs.sidebarItem.collapse(); + + try { + const dueDate = date ? formatDate(date, 'yyyy-mm-dd') : null; + await this.setActiveIssueDueDate({ dueDate, projectPath: this.projectPathForActiveIssue }); + } catch (e) { + createFlash({ message: this.$options.i18n.updateDueDateError }); + } finally { + this.loading = false; + } + }, + }, + i18n: { + dueDate: __('Due date'), + removeDueDate: __('remove due date'), + updateDueDateError: __('An error occurred when updating the issue due date'), + }, +}; +</script> + +<template> + <board-editable-item + ref="sidebarItem" + class="board-sidebar-due-date" + :title="$options.i18n.dueDate" + :loading="loading" + @open="openDatePicker" + > + <template v-if="hasDueDate" #collapsed> + <div class="gl-display-flex gl-align-items-center"> + <strong class="gl-text-gray-900">{{ formattedDueDate }}</strong> + <span class="gl-mx-2">-</span> + <gl-button + variant="link" + class="gl-text-gray-400!" + data-testid="reset-button" + :disabled="loading" + @click="setDueDate(null)" + > + {{ $options.i18n.removeDueDate }} + </gl-button> + </div> + </template> + <template> + <gl-datepicker + ref="datePicker" + :value="parsedDueDate" + show-clear-button + @input="setDueDate" + @clear="setDueDate(null)" + /> + </template> + </board-editable-item> +</template> +<style> +/* + * This can be removed after closing: + * https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1048 + */ +.board-sidebar-due-date .gl-datepicker, +.board-sidebar-due-date .gl-datepicker-input { + width: 100%; +} +</style> 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 0f063c7582e..9d537a4ef2c 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -21,9 +21,9 @@ export default { }, inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], computed: { - ...mapGetters({ issue: 'getActiveIssue' }), + ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), selectedLabels() { - const { labels = [] } = this.issue; + const { labels = [] } = this.activeIssue; return labels.map(label => ({ ...label, @@ -31,17 +31,13 @@ export default { })); }, issueLabels() { - const { labels = [] } = this.issue; + const { labels = [] } = this.activeIssue; return labels.map(label => ({ ...label, scoped: isScopedLabel(label), })); }, - projectPath() { - const { referencePath = '' } = this.issue; - return referencePath.slice(0, referencePath.indexOf('#')); - }, }, methods: { ...mapActions(['setActiveIssueLabels']), @@ -55,7 +51,7 @@ export default { .filter(label => !payload.find(selected => selected.id === label.id)) .map(label => label.id); - const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath }; + const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveIssueLabels(input); } catch (e) { createFlash({ message: __('An error occurred while updating labels.') }); @@ -68,7 +64,7 @@ export default { try { const removeLabelIds = [getIdFromGraphQLId(id)]; - const input = { removeLabelIds, projectPath: this.projectPath }; + const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue }; await this.setActiveIssueLabels(input); } catch (e) { createFlash({ message: __('An error occurred when removing the label.') }); diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue new file mode 100644 index 00000000000..ed069cea630 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_subscription.vue @@ -0,0 +1,71 @@ +<script> +import { mapGetters, mapActions } from 'vuex'; +import { GlToggle } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { __, s__ } from '~/locale'; + +export default { + i18n: { + header: { + title: __('Notifications'), + /* Any change to subscribeDisabledDescription + must be reflected in app/helpers/notifications_helper.rb */ + subscribeDisabledDescription: __( + 'Notifications have been disabled by the project or group owner', + ), + }, + updateSubscribedErrorMessage: s__( + 'IssueBoards|An error occurred while setting notifications status.', + ), + }, + components: { + GlToggle, + }, + data() { + return { + loading: false, + }; + }, + computed: { + ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), + notificationText() { + return this.activeIssue.emailsDisabled + ? this.$options.i18n.header.subscribeDisabledDescription + : this.$options.i18n.header.title; + }, + }, + methods: { + ...mapActions(['setActiveIssueSubscribed']), + async handleToggleSubscription() { + this.loading = true; + + try { + await this.setActiveIssueSubscribed({ + subscribed: !this.activeIssue.subscribed, + projectPath: this.projectPathForActiveIssue, + }); + } catch (error) { + createFlash({ message: this.$options.i18n.updateSubscribedErrorMessage }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between" + data-testid="sidebar-notifications" + > + <span data-testid="notification-header-text"> {{ notificationText }} </span> + <gl-toggle + v-if="!activeIssue.emailsDisabled" + :value="activeIssue.subscribed" + :is-loading="loading" + data-testid="notification-subscribe-toggle" + @change="handleToggleSubscription" + /> + </div> +</template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 2f64014a949..49cb560594c 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -18,7 +18,11 @@ export const inactiveId = 0; export const ISSUABLE = 'issuable'; export const LIST = 'list'; +/* eslint-disable-next-line @gitlab/require-i18n-strings */ +export const DEFAULT_LABELS = ['to do', 'doing']; + export default { BoardType, ListType, + DEFAULT_LABELS, }; diff --git a/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql new file mode 100644 index 00000000000..1f383245ac2 --- /dev/null +++ b/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql @@ -0,0 +1,8 @@ +mutation issueSetSubscription($input: IssueSetSubscriptionInput!) { + issueSetSubscription(input: $input) { + issue { + subscribed + } + errors + } +} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 887abe79059..d3e40299d8d 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; @@ -86,10 +86,17 @@ export default () => { boardId: $boardApp.dataset.boardId, groupId: Number($boardApp.dataset.groupId), rootPath: $boardApp.dataset.rootPath, + currentUserId: gon.current_user_id || null, canUpdate: $boardApp.dataset.canUpdate, labelsFetchPath: $boardApp.dataset.labelsFetchPath, labelsManagePath: $boardApp.dataset.labelsManagePath, labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath, + timeTrackingLimitToHours: parseBoolean($boardApp.dataset.timeTrackingLimitToHours), + weightFeatureAvailable: parseBoolean($boardApp.dataset.weightFeatureAvailable), + boardWeight: $boardApp.dataset.boardWeight + ? parseInt($boardApp.dataset.boardWeight, 10) + : null, + scopedLabelsAvailable: parseBoolean($boardApp.dataset.scopedLabels), }, store, apolloProvider, @@ -108,6 +115,7 @@ export default () => { }, computed: { ...mapState(['isShowingEpicsSwimlanes']), + ...mapGetters(['shouldUseGraphQL']), detailIssueVisible() { return Object.keys(this.detailIssue.issue).length; }, @@ -153,7 +161,7 @@ export default () => { boardsStore.disabled = this.disabled; - if (!gon.features.graphqlBoardLists) { + if (!this.shouldUseGraphQL) { this.initialBoardLoad(); } }, diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index fceb8c9d48e..f02c92e4230 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -1,17 +1,12 @@ /* global DocumentTouch */ -import $ from 'jquery'; import sortableConfig from 'ee_else_ce/sortable/sortable_config'; export function sortableStart() { - $('.has-tooltip') - .tooltip('hide') - .tooltip('disable'); document.body.classList.add('is-dragging'); } export function sortableEnd() { - $('.has-tooltip').tooltip('enable'); document.body.classList.remove('is-dragging'); } diff --git a/app/assets/javascripts/boards/queries/board_labels.query.graphql b/app/assets/javascripts/boards/queries/board_labels.query.graphql new file mode 100644 index 00000000000..42a94419a97 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_labels.query.graphql @@ -0,0 +1,23 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +query BoardLabels( + $fullPath: ID! + $searchTerm: String + $isGroup: Boolean = false + $isProject: Boolean = false +) { + group(fullPath: $fullPath) @include(if: $isGroup) { + labels(searchTerm: $searchTerm) { + nodes { + ...Label + } + } + } + project(fullPath: $fullPath) @include(if: $isProject) { + labels(searchTerm: $searchTerm) { + nodes { + ...Label + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql new file mode 100644 index 00000000000..ef3fd36e980 --- /dev/null +++ b/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql @@ -0,0 +1,5 @@ +mutation DestroyBoardList($listId: ID!) { + destroyBoardList(input: { listId: $listId }) { + errors + } +} diff --git a/app/assets/javascripts/boards/queries/issue_create.mutation.graphql b/app/assets/javascripts/boards/queries/issue_create.mutation.graphql new file mode 100644 index 00000000000..65be147be07 --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_create.mutation.graphql @@ -0,0 +1,10 @@ +#import "ee_else_ce/boards/queries/issue.fragment.graphql" + +mutation CreateIssue($input: CreateIssueInput!) { + createIssue(input: $input) { + issue { + ...IssueNode + } + errors + } +} diff --git a/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql b/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql new file mode 100644 index 00000000000..bbea248cf85 --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql @@ -0,0 +1,8 @@ +mutation issueSetDueDate($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issue { + dueDate + } + errors + } +} diff --git a/app/assets/javascripts/boards/queries/users_search.query.graphql b/app/assets/javascripts/boards/queries/users_search.query.graphql new file mode 100644 index 00000000000..ca016495d79 --- /dev/null +++ b/app/assets/javascripts/boards/queries/users_search.query.graphql @@ -0,0 +1,11 @@ +query usersSearch($search: String!) { + users(search: $search) { + nodes { + username + name + webUrl + avatarUrl + id + } + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 1fed1228106..dd950a45076 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,26 +1,30 @@ -import Cookies from 'js-cookie'; import { pick } from 'lodash'; import boardListsQuery from 'ee_else_ce/boards/queries/board_lists.query.graphql'; -import { __ } from '~/locale'; -import { parseBoolean } from '~/lib/utils/common_utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { BoardType, ListType, inactiveId } from '~/boards/constants'; +import { BoardType, ListType, inactiveId, DEFAULT_LABELS } from '~/boards/constants'; import * as types from './mutation_types'; import { formatBoardLists, formatListIssues, fullBoardId, formatListsPageInfo, + formatIssue, } from '../boards_util'; import boardStore from '~/boards/stores/boards_store'; +import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import listsIssuesQuery from '../queries/lists_issues.query.graphql'; +import boardLabelsQuery from '../queries/board_labels.query.graphql'; import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; import updateBoardListMutation from '../queries/board_list_update.mutation.graphql'; import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; +import destroyBoardListMutation from '../queries/board_list_destroy.mutation.graphql'; +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'; const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -83,7 +87,7 @@ export default { if (!lists.nodes.find(l => l.listType === ListType.backlog) && !hideBacklogList) { dispatch('createList', { backlog: true }); } - dispatch('showWelcomeList'); + dispatch('generateDefaultLists'); }) .catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE)); }, @@ -121,7 +125,32 @@ export default { ); }, - showWelcomeList: ({ state, dispatch }) => { + showPromotionList: () => {}, + + fetchLabels: ({ state, commit }, searchTerm) => { + const { endpoints, boardType } = state; + const { fullPath } = endpoints; + + const variables = { + fullPath, + searchTerm, + isGroup: boardType === BoardType.group, + isProject: boardType === BoardType.project, + }; + + return gqlClient + .query({ + query: boardLabelsQuery, + variables, + }) + .then(({ data }) => { + const labels = data[boardType]?.labels; + return labels.nodes; + }) + .catch(() => commit(types.RECEIVE_LABELS_FAILURE)); + }, + + generateDefaultLists: async ({ state, commit, dispatch }) => { if (state.disabled) { return; } @@ -132,22 +161,18 @@ export default { ) { return; } - if (parseBoolean(Cookies.get('issue_board_welcome_hidden'))) { - return; - } - dispatch('addList', { - id: 'blank', - listType: ListType.blank, - title: __('Welcome to your issue board!'), - position: 0, - }); - }, - - showPromotionList: () => {}, + const fetchLabelsAndCreateList = label => { + return dispatch('fetchLabels', label) + .then(res => { + if (res.length > 0) { + dispatch('createList', { labelId: res[0].id }); + } + }) + .catch(() => commit(types.GENERATE_DEFAULT_LISTS_FAILURE)); + }; - generateDefaultLists: () => { - notImplemented(); + await Promise.all(DEFAULT_LABELS.map(fetchLabelsAndCreateList)); }, moveList: ( @@ -191,8 +216,26 @@ export default { }); }, - deleteList: () => { - notImplemented(); + removeList: ({ state, commit }, listId) => { + const listsBackup = { ...state.boardLists }; + + commit(types.REMOVE_LIST, listId); + + return gqlClient + .mutate({ + mutation: destroyBoardListMutation, + variables: { + listId, + }, + }) + .then(({ data: { destroyBoardList: { errors } } }) => { + if (errors.length > 0) { + commit(types.REMOVE_LIST_FAILURE, listsBackup); + } + }) + .catch(() => { + commit(types.REMOVE_LIST_FAILURE, listsBackup); + }); }, fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { @@ -271,20 +314,69 @@ export default { ); }, - createNewIssue: () => { - notImplemented(); + setAssignees: ({ commit, getters }, assigneeUsernames) => { + return gqlClient + .mutate({ + mutation: updateAssignees, + variables: { + iid: getters.activeIssue.iid, + projectPath: getters.activeIssue.referencePath.split('#')[0], + assigneeUsernames, + }, + }) + .then(({ data }) => { + commit('UPDATE_ISSUE_BY_ID', { + issueId: getters.activeIssue.id, + prop: 'assignees', + value: data.issueSetAssignees.issue.assignees.nodes, + }); + }); + }, + + createNewIssue: ({ commit, state }, issueInput) => { + const input = issueInput; + const { boardType, endpoints } = state; + if (boardType === BoardType.project) { + input.projectPath = endpoints.fullPath; + } + + return gqlClient + .mutate({ + mutation: issueCreateMutation, + variables: { input }, + }) + .then(({ data }) => { + if (data.createIssue.errors.length) { + commit(types.CREATE_ISSUE_FAILURE); + } else { + return data.createIssue?.issue; + } + return null; + }) + .catch(() => commit(types.CREATE_ISSUE_FAILURE)); }, addListIssue: ({ commit }, { list, issue, position }) => { commit(types.ADD_ISSUE_TO_LIST, { list, issue, position }); }, - addListIssueFailure: ({ commit }, { list, issue }) => { - commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issue }); + addListNewIssue: ({ commit, dispatch }, { issueInput, list }) => { + const issue = formatIssue({ ...issueInput, id: 'tmp' }); + commit(types.ADD_ISSUE_TO_LIST, { list, issue, position: 0 }); + + dispatch('createNewIssue', issueInput) + .then(res => { + commit(types.ADD_ISSUE_TO_LIST, { + list, + issue: formatIssue({ ...res, id: getIdFromGraphQLId(res.id) }), + }); + commit(types.REMOVE_ISSUE_FROM_LIST, { list, issue }); + }) + .catch(() => commit(types.ADD_ISSUE_TO_LIST_FAILURE, { list, issueId: issueInput.id })); }, setActiveIssueLabels: async ({ commit, getters }, input) => { - const activeIssue = getters.getActiveIssue; + const { activeIssue } = getters; const { data } = await gqlClient.mutate({ mutation: issueSetLabels, variables: { @@ -308,6 +400,53 @@ export default { }); }, + setActiveIssueDueDate: async ({ commit, getters }, input) => { + const { activeIssue } = getters; + const { data } = await gqlClient.mutate({ + mutation: issueSetDueDate, + variables: { + input: { + iid: String(activeIssue.iid), + projectPath: input.projectPath, + dueDate: input.dueDate, + }, + }, + }); + + if (data.updateIssue?.errors?.length > 0) { + throw new Error(data.updateIssue.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: activeIssue.id, + prop: 'dueDate', + value: data.updateIssue.issue.dueDate, + }); + }, + + setActiveIssueSubscribed: async ({ commit, getters }, input) => { + const { data } = await gqlClient.mutate({ + mutation: issueSetSubscriptionMutation, + variables: { + input: { + iid: String(getters.activeIssue.iid), + projectPath: input.projectPath, + subscribedState: input.subscribed, + }, + }, + }); + + if (data.issueSetSubscription?.errors?.length > 0) { + throw new Error(data.issueSetSubscription.errors); + } + + commit(types.UPDATE_ISSUE_BY_ID, { + issueId: getters.activeIssue.id, + prop: 'subscribed', + value: data.issueSetSubscription.issue.subscribed, + }); + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index d1a5db1bcc5..337b2897fe9 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -1,7 +1,6 @@ /* eslint-disable no-shadow, no-param-reassign,consistent-return */ /* global List */ /* global ListIssue */ -import $ from 'jquery'; import { sortBy, pick } from 'lodash'; import Vue from 'vue'; import Cookies from 'js-cookie'; @@ -119,8 +118,12 @@ const boardsStore = { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); }, + updateNewListDropdown(listId) { - $(`.js-board-list-${listId}`).removeClass('is-active'); + // eslint-disable-next-line no-unused-expressions + document + .querySelector(`.js-board-list-${getIdFromGraphQLId(listId)}`) + ?.classList.remove('is-active'); }, shouldAddBlankState() { // Decide whether to add the blank state diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index 89a3b14b262..cd28b4a0ff7 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -2,7 +2,7 @@ import { find } from 'lodash'; import { inactiveId } from '../constants'; export default { - getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), + labelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), isSidebarOpen: state => state.activeId !== inactiveId, isSwimlanesOn: state => { if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) { @@ -15,15 +15,20 @@ export default { return state.issues[id] || {}; }, - getIssues: (state, getters) => listId => { + getIssuesByList: (state, getters) => listId => { const listIssueIds = state.issuesByListId[listId] || []; return listIssueIds.map(id => getters.getIssueById(id)); }, - getActiveIssue: state => { + activeIssue: state => { return state.issues[state.activeId] || {}; }, + projectPathForActiveIssue: (_, getters) => { + const referencePath = getters.activeIssue.referencePath || ''; + return referencePath.slice(0, referencePath.indexOf('#')); + }, + getListByLabelId: state => labelId => { return find(state.boardLists, l => l.label?.id === labelId); }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index 09ab08062df..3a57cb9b5e1 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -2,6 +2,8 @@ export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA'; export const SET_FILTERS = 'SET_FILTERS'; export const CREATE_LIST_SUCCESS = 'CREATE_LIST_SUCCESS'; export const CREATE_LIST_FAILURE = 'CREATE_LIST_FAILURE'; +export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE'; +export const GENERATE_DEFAULT_LISTS_FAILURE = 'GENERATE_DEFAULT_LISTS_FAILURE'; export const RECEIVE_BOARD_LISTS_SUCCESS = 'RECEIVE_BOARD_LISTS_SUCCESS'; export const RECEIVE_BOARD_LISTS_FAILURE = 'RECEIVE_BOARD_LISTS_FAILURE'; export const SHOW_PROMOTION_LIST = 'SHOW_PROMOTION_LIST'; @@ -10,12 +12,12 @@ export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR'; export const MOVE_LIST = 'MOVE_LIST'; export const UPDATE_LIST_FAILURE = 'UPDATE_LIST_FAILURE'; -export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST'; -export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS'; -export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR'; +export const REMOVE_LIST = 'REMOVE_LIST'; +export const REMOVE_LIST_FAILURE = 'REMOVE_LIST_FAILURE'; export const REQUEST_ISSUES_FOR_LIST = 'REQUEST_ISSUES_FOR_LIST'; export const RECEIVE_ISSUES_FOR_LIST_FAILURE = 'RECEIVE_ISSUES_FOR_LIST_FAILURE'; export const RECEIVE_ISSUES_FOR_LIST_SUCCESS = 'RECEIVE_ISSUES_FOR_LIST_SUCCESS'; +export const CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_FAILURE'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; @@ -27,6 +29,7 @@ export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS'; export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR'; export const ADD_ISSUE_TO_LIST = 'ADD_ISSUE_TO_LIST'; export const ADD_ISSUE_TO_LIST_FAILURE = 'ADD_ISSUE_TO_LIST_FAILURE'; +export const REMOVE_ISSUE_FROM_LIST = 'REMOVE_ISSUE_FROM_LIST'; export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 0c7dbc0d2ef..bb083158c8f 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -62,6 +62,14 @@ export default { state.error = s__('Boards|An error occurred while creating the list. Please try again.'); }, + [mutationTypes.RECEIVE_LABELS_FAILURE]: state => { + state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.'); + }, + + [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: state => { + state.error = s__('Boards|An error occurred while generating lists. Please reload the page.'); + }, + [mutationTypes.REQUEST_ADD_LIST]: () => { notImplemented(); }, @@ -85,16 +93,13 @@ export default { Vue.set(state, 'boardLists', backupList); }, - [mutationTypes.REQUEST_REMOVE_LIST]: () => { - notImplemented(); + [mutationTypes.REMOVE_LIST]: (state, listId) => { + Vue.delete(state.boardLists, listId); }, - [mutationTypes.RECEIVE_REMOVE_LIST_SUCCESS]: () => { - notImplemented(); - }, - - [mutationTypes.RECEIVE_REMOVE_LIST_ERROR]: () => { - notImplemented(); + [mutationTypes.REMOVE_LIST_FAILURE](state, listsBackup) { + state.error = s__('Boards|An error occurred while removing the list. Please try again.'); + state.boardLists = listsBackup; }, [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => { @@ -196,16 +201,28 @@ export default { notImplemented(); }, + [mutationTypes.CREATE_ISSUE_FAILURE]: state => { + state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); + }, + [mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => { - const listIssues = state.issuesByListId[list.id]; - listIssues.splice(position, 0, issue.id); - Vue.set(state.issuesByListId, list.id, listIssues); + addIssueToList({ + state, + listId: list.id, + issueId: issue.id, + atIndex: position, + }); Vue.set(state.issues, issue.id, issue); }, - [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issue }) => { + [mutationTypes.ADD_ISSUE_TO_LIST_FAILURE]: (state, { list, issueId }) => { state.error = s__('Boards|An error occurred while creating the issue. Please try again.'); + removeIssueFromList({ state, listId: list.id, issueId }); + }, + + [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => { removeIssueFromList({ state, listId: list.id, issueId: issue.id }); + Vue.delete(state.issues, issue.id); }, [mutationTypes.SET_CURRENT_PAGE]: () => { diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js index fa13d3a9e3c..347deb81846 100644 --- a/app/assets/javascripts/boards/toggle_focus.js +++ b/app/assets/javascripts/boards/toggle_focus.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import Vue from 'vue'; import { GlIcon } from '@gitlab/ui'; +import { hide } from '~/tooltips'; export default (ModalStore, boardsStore) => { const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board'); @@ -17,7 +18,9 @@ export default (ModalStore, boardsStore) => { }, methods: { toggleFocusMode() { - $(this.$refs.toggleFocusModeButton).tooltip('hide'); + const $el = $(this.$refs.toggleFocusModeButton); + hide($el); + issueBoardsContent.classList.toggle('is-focused'); this.isFullscreen = !this.isFullscreen; diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue index 2532f4b86d2..def45026b35 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue @@ -19,7 +19,11 @@ export default { type: String, required: true, }, - helpPagePath: { + lintHelpPagePath: { + type: String, + required: true, + }, + pipelineSimulationHelpPagePath: { type: String, required: true, }, @@ -27,6 +31,7 @@ export default { data() { return { content: '', + loading: false, valid: false, errors: null, warnings: null, @@ -44,6 +49,7 @@ export default { }, methods: { async lint() { + this.loading = true; try { const { data: { @@ -62,6 +68,8 @@ export default { } catch (error) { this.apiError = error; this.isErrorDismissed = false; + } finally { + this.loading = false; } }, clear() { @@ -93,6 +101,7 @@ export default { <div class="gl-display-flex gl-align-items-center"> <gl-button class="gl-mr-4" + :loading="loading" category="primary" variant="success" data-testid="ci-lint-validate" @@ -101,7 +110,7 @@ export default { > <gl-form-checkbox v-model="dryRun" >{{ __('Simulate a pipeline created for the default branch') }} - <gl-link :href="helpPagePath" target="_blank" + <gl-link :href="pipelineSimulationHelpPagePath" target="_blank" ><gl-icon class="gl-text-blue-600" name="question-o"/></gl-link ></gl-form-checkbox> </div> @@ -115,6 +124,7 @@ export default { :errors="errors" :warnings="warnings" :dry-run="dryRun" + :lint-help-page-path="lintHelpPagePath" /> </div> </template> diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue b/app/assets/javascripts/ci_lint/components/ci_lint_results.vue index 28b2a028b29..8b37c94de19 100644 --- a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue +++ b/app/assets/javascripts/ci_lint/components/ci_lint_results.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlTable } from '@gitlab/ui'; +import { GlAlert, GlLink, GlSprintf, GlTable } from '@gitlab/ui'; import CiLintWarnings from './ci_lint_warnings.vue'; import CiLintResultsValue from './ci_lint_results_value.vue'; import CiLintResultsParam from './ci_lint_results_param.vue'; @@ -8,8 +8,17 @@ import { __ } from '~/locale'; const thBorderColor = 'gl-border-gray-100!'; export default { - correct: { variant: 'success', text: __('syntax is correct') }, - incorrect: { variant: 'danger', text: __('syntax is incorrect') }, + correct: { + variant: 'success', + text: __('syntax is correct.'), + }, + incorrect: { + variant: 'danger', + text: __('syntax is incorrect.'), + }, + includesText: __( + 'CI configuration validated, including all configuration added with the %{codeStart}includes%{codeEnd} keyword. %{link}', + ), warningTitle: __('The form contains the following warning:'), fields: [ { @@ -25,6 +34,8 @@ export default { ], components: { GlAlert, + GlLink, + GlSprintf, GlTable, CiLintWarnings, CiLintResultsValue, @@ -51,6 +62,10 @@ export default { type: Boolean, required: true, }, + lintHelpPagePath: { + type: String, + required: true, + }, }, data() { return { @@ -82,8 +97,20 @@ export default { :title="__('Status:')" :dismissible="false" data-testid="ci-lint-status" - >{{ status.text }}</gl-alert - > + >{{ status.text }} + <gl-sprintf :message="$options.includesText"> + <template #code="{content}"> + <code> + {{ content }} + </code> + </template> + <template #link> + <gl-link :href="lintHelpPagePath" target="_blank"> + {{ __('More information') }} + </gl-link> + </template> + </gl-sprintf> + </gl-alert> <pre v-if="shouldShowError" diff --git a/app/assets/javascripts/ci_lint/graphql/resolvers.js b/app/assets/javascripts/ci_lint/graphql/resolvers.js new file mode 100644 index 00000000000..126b4c664b2 --- /dev/null +++ b/app/assets/javascripts/ci_lint/graphql/resolvers.js @@ -0,0 +1,34 @@ +import axios from '~/lib/utils/axios_utils'; + +const resolvers = { + Mutation: { + lintCI: (_, { endpoint, content, dry_run }) => { + return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ + valid: data.valid, + errors: data.errors, + warnings: data.warnings, + jobs: data.jobs.map(job => { + const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null; + + return { + name: job.name, + stage: job.stage, + beforeScript: job.before_script, + script: job.script, + afterScript: job.after_script, + tagList: job.tag_list, + environment: job.environment, + when: job.when, + allowFailure: job.allow_failure, + only, + except: job.except, + __typename: 'CiLintJob', + }; + }), + __typename: 'CiLintContent', + })); + }, + }, +}; + +export default resolvers; diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js index c41e6d47d75..e4cda4cb369 100644 --- a/app/assets/javascripts/ci_lint/index.js +++ b/app/assets/javascripts/ci_lint/index.js @@ -1,48 +1,18 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import axios from '~/lib/utils/axios_utils'; import createDefaultClient from '~/lib/graphql'; import CiLint from './components/ci_lint.vue'; +import resolvers from './graphql/resolvers'; Vue.use(VueApollo); -const resolvers = { - Mutation: { - lintCI: (_, { endpoint, content, dry_run }) => { - return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({ - valid: data.valid, - errors: data.errors, - warnings: data.warnings, - jobs: data.jobs.map(job => ({ - name: job.name, - stage: job.stage, - beforeScript: job.before_script, - script: job.script, - afterScript: job.after_script, - tagList: job.tag_list, - environment: job.environment, - when: job.when, - allowFailure: job.allow_failure, - only: { - refs: job.only.refs, - __typename: 'CiLintJobOnlyPolicy', - }, - except: job.except, - __typename: 'CiLintJob', - })), - __typename: 'CiLintContent', - })); - }, - }, -}; - const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(resolvers), }); export default (containerId = '#js-ci-lint') => { const containerEl = document.querySelector(containerId); - const { endpoint, helpPagePath } = containerEl.dataset; + const { endpoint, lintHelpPagePath, pipelineSimulationHelpPagePath } = containerEl.dataset; return new Vue({ el: containerEl, @@ -51,7 +21,8 @@ export default (containerId = '#js-ci-lint') => { return createElement(CiLint, { props: { endpoint, - helpPagePath, + lintHelpPagePath, + pipelineSimulationHelpPagePath, }, }); }, diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js deleted file mode 100644 index b8bf363fc9d..00000000000 --- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js +++ /dev/null @@ -1,128 +0,0 @@ -import { escape } from 'lodash'; -import axios from '../lib/utils/axios_utils'; -import { s__ } from '../locale'; -import { deprecatedCreateFlash as Flash } from '../flash'; -import { parseBoolean } from '../lib/utils/common_utils'; -import statusCodes from '../lib/utils/http_status'; -import VariableList from './ci_variable_list'; - -function generateErrorBoxContent(errors) { - const errorList = [].concat(errors).map( - errorString => ` - <li> - ${escape(errorString)} - </li> - `, - ); - - return ` - <p> - ${s__('CiVariable|Validation failed')} - </p> - <ul> - ${errorList.join('')} - </ul> - `; -} - -// Used for the variable list on CI/CD projects/groups settings page -export default class AjaxVariableList { - constructor({ - container, - saveButton, - errorBox, - formField = 'variables', - saveEndpoint, - maskableRegex, - }) { - this.container = container; - this.saveButton = saveButton; - this.errorBox = errorBox; - this.saveEndpoint = saveEndpoint; - this.maskableRegex = maskableRegex; - - this.variableList = new VariableList({ - container: this.container, - formField, - maskableRegex, - }); - - this.bindEvents(); - this.variableList.init(); - } - - bindEvents() { - this.saveButton.addEventListener('click', this.onSaveClicked.bind(this)); - } - - onSaveClicked() { - const loadingIcon = this.saveButton.querySelector('.js-ci-variables-save-loading-icon'); - loadingIcon.classList.toggle('hide', false); - this.errorBox.classList.toggle('hide', true); - // We use this to prevent a user from changing a key before we have a chance - // to match it up in `updateRowsWithPersistedVariables` - this.variableList.toggleEnableRow(false); - - return axios - .patch( - this.saveEndpoint, - { - variables_attributes: this.variableList.getAllData(), - }, - { - // We want to be able to process the `res.data` from a 400 error response - // and print the validation messages such as duplicate variable keys - validateStatus: status => - (status >= statusCodes.OK && status < statusCodes.MULTIPLE_CHOICES) || - status === statusCodes.BAD_REQUEST, - }, - ) - .then(res => { - loadingIcon.classList.toggle('hide', true); - this.variableList.toggleEnableRow(true); - - if (res.status === statusCodes.OK && res.data) { - this.updateRowsWithPersistedVariables(res.data.variables); - this.variableList.hideValues(); - } else if (res.status === statusCodes.BAD_REQUEST) { - // Validation failed - this.errorBox.innerHTML = generateErrorBoxContent(res.data); - this.errorBox.classList.toggle('hide', false); - } - }) - .catch(() => { - loadingIcon.classList.toggle('hide', true); - this.variableList.toggleEnableRow(true); - Flash(s__('CiVariable|Error occurred while saving variables')); - }); - } - - updateRowsWithPersistedVariables(persistedVariables = []) { - const persistedVariableMap = [].concat(persistedVariables).reduce( - (variableMap, variable) => ({ - ...variableMap, - [variable.key]: variable, - }), - {}, - ); - - this.container.querySelectorAll('.js-row').forEach(row => { - // If we submitted a row that was destroyed, remove it so we don't try - // to destroy it again which would cause a BE error - const destroyInput = row.querySelector('.js-ci-variable-input-destroy'); - if (parseBoolean(destroyInput.value)) { - row.remove(); - // Update the ID input so any future edits and `_destroy` will apply on the BE - } else { - const key = row.querySelector('.js-ci-variable-input-key').value; - const persistedVariable = persistedVariableMap[key]; - - if (persistedVariable) { - // eslint-disable-next-line no-param-reassign - row.querySelector('.js-ci-variable-input-id').value = persistedVariable.id; - row.setAttribute('data-is-persisted', 'true'); - } - } - }); - } -} diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue index ceb94b1f0f8..83e9717041f 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue @@ -60,7 +60,7 @@ export default { </script> <template> <gl-dropdown :text="value"> - <gl-search-box-by-type v-model.trim="searchTerm" /> + <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" /> <gl-dropdown-item v-for="environment in filteredResults" :key="environment" @@ -75,7 +75,7 @@ export default { }}</gl-dropdown-item> <template v-if="shouldRenderCreateButton"> <gl-dropdown-divider /> - <gl-dropdown-item @click="createClicked"> + <gl-dropdown-item data-testid="create-wildcard-button" @click="createClicked"> {{ composedCreateButtonLabel }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index a2f4bea2f61..da816f85466 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -236,6 +236,7 @@ export default { :label="__('Environment scope')" label-for="ci-variable-env" class="w-50" + data-testid="environment-scope" > <ci-environments-dropdown class="w-100" @@ -247,7 +248,11 @@ export default { </div> <gl-form-group :label="__('Flags')" label-for="ci-variable-flags"> - <gl-form-checkbox v-model="protected_variable" class="mb-0"> + <gl-form-checkbox + v-model="protected_variable" + class="mb-0" + data-testid="ci-variable-protected-checkbox" + > {{ __('Protect variable') }} <gl-link target="_blank" :href="protectedEnvironmentVariablesLink"> <gl-icon name="question" :size="12" /> @@ -261,6 +266,7 @@ export default { ref="masked-ci-variable" v-model="masked" data-qa-selector="ci_variable_masked_checkbox" + data-testid="ci-variable-masked-checkbox" > {{ __('Mask variable') }} <gl-link target="_blank" :href="maskedEnvironmentVariablesLink"> diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 412260da958..471c1a0b4a2 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -343,7 +343,7 @@ export default { > <span v-else class="js-cluster-application-title">{{ title }}</span> </strong> - <slot name="installedVia"></slot> + <slot name="installed-via"></slot> <div> <slot name="description"></slot> </div> diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index b03cf6fc31b..271d862afab 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -467,6 +467,17 @@ export default { notebooks to a class of students, a corporate data science group, or a scientific research group.`) }} + <gl-sprintf + :message=" + s__( + 'ClusterIntegration|%{boldStart}Note:%{boldEnd} Requires Ingress to be installed.', + ) + " + > + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> </p> <template v-if="ingressExternalEndpoint"> @@ -549,8 +560,8 @@ export default { @set="setKnativeDomain" /> </template> - <template v-if="cloudRun" #installedVia> - <span data-testid="installedVia"> + <template v-if="cloudRun" #installed-via> + <span data-testid="installed-via"> <gl-sprintf :message="s__('ClusterIntegration|installed via %{linkStart}Cloud Run%{linkEnd}')" > diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index f8fb58cdca2..08fd7db40a1 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -1,17 +1,17 @@ <script> import { mapState, mapActions } from 'vuex'; import { - GlDeprecatedBadge as GlBadge, + GlBadge, GlLink, GlLoadingIcon, GlPagination, GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlTable, + GlTooltipDirective, } from '@gitlab/ui'; import AncestorNotice from './ancestor_notice.vue'; import NodeErrorHelpText from './node_error_help_text.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import { __, sprintf } from '~/locale'; @@ -30,7 +30,7 @@ export default { NodeErrorHelpText, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, computed: { ...mapState([ @@ -227,7 +227,7 @@ export default { <gl-loading-icon v-if="item.status === 'deleting' || item.status === 'creating'" - v-tooltip + v-gl-tooltip :title="statusTitle(item.status)" size="sm" /> @@ -294,7 +294,7 @@ export default { </template> <template #cell(cluster_type)="{value}"> - <gl-badge variant="light"> + <gl-badge variant="muted"> {{ value }} </gl-badge> </template> diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 45ac1bafd61..1c1f0664885 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -5,6 +5,7 @@ import { __ } from './locale'; import axios from './lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from './flash'; import { capitalizeFirstCharacter } from './lib/utils/text_utility'; +import { fixTitle } from '~/tooltips'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { @@ -76,7 +77,7 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( $dropdownContainer.on('click', '.dropdown-content a', e => { $dropdown.prop('title', e.target.text.replace(/_+?/g, '-')); if ($dropdown.hasClass('has-tooltip')) { - $dropdown.tooltip('_fixTitle'); + fixTitle($dropdown); } }); }); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index d403f370f9d..2858561e033 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -1,15 +1,12 @@ <script> import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex'; -import { GlFormInput, GlFormCheckbox, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlFormGroup, GlFormInput, GlFormCheckbox, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import { s__ } from '~/locale'; import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles'); -const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers( - 'regions', -); const { mapState: mapKeyPairsState, mapActions: mapKeyPairsActions } = createNamespacedHelpers( 'keyPairs', ); @@ -27,6 +24,7 @@ export default { components: { ClusterFormDropdown, GlFormCheckbox, + GlFormGroup, GlFormInput, GlIcon, GlLink, @@ -60,11 +58,10 @@ export default { ), roleDropdownHelpPath: 'https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role', - regionsDropdownHelpText: s__( - 'ClusterIntegration|Learn more about %{linkStart}Regions%{linkEnd}.', + regionInputLabel: s__('ClusterIntegration|Cluster Region'), + regionHelpText: s__( + 'ClusterIntegration|The region the new cluster will be created in. You must reauthenticate to change regions.', ), - regionsDropdownHelpPath: - 'https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/', keyPairDropdownHelpText: s__( 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{linkStart}Amazon Web Services%{linkEnd}.', ), @@ -117,11 +114,6 @@ export default { isLoadingRoles: 'isLoadingItems', loadingRolesError: 'loadingItemsError', }), - ...mapRegionsState({ - regions: 'items', - isLoadingRegions: 'isLoadingItems', - loadingRegionsError: 'loadingItemsError', - }), ...mapKeyPairsState({ keyPairs: 'items', isLoadingKeyPairs: 'isLoadingItems', @@ -195,8 +187,8 @@ export default { }, }, mounted() { - this.fetchRegions(); this.fetchRoles(); + this.setRegionAndFetchVpcsAndKeyPairs(); }, methods: { ...mapActions([ @@ -215,20 +207,18 @@ export default { 'setGitlabManagedCluster', 'setNamespacePerEnvironment', ]), - ...mapRegionsActions({ fetchRegions: 'fetchItems' }), ...mapVpcActions({ fetchVpcs: 'fetchItems' }), ...mapSubnetActions({ fetchSubnets: 'fetchItems' }), ...mapRolesActions({ fetchRoles: 'fetchItems' }), ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }), ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }), - setRegionAndFetchVpcsAndKeyPairs(region) { - this.setRegion({ region }); + setRegionAndFetchVpcsAndKeyPairs() { this.setVpc({ vpc: null }); this.setKeyPair({ keyPair: null }); this.setSubnet({ subnet: [] }); this.setSecurityGroup({ securityGroup: null }); - this.fetchVpcs({ region }); - this.fetchKeyPairs({ region }); + this.fetchVpcs({ region: this.selectedRegion }); + this.fetchKeyPairs({ region: this.selectedRegion }); }, setVpcAndFetchSubnets(vpc) { this.setVpc({ vpc }); @@ -314,33 +304,12 @@ export default { </gl-sprintf> </p> </div> - <div class="form-group"> - <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label> - <cluster-form-dropdown - field-id="eks-region" - field-name="eks-region" - :value="selectedRegion" - :items="regions" - :loading="isLoadingRegions" - :loading-text="s__('ClusterIntegration|Loading Regions')" - :placeholder="s__('ClusterIntergation|Select a region')" - :search-field-placeholder="s__('ClusterIntegration|Search regions')" - :empty-text="s__('ClusterIntegration|No region found')" - :has-errors="Boolean(loadingRegionsError)" - :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" - @input="setRegionAndFetchVpcsAndKeyPairs($event)" - /> - <p class="form-text text-muted"> - <gl-sprintf :message="$options.i18n.regionsDropdownHelpText"> - <template #link="{ content }"> - <gl-link :href="$options.i18n.regionsDropdownHelpPath" target="_blank"> - {{ content }} - <gl-icon name="external-link" class="gl-vertical-align-middle" /> - </gl-link> - </template> - </gl-sprintf> - </p> - </div> + <gl-form-group + :label="$options.i18n.regionInputLabel" + :description="$options.i18n.regionHelpText" + > + <gl-form-input id="eks-region" :value="selectedRegion" type="text" readonly /> + </gl-form-group> <div class="form-group"> <label class="label-bold" for="eks-key-pair">{{ s__('ClusterIntegration|Key pair name') diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index 5c13cbb2775..a3f76241bf2 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -1,15 +1,20 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlFormInput, GlButton } from '@gitlab/ui'; +import { GlButton, GlFormGroup, GlFormInput, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import { escape } from 'lodash'; import { mapState, mapActions } from 'vuex'; +import { DEFAULT_REGION } from '../constants'; import { sprintf, s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; export default { components: { - GlFormInput, GlButton, + GlFormGroup, + GlFormInput, + GlIcon, + GlLink, + GlSprintf, ClipboardButton, }, props: { @@ -26,9 +31,18 @@ export default { required: true, }, }, + i18n: { + regionInputLabel: s__('ClusterIntegration|Cluster Region'), + regionHelpPath: 'https://aws.amazon.com/about-aws/global-infrastructure/regions_az/', + regionHelpText: s__( + 'ClusterIntegration|Select the region you want to create the new cluster in. Make sure you have access to this region for your role to be able to authenticate. If no region is selected, we will use %{codeStart}DEFAULT_REGION%{codeEnd}. Learn more about %{linkStart}Regions%{linkEnd}.', + ), + regionHelpTextDefaultRegion: DEFAULT_REGION, + }, data() { return { roleArn: this.$store.state.roleArn, + selectedRegion: this.$store.state.selectedRegion, }; }, computed: { @@ -130,13 +144,33 @@ export default { <gl-form-input id="eks-provision-role-arn" v-model="roleArn" /> <p class="form-text text-muted" v-html="provisionRoleArnHelpText"></p> </div> + + <gl-form-group :label="$options.i18n.regionInputLabel"> + <gl-form-input id="eks-region" v-model="selectedRegion" type="text" /> + + <template #description> + <gl-sprintf :message="$options.i18n.regionHelpText"> + <template #code> + <code>{{ $options.i18n.regionHelpTextDefaultRegion }}</code> + </template> + + <template #link="{ content }"> + <gl-link :href="$options.i18n.regionHelpPath" target="_blank"> + {{ content }} + <gl-icon name="external-link" /> + </gl-link> + </template> + </gl-sprintf> + </template> + </gl-form-group> + <gl-button variant="success" category="primary" type="submit" :disabled="submitButtonDisabled" :loading="isCreatingRole" - @click.prevent="createRole({ roleArn, externalId })" + @click.prevent="createRole({ roleArn, selectedRegion, externalId })" > {{ submitButtonLabel }} </gl-button> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js index 471d6e1f0aa..0f0db2090c1 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js @@ -1,3 +1,5 @@ +export const DEFAULT_REGION = 'us-east-2'; + export const KUBERNETES_VERSIONS = [ { name: '1.14', value: '1.14' }, { name: '1.15', value: '1.15' }, diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js index 601ff6f9adc..58568b5dedb 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js @@ -8,13 +8,8 @@ const lookupVpcName = ({ Tags: tags, VpcId: id }) => { return nameTag ? nameTag.Value : id; }; -export const DEFAULT_REGION = 'us-east-2'; - export const setAWSConfig = ({ awsCredentials }) => { - AWS.config = { - ...awsCredentials, - region: DEFAULT_REGION, - }; + AWS.config = awsCredentials; }; export const fetchRoles = () => { @@ -26,20 +21,6 @@ export const fetchRoles = () => { .then(({ Roles: roles }) => roles.map(({ RoleName: name, Arn: value }) => ({ name, value }))); }; -export const fetchRegions = () => { - const ec2 = new EC2(); - - return ec2 - .describeRegions() - .promise() - .then(({ Regions: regions }) => - regions.map(({ RegionName: name }) => ({ - name, - value: name, - })), - ); -}; - export const fetchKeyPairs = ({ region }) => { const ec2 = new EC2({ region }); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 48c85ff627f..f3950a3343a 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,4 +1,5 @@ import * as types from './mutation_types'; +import { DEFAULT_REGION } from '../constants'; import { setAWSConfig } from '../services/aws_services_facade'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; @@ -25,12 +26,22 @@ export const setKubernetesVersion = ({ commit }, payload) => { export const createRole = ({ dispatch, state: { createRolePath } }, payload) => { dispatch('requestCreateRole'); + const region = payload.selectedRegion || DEFAULT_REGION; + return axios .post(createRolePath, { role_arn: payload.roleArn, role_external_id: payload.externalId, + region, + }) + .then(({ data }) => { + const awsData = { + ...convertObjectPropsToCamelCase(data), + region, + }; + + dispatch('createRoleSuccess', awsData); }) - .then(({ data }) => dispatch('createRoleSuccess', convertObjectPropsToCamelCase(data))) .catch(error => dispatch('createRoleError', { error })); }; @@ -38,7 +49,8 @@ export const requestCreateRole = ({ commit }) => { commit(types.REQUEST_CREATE_ROLE); }; -export const createRoleSuccess = ({ commit }, awsCredentials) => { +export const createRoleSuccess = ({ dispatch, commit }, awsCredentials) => { + dispatch('setRegion', { region: awsCredentials.region }); setAWSConfig({ awsCredentials }); commit(types.CREATE_ROLE_SUCCESS); }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js index 8dc55506dc2..262bbb3167a 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -8,7 +8,6 @@ import clusterDropdownStore from '~/create_cluster/store/cluster_dropdown'; import { fetchRoles, - fetchRegions, fetchKeyPairs, fetchVpcs, fetchSubnets, @@ -26,10 +25,6 @@ const createStore = ({ initialState }) => namespaced: true, ...clusterDropdownStore({ fetchFn: fetchRoles }), }, - regions: { - namespaced: true, - ...clusterDropdownStore({ fetchFn: fetchRegions }), - }, keyPairs: { namespaced: true, ...clusterDropdownStore({ fetchFn: fetchKeyPairs }), diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue index 979628d683d..85d9f0d66ab 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { GlSprintf, GlLink } from '@gitlab/ui'; +import { GlSprintf, GlLink, GlIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; import gkeDropdownMixin from './gke_dropdown_mixin'; @@ -10,6 +10,7 @@ export default { components: { GlSprintf, GlLink, + GlIcon, }, mixins: [gkeDropdownMixin], props: { @@ -178,14 +179,14 @@ export default { 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral' " target="_blank" - >{{ content }} <i class="fa fa-external-link" aria-hidden="true"></i - ></gl-link> + >{{ content }} <gl-icon name="external-link" aria-hidden="true" + /></gl-link> </template> <template #docsLink="{ content }"> <gl-link :href="docsUrl" target="_blank" - >{{ content }} <i class="fa fa-external-link" aria-hidden="true"></i - ></gl-link> + >{{ content }} <gl-icon name="external-link" aria-hidden="true" + /></gl-link> </template> <template #error> diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 010a6b073f9..49091f5f140 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -168,9 +168,6 @@ export default class CreateMergeRequestDropdown { disable() { this.disableCreateAction(); - - this.dropdownToggle.classList.add('disabled'); - this.dropdownToggle.setAttribute('disabled', 'disabled'); } disableCreateAction() { @@ -189,9 +186,6 @@ export default class CreateMergeRequestDropdown { this.createTargetButton.classList.remove('disabled'); this.createTargetButton.removeAttribute('disabled'); - - this.dropdownToggle.classList.remove('disabled'); - this.dropdownToggle.removeAttribute('disabled'); } static findByValue(objects, ref, returnFirstMatch = false) { diff --git a/app/assets/javascripts/dependency_proxy.js b/app/assets/javascripts/dependency_proxy.js new file mode 100644 index 00000000000..ddf5703b28f --- /dev/null +++ b/app/assets/javascripts/dependency_proxy.js @@ -0,0 +1,5 @@ +import setupToggleButtons from '~/toggle_buttons'; + +export default () => { + setupToggleButtons(document.querySelector('.js-dependency-proxy-toggle-area')); +}; diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index 5b41d23bd27..16eee094108 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -1,8 +1,7 @@ <script> import { head, tail } from 'lodash'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import actionBtn from './action_btn.vue'; @@ -13,7 +12,7 @@ export default { GlIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, mixins: [timeagoMixin], props: { @@ -125,7 +124,7 @@ export default { <div class="table-mobile-content deploy-project-list"> <template v-if="projects.length > 0"> <a - v-tooltip + v-gl-tooltip :title="projectTooltipTitle(firstProject)" class="label deploy-project-label" > @@ -134,7 +133,7 @@ export default { </a> <a v-if="isExpandable" - v-tooltip + v-gl-tooltip :title="restProjectsTooltip" class="label deploy-project-label" @click="toggleExpanded" @@ -145,7 +144,7 @@ export default { v-for="deployKeysProject in restProjects" v-else-if="isExpanded" :key="deployKeysProject.project.full_path" - v-tooltip + v-gl-tooltip :href="deployKeysProject.project.full_path" :title="projectTooltipTitle(deployKeysProject)" class="label deploy-project-label" @@ -160,7 +159,7 @@ export default { <div class="table-section section-15 text-right"> <div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div> <div class="table-mobile-content text-secondary key-created-at"> - <span v-tooltip :title="tooltipTitle(deployKey.created_at)"> + <span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)"> <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.created_at) }}</span> </span> </div> @@ -172,7 +171,7 @@ export default { </action-btn> <a v-if="deployKey.can_edit" - v-tooltip + v-gl-tooltip :href="editDeployKeyPath" :title="__('Edit')" class="btn btn-default text-secondary" @@ -182,7 +181,7 @@ export default { </a> <action-btn v-if="isRemovable" - v-tooltip + v-gl-tooltip :deploy-key="deployKey" :title="__('Remove')" btn-css-class="btn-danger" @@ -193,7 +192,7 @@ export default { </action-btn> <action-btn v-else-if="isEnabled" - v-tooltip + v-gl-tooltip :deploy-key="deployKey" :title="__('Disable')" btn-css-class="btn-warning" diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue index 7ae569216f0..5d32bfd4a73 100644 --- a/app/assets/javascripts/design_management/components/design_destroyer.vue +++ b/app/assets/javascripts/design_management/components/design_destroyer.vue @@ -1,6 +1,6 @@ <script> import { ApolloMutation } from 'vue-apollo'; -import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql'; import { updateStoreAfterDesignsDelete } from '../utils/cache_update'; diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 845f1aec8cf..6aab4bf423e 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -210,7 +210,7 @@ export default { :class="{ 'gl-bg-blue-50': isDiscussionActive }" @error="$emit('update-note-error', $event)" > - <template v-if="discussion.resolvable" #resolveDiscussion> + <template v-if="discussion.resolvable" #resolve-discussion> <button v-gl-tooltip :class="{ 'is-active': discussion.resolved }" @@ -224,7 +224,7 @@ export default { <gl-loading-icon v-else inline /> </button> </template> - <template v-if="discussion.resolved" #resolvedStatus> + <template v-if="discussion.resolved" #resolved-status> <p class="gl-text-gray-500 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message"> {{ __('Resolved by') }} <gl-link @@ -277,7 +277,7 @@ export default { @submit-form="mutate" @cancel-form="hideForm" > - <template v-if="discussion.resolvable" #resolveCheckbox> + <template v-if="discussion.resolvable" #resolve-checkbox> <label data-testid="resolve-checkbox"> <input v-model="shouldChangeResolvedStatus" type="checkbox" /> {{ resolveCheckboxText }} diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index 7f4b3b31024..421a4dc274a 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -108,7 +108,7 @@ export default { </span> </div> <div class="gl-display-flex gl-align-items-baseline"> - <slot name="resolveDiscussion"></slot> + <slot name="resolve-discussion"></slot> <button v-if="isEditButtonVisible" v-gl-tooltip @@ -127,7 +127,7 @@ export default { class="note-text js-note-text md" data-qa-selector="note_content" ></div> - <slot name="resolvedStatus"></slot> + <slot name="resolved-status"></slot> </template> <apollo-mutation v-else diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 3754e1dbbc1..7aaac58a1ce 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -110,7 +110,7 @@ export default { </textarea> </template> </markdown-field> - <slot name="resolveCheckbox"></slot> + <slot name="resolve-checkbox"></slot> <div class="note-form-actions gl-display-flex gl-justify-content-space-between"> <gl-button ref="submitButton" diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 88f3ce0b8ea..3c2ce693bc0 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -112,9 +112,9 @@ export default { }, canMoveNote(note) { const { userPermissions } = note; - const { adminNote } = userPermissions || {}; + const { repositionNote } = userPermissions || {}; - return Boolean(adminNote); + return Boolean(repositionNote); }, isPositionInOverlay(position) { const { top, left } = this.getNoteRelativePosition(position); diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue index 8d26f84641e..85c6bd4d79e 100644 --- a/app/assets/javascripts/design_management/components/design_scaler.vue +++ b/app/assets/javascripts/design_management/components/design_scaler.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlButtonGroup, GlButton } from '@gitlab/ui'; const SCALE_STEP_SIZE = 0.2; const DEFAULT_SCALE = 1; @@ -8,7 +8,8 @@ const MAX_SCALE = 2; export default { components: { - GlIcon, + GlButtonGroup, + GlButton, }, data() { return { @@ -49,17 +50,9 @@ export default { </script> <template> - <div class="design-scaler btn-group" role="group"> - <button class="btn" :disabled="disableDecrease" @click="decrementScale"> - <span class="gl-display-flex gl-justify-content-center gl-align-items-center gl-icon s16"> - – - </span> - </button> - <button class="btn" :disabled="disableReset" @click="resetScale"> - <gl-icon name="redo" /> - </button> - <button class="btn" :disabled="disableIncrease" @click="incrementScale"> - <gl-icon name="plus" /> - </button> - </div> + <gl-button-group class="gl-z-index-1"> + <gl-button icon="dash" :disabled="disableDecrease" @click="decrementScale" /> + <gl-button icon="redo" :disabled="disableReset" @click="resetScale" /> + <gl-button icon="plus" :disabled="disableIncrease" @click="incrementScale" /> + </gl-button-group> </template> diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index fb8e74c8c4c..41dcec38abe 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -207,6 +207,6 @@ export default { /> </gl-collapse> </template> - <slot name="replyForm"></slot> + <slot name="reply-form"></slot> </div> </template> diff --git a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue index 2719d701c12..4edc2e410c7 100644 --- a/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue +++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue @@ -1,7 +1,7 @@ <script> /* global Mousetrap */ import 'mousetrap'; -import { GlButton, GlButtonGroup } from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import allDesignsMixin from '../../mixins/all_designs'; import { DESIGN_ROUTE_NAME } from '../../router/constants'; @@ -11,6 +11,9 @@ export default { GlButton, GlButtonGroup, }, + directives: { + GlTooltip: GlTooltipDirective, + }, mixins: [allDesignsMixin], props: { id: { @@ -68,6 +71,7 @@ export default { {{ paginationText }} <gl-button-group class="gl-mx-5"> <gl-button + v-gl-tooltip.bottom :disabled="!previousDesign" :title="s__('DesignManagement|Go to previous design')" icon="angle-left" @@ -75,6 +79,7 @@ export default { @click="navigateToDesign(previousDesign)" /> <gl-button + v-gl-tooltip.bottom :disabled="!nextDesign" :title="s__('DesignManagement|Go to next design')" icon="angle-right" diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index 8d25d467d59..4caee863df8 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -1,10 +1,10 @@ <script> -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql'; import { __, sprintf } from '~/locale'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import DesignNavigation from './design_navigation.vue'; import DeleteButton from '../delete_button.vue'; -import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; export default { @@ -14,6 +14,9 @@ export default { DesignNavigation, DeleteButton, }, + directives: { + GlTooltip: GlTooltipDirective, + }, mixins: [timeagoMixin], props: { id: { @@ -112,14 +115,21 @@ export default { </div> </div> <design-navigation :id="id" class="gl-ml-auto gl-flex-shrink-0" /> - <gl-button :href="image" icon="download" /> + <gl-button + v-gl-tooltip.bottom + :href="image" + icon="download" + :title="s__('DesignManagement|Download design')" + /> <delete-button v-if="isLatestVersion && canDeleteDesign" + v-gl-tooltip.bottom class="gl-ml-3" :is-deleting="isDeleting" button-variant="warning" button-icon="archive" button-category="secondary" + :title="s__('DesignManagement|Archive design')" @deleteSelectedDesigns="$emit('delete')" /> </header> diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue index c76041c74a8..d7b287f663b 100644 --- a/app/assets/javascripts/design_management/components/upload/button.vue +++ b/app/assets/javascripts/design_management/components/upload/button.vue @@ -1,11 +1,10 @@ <script> -import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; export default { components: { GlButton, - GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -38,12 +37,12 @@ export default { ) " :disabled="isSaving" + :loading="isSaving" variant="default" size="small" @click="openFileUpload" > {{ s__('DesignManagement|Upload designs') }} - <gl-loading-icon v-if="isSaving" inline class="ml-1" /> </gl-button> <input diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js index 63a92ef5ec0..92928ca429f 100644 --- a/app/assets/javascripts/design_management/constants.js +++ b/app/assets/javascripts/design_management/constants.js @@ -5,9 +5,6 @@ export const VALID_DESIGN_FILE_MIMETYPE = { regex: /image\/.+/, }; -// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types -export const VALID_DATA_TRANSFER_TYPE = 'Files'; - export const ACTIVE_DISCUSSION_SOURCE_TYPES = { pin: 'pin', discussion: 'discussion', diff --git a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql index c243e39f3d3..e599ab19c2d 100644 --- a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql +++ b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql @@ -1,3 +1,4 @@ fragment DesignNotePermissions on NotePermissions { adminNote + repositionNote } diff --git a/app/assets/javascripts/design_management/graphql/mutations/reposition_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/reposition_image_diff_note.mutation.graphql new file mode 100644 index 00000000000..78fbcf1c3c7 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/reposition_image_diff_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/design_note.fragment.graphql" + +mutation repositionImageDiffNote($input: RepositionImageDiffNoteInput!) { + repositionImageDiffNote(input: $input) { + errors + note { + ...DesignNote + } + } +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql deleted file mode 100644 index 5562ca9d89f..00000000000 --- a/app/assets/javascripts/design_management/graphql/mutations/update_image_diff_note.mutation.graphql +++ /dev/null @@ -1,10 +0,0 @@ -#import "../fragments/design_note.fragment.graphql" - -mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) { - updateImageDiffNote(input: $input) { - errors - note { - ...DesignNote - } - } -} diff --git a/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql b/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql deleted file mode 100644 index a87b256dc95..00000000000 --- a/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql +++ /dev/null @@ -1,10 +0,0 @@ -query permissions($fullPath: ID!, $iid: String!) { - project(fullPath: $fullPath) { - id - issue(iid: $iid) { - userPermissions { - createDesign - } - } - } -} diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql index 96869a404b1..99a61191c6e 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql @@ -1,7 +1,12 @@ #import "../fragments/design.fragment.graphql" #import "~/graphql_shared/fragments/author.fragment.graphql" -query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) { +query getDesign( + $fullPath: ID! + $iid: String! + $atVersion: DesignManagementVersionID + $filenames: [String!] +) { project(fullPath: $fullPath) { id issue(iid: $iid) { diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql deleted file mode 100644 index efa61edf51a..00000000000 --- a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql +++ /dev/null @@ -1,23 +0,0 @@ -#import "../fragments/design_list.fragment.graphql" -#import "../fragments/version.fragment.graphql" - -query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { - project(fullPath: $fullPath) { - id - issue(iid: $iid) { - designCollection { - copyState - designs(atVersion: $atVersion) { - nodes { - ...DesignListItem - } - } - versions { - nodes { - ...VersionListItem - } - } - } - } - } -} diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js index 62bcf216add..466f61e21fa 100644 --- a/app/assets/javascripts/design_management/mixins/all_designs.js +++ b/app/assets/javascripts/design_management/mixins/all_designs.js @@ -1,7 +1,7 @@ import { propertyOf } from 'lodash'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; +import createFlash, { FLASH_TYPES } from '~/flash'; import { s__ } from '~/locale'; -import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; import allVersionsMixin from './all_versions'; import { DESIGNS_ROUTE_NAME } from '../router/constants'; @@ -36,20 +36,20 @@ export default { }, result() { if (this.$route.query.version && !this.hasValidVersion) { - createFlash( - s__( + createFlash({ + message: s__( 'DesignManagement|Requested design version does not exist. Showing latest version instead', ), - ); + }); this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } }); } if (this.designCollection.copyState === 'ERROR') { - createFlash( - s__( + createFlash({ + message: s__( 'DesignManagement|There was an error moving your designs. Please upload your designs below.', ), - 'warning', - ); + type: FLASH_TYPES.WARNING, + }); } }, }, diff --git a/app/assets/javascripts/design_management/mixins/all_versions.js b/app/assets/javascripts/design_management/mixins/all_versions.js index 7a094f23378..07cd0fc92bd 100644 --- a/app/assets/javascripts/design_management/mixins/all_versions.js +++ b/app/assets/javascripts/design_management/mixins/all_versions.js @@ -1,4 +1,4 @@ -import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; import { findVersionId } from '../utils/design_management_utils'; export default { diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 6a96b06dcd8..e07279ba39d 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -2,7 +2,7 @@ import Mousetrap from 'mousetrap'; import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import allVersionsMixin from '../../mixins/all_versions'; import Toolbar from '../../components/toolbar/index.vue'; @@ -13,18 +13,19 @@ import DesignReplyForm from '../../components/design_notes/design_reply_form.vue import DesignSidebar from '../../components/design_sidebar.vue'; import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql'; -import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql'; +import repositionImageDiffNoteMutation from '../../graphql/mutations/reposition_image_diff_note.mutation.graphql'; import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; import { extractDiscussions, extractDesign, - updateImageDiffNoteOptimisticResponse, + repositionImageDiffNoteOptimisticResponse, toDiffNoteGid, extractDesignNoteId, + getPageLayoutElement, } from '../../utils/design_management_utils'; import { updateStoreAfterAddImageDiffNote, - updateStoreAfterUpdateImageDiffNote, + updateStoreAfterRepositionImageDiffNote, } from '../../utils/cache_update'; import { ADD_DISCUSSION_COMMENT_ERROR, @@ -38,7 +39,7 @@ import { } from '../../utils/error_messages'; import { trackDesignDetailView } from '../../utils/tracking'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; -import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES, DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../../constants'; const DEFAULT_SCALE = 1; @@ -181,12 +182,12 @@ export default { updateImageDiffNoteInStore( store, { - data: { updateImageDiffNote }, + data: { repositionImageDiffNote }, }, ) { - return updateStoreAfterUpdateImageDiffNote( + return updateStoreAfterRepositionImageDiffNote( store, - updateImageDiffNote, + repositionImageDiffNote, getDesignQuery, this.designVariables, ); @@ -198,7 +199,7 @@ export default { ); const mutationPayload = { - optimisticResponse: updateImageDiffNoteOptimisticResponse(note, { + optimisticResponse: repositionImageDiffNoteOptimisticResponse(note, { position, }), variables: { @@ -207,7 +208,7 @@ export default { position, }, }, - mutation: updateImageDiffNoteMutation, + mutation: repositionImageDiffNoteMutation, update: this.updateImageDiffNoteInStore, }; @@ -229,7 +230,7 @@ export default { onQueryError(message) { // because we redirect user to /designs (the issue page), // we want to create these flashes on the issue page - createFlash(message); + createFlash({ message }); this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME }); }, onError(message, e) { @@ -300,6 +301,22 @@ export default { this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded; }, }, + beforeRouteEnter(to, from, next) { + const pageEl = getPageLayoutElement(); + if (pageEl) { + pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + } + + next(); + }, + beforeRouteLeave(to, from, next) { + const pageEl = getPageLayoutElement(); + if (pageEl) { + pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST); + } + + next(); + }, createImageDiffNoteMutation, DESIGNS_ROUTE_NAME, }; @@ -366,7 +383,7 @@ export default { @toggleResolvedComments="toggleResolvedComments" @todoError="onTodoError" > - <template #replyForm> + <template #reply-form> <apollo-mutation v-if="isAnnotating" #default="{ mutate, loading }" diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 6e71dca41e9..ea404692840 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -1,25 +1,26 @@ <script> -import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import VueDraggable from 'vuedraggable'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { s__, sprintf } from '~/locale'; +import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql'; +import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { __, s__, sprintf } from '~/locale'; import { getFilename } from '~/lib/utils/file_upload'; import UploadButton from '../components/upload/button.vue'; import DeleteButton from '../components/delete_button.vue'; import Design from '../components/list/item.vue'; import DesignDestroyer from '../components/design_destroyer.vue'; import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue'; -import DesignDropzone from '../components/upload/design_dropzone.vue'; +import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql'; import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql'; -import permissionsQuery from '../graphql/queries/design_permissions.query.graphql'; -import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; import allDesignsMixin from '../mixins/all_designs'; import { UPLOAD_DESIGN_ERROR, EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, MOVE_DESIGN_ERROR, + UPLOAD_DESIGN_INVALID_FILETYPE_ERROR, designUploadSkippedWarning, designDeletionError, } from '../utils/error_messages'; @@ -34,6 +35,7 @@ import { } from '../utils/design_management_utils'; import { trackDesignCreate, trackDesignUpdate } from '../utils/tracking'; import { DESIGNS_ROUTE_NAME } from '../router/constants'; +import { VALID_DESIGN_FILE_MIMETYPE } from '../constants'; const MAXIMUM_FILE_UPLOAD_LIMIT = 10; @@ -42,6 +44,8 @@ export default { GlLoadingIcon, GlAlert, GlButton, + GlSprintf, + GlLink, UploadButton, Design, DesignDestroyer, @@ -50,6 +54,11 @@ export default { DesignDropzone, VueDraggable, }, + dropzoneProps: { + dropToStartMessage: __('Drop your designs to start your upload.'), + isFileValid: isValidDesignFile, + validFileMimetypes: [VALID_DESIGN_FILE_MIMETYPE.mimetype], + }, mixins: [allDesignsMixin], apollo: { permissions: { @@ -139,8 +148,8 @@ export default { if (!this.canCreateDesign) return false; if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) { - createFlash( - sprintf( + createFlash({ + message: sprintf( s__( 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.', ), @@ -148,7 +157,7 @@ export default { upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT, }, ), - ); + }); return false; } @@ -191,7 +200,7 @@ export default { const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || []; const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles); if (skippedWarningMessage) { - createFlash(skippedWarningMessage, 'warning'); + createFlash({ message: skippedWarningMessage, types: FLASH_TYPES.WARNING }); } // if this upload resulted in a new version being created, redirect user to the latest version @@ -214,7 +223,7 @@ export default { }, onUploadDesignError() { this.resetFilesToBeSaved(); - createFlash(UPLOAD_DESIGN_ERROR); + createFlash({ message: UPLOAD_DESIGN_ERROR }); }, changeSelectedDesigns(filename) { if (this.isDesignSelected(filename)) { @@ -245,18 +254,21 @@ export default { }, onDesignDeleteError() { const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 }); - createFlash(errorMessage); + createFlash({ message: errorMessage }); + }, + onDesignDropzoneError() { + createFlash({ message: UPLOAD_DESIGN_INVALID_FILETYPE_ERROR }); }, onExistingDesignDropzoneChange(files, existingDesignFilename) { const filesArr = Array.from(files); if (filesArr.length > 1) { - createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE); + createFlash({ message: EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE }); return; } if (!filesArr.some(({ name }) => existingDesignFilename === name)) { - createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE); + createFlash({ message: EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE }); return; } @@ -307,7 +319,7 @@ export default { optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns), }) .catch(() => { - createFlash(MOVE_DESIGN_ERROR); + createFlash({ message: MOVE_DESIGN_ERROR }); }) .finally(() => { this.isReorderingInProgress = false; @@ -325,6 +337,9 @@ export default { animation: 200, ghostClass: 'gl-visibility-hidden', }, + i18n: { + dropzoneDescriptionText: __('Drop or %{linkStart}upload%{linkEnd} designs to attach'), + }, }; </script> @@ -335,7 +350,11 @@ export default { @mouseenter="toggleOnPasteListener" @mouseleave="toggleOffPasteListener" > - <header v-if="showToolbar" class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex"> + <header + v-if="showToolbar" + class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex" + data-testid="design-toolbar-wrapper" + > <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full"> <div> <span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span> @@ -371,7 +390,12 @@ export default { {{ s__('DesignManagement|Archive selected') }} </delete-button> </design-destroyer> - <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" /> + <upload-button + v-if="canCreateDesign" + :is-saving="isSaving" + data-testid="design-upload-button" + @upload="onUploadDesign" + /> </div> </div> </header> @@ -414,15 +438,26 @@ export default { class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" > <design-dropzone - :has-designs="hasDesigns" - :is-dragging-design="isDraggingDesign" + :display-as-card="hasDesigns" + :enable-drag-behavior="isDraggingDesign" + v-bind="$options.dropzoneProps" @change="onExistingDesignDropzoneChange($event, design.filename)" + @error="onDesignDropzoneError" > <design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)" class="gl-bg-white" /> + <template #upload-text="{ openFileUpload }"> + <gl-sprintf :message="$options.i18n.dropzoneDescriptionText"> + <template #link="{ content }"> + <gl-link @click.stop="openFileUpload"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> </design-dropzone> <input @@ -438,12 +473,24 @@ export default { <template #header> <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper"> <design-dropzone - :is-dragging-design="isDraggingDesign" + :enable-drag-behavior="isDraggingDesign" :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" - :has-designs="hasDesigns" + :display-as-card="hasDesigns" + v-bind="$options.dropzoneProps" data-qa-selector="design_dropzone_content" @change="onUploadDesign" - /> + @error="onDesignDropzoneError" + > + <template #upload-text="{ openFileUpload }"> + <gl-sprintf :message="$options.i18n.dropzoneDescriptionText"> + <template #link="{ content }"> + <gl-link @click.stop="openFileUpload"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </template> + </design-dropzone> </li> </template> </vue-draggable> diff --git a/app/assets/javascripts/design_management/router/index.js b/app/assets/javascripts/design_management/router/index.js index cbeb2f7ce42..12692612bbc 100644 --- a/app/assets/javascripts/design_management/router/index.js +++ b/app/assets/javascripts/design_management/router/index.js @@ -1,9 +1,6 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import routes from './routes'; -import { DESIGN_ROUTE_NAME } from './constants'; -import { getPageLayoutElement } from '~/design_management/utils/design_management_utils'; -import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants'; Vue.use(VueRouter); @@ -13,20 +10,6 @@ export default function createRouter(base) { mode: 'history', routes, }); - const pageEl = getPageLayoutElement(); - - router.beforeEach(({ name }, _, next) => { - // apply a fullscreen layout style in Design View (a.k.a design detail) - if (pageEl) { - if (name === DESIGN_ROUTE_NAME) { - pageEl.classList.add(...DESIGN_DETAIL_LAYOUT_CLASSLIST); - } else { - pageEl.classList.remove(...DESIGN_DETAIL_LAYOUT_CLASSLIST); - } - } - - next(); - }); return router; } diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index fc0530ff977..5bd0288d037 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -2,7 +2,7 @@ import { differenceBy } from 'lodash'; import produce from 'immer'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils'; import { ADD_IMAGE_DIFF_NOTE_ERROR, @@ -101,7 +101,7 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) = }); }; -const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => { +const updateImageDiffNoteInStore = (store, repositionImageDiffNote, query, variables) => { const sourceData = store.readQuery({ query, variables, @@ -111,12 +111,12 @@ const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables const design = extractDesign(draftData); const discussion = extractCurrentDiscussion( design.discussions, - updateImageDiffNote.note.discussion.id, + repositionImageDiffNote.note.discussion.id, ); discussion.notes = { ...discussion.notes, - nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)], + nodes: [repositionImageDiffNote.note, ...discussion.notes.nodes.slice(1)], }; }); @@ -237,7 +237,7 @@ export const deletePendingTodoFromStore = (store, todoMarkDone, query, queryVari }; const onError = (data, message) => { - createFlash(message); + createFlash({ message }); throw new Error(data.errors); }; @@ -268,7 +268,7 @@ export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariab } }; -export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => { +export const updateStoreAfterRepositionImageDiffNote = (store, data, query, queryVariables) => { if (hasErrors(data)) { onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR); } else { @@ -286,7 +286,7 @@ export const updateStoreAfterUploadDesign = (store, data, query) => { export const updateDesignsOnStoreAfterReorder = (store, data, query) => { if (hasErrors(data)) { - createFlash(data.errors[0]); + createFlash({ message: data.errors[0] }); } else { moveDesignInStore(store, data, query); } diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js index 687e793d3df..a905230811c 100644 --- a/app/assets/javascripts/design_management/utils/design_management_utils.js +++ b/app/assets/javascripts/design_management/utils/design_management_utils.js @@ -107,12 +107,12 @@ export const designUploadOptimisticResponse = files => { * @param {Object} note * @param {Object} position */ -export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({ +export const repositionImageDiffNoteOptimisticResponse = (note, { position }) => ({ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 // eslint-disable-next-line @gitlab/require-i18n-strings __typename: 'Mutation', - updateImageDiffNote: { - __typename: 'UpdateImageDiffNotePayload', + repositionImageDiffNote: { + __typename: 'RepositionImageDiffNotePayload', note: { ...note, position: { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 085f951147f..9d8d184a3f6 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -20,6 +20,8 @@ import HiddenFilesWarning from './hidden_files_warning.vue'; import MergeConflictWarning from './merge_conflict_warning.vue'; import CollapsedFilesWarning from './collapsed_files_warning.vue'; +import { diffsApp } from '../utils/performance'; + import { TREE_LIST_WIDTH_STORAGE_KEY, INITIAL_TREE_WIDTH, @@ -124,7 +126,6 @@ export default { return { treeWidth, diffFilesLength: 0, - collapsedWarningDismissed: false, }; }, computed: { @@ -153,7 +154,7 @@ export default { 'canMerge', 'hasConflicts', ]), - ...mapGetters('diffs', ['hasCollapsedFile', 'isParallelView', 'currentDiffIndex']), + ...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']), ...mapGetters(['isNotesFetched', 'getNoteableData']), diffs() { if (!this.viewDiffsFileByFile) { @@ -206,11 +207,7 @@ export default { visible = this.$options.alerts.ALERT_OVERFLOW_HIDDEN; } else if (this.isDiffHead && this.hasConflicts) { visible = this.$options.alerts.ALERT_MERGE_CONFLICT; - } else if ( - this.hasCollapsedFile && - !this.collapsedWarningDismissed && - !this.viewDiffsFileByFile - ) { + } else if (this.whichCollapsedTypes.automatic && !this.viewDiffsFileByFile) { visible = this.$options.alerts.ALERT_COLLAPSED_FILES; } @@ -277,8 +274,12 @@ export default { ); } }, + beforeCreate() { + diffsApp.instrument(); + }, created() { this.adjustView(); + eventHub.$once('fetchDiffData', this.fetchData); eventHub.$on('refetchDiffData', this.refetchDiffData); this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES; @@ -299,6 +300,8 @@ export default { ); }, beforeDestroy() { + diffsApp.deinstrument(); + eventHub.$off('fetchDiffData', this.fetchData); eventHub.$off('refetchDiffData', this.refetchDiffData); this.removeEventListeners(); @@ -429,9 +432,6 @@ export default { this.toggleShowTreeList(false); } }, - dismissCollapsedWarning() { - this.collapsedWarningDismissed = true; - }, }, minTreeWidth: MIN_TREE_WIDTH, maxTreeWidth: MAX_TREE_WIDTH, @@ -464,7 +464,6 @@ export default { <collapsed-files-warning v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES" :limited="isLimitedContainer" - @dismiss="dismissCollapsedWarning" /> <div @@ -496,9 +495,11 @@ export default { <div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div> <template v-else-if="renderDiffFiles"> <diff-file - v-for="file in diffs" + v-for="(file, index) in diffs" :key="file.newPath" :file="file" + :is-first-file="index === 0" + :is-last-file="index === diffs.length - 1" :help-page-path="helpPagePath" :can-current-user-fork="canCurrentUserFork" :view-diffs-file-by-file="viewDiffsFileByFile" diff --git a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue index 270bbfb99b7..0cf1cdb17f8 100644 --- a/app/assets/javascripts/diffs/components/collapsed_files_warning.vue +++ b/app/assets/javascripts/diffs/components/collapsed_files_warning.vue @@ -1,9 +1,8 @@ <script> -import { mapActions } from 'vuex'; - import { GlAlert, GlButton } from '@gitlab/ui'; -import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants'; +import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants'; +import eventHub from '../event_hub'; export default { components: { @@ -36,13 +35,12 @@ export default { }, methods: { - ...mapActions('diffs', ['expandAllFiles']), dismiss() { this.isDismissed = true; this.$emit('dismiss'); }, expand() { - this.expandAllFiles(); + eventHub.$emit(EVT_EXPAND_ALL_FILES); this.dismiss(); }, }, diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index b1ebd8e6ebc..700d5ec86c8 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -6,7 +6,8 @@ import { polyfillSticky } from '~/lib/utils/sticky'; import CompareDropdownLayout from './compare_dropdown_layout.vue'; import SettingsDropdown from './settings_dropdown.vue'; import DiffStats from './diff_stats.vue'; -import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants'; +import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '../constants'; +import eventHub from '../event_hub'; export default { components: { @@ -38,7 +39,7 @@ export default { }, computed: { ...mapGetters('diffs', [ - 'hasCollapsedFile', + 'whichCollapsedTypes', 'diffCompareDropdownTargetVersions', 'diffCompareDropdownSourceVersions', ]), @@ -67,9 +68,11 @@ export default { ...mapActions('diffs', [ 'setInlineDiffViewType', 'setParallelDiffViewType', - 'expandAllFiles', 'toggleShowTreeList', ]), + expandAllFiles() { + eventHub.$emit(EVT_EXPAND_ALL_FILES); + }, }, }; </script> @@ -129,7 +132,7 @@ export default { {{ __('Show latest version') }} </gl-button> <gl-button - v-show="hasCollapsedFile" + v-show="whichCollapsedTypes.any" variant="default" class="gl-mr-3" @click="expandAllFiles" diff --git a/app/assets/javascripts/diffs/components/diff_comment_cell.vue b/app/assets/javascripts/diffs/components/diff_comment_cell.vue new file mode 100644 index 00000000000..4b0b603f5a5 --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_comment_cell.vue @@ -0,0 +1,69 @@ +<script> +import { mapActions } from 'vuex'; +import DiffDiscussions from './diff_discussions.vue'; +import DiffLineNoteForm from './diff_line_note_form.vue'; +import DiffDiscussionReply from './diff_discussion_reply.vue'; + +export default { + components: { + DiffDiscussions, + DiffLineNoteForm, + DiffDiscussionReply, + }, + props: { + line: { + type: Object, + required: true, + }, + diffFileHash: { + type: String, + required: true, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, + hasDraft: { + type: Boolean, + required: false, + default: false, + }, + linePosition: { + type: String, + required: false, + default: '', + }, + }, + methods: { + ...mapActions('diffs', ['showCommentForm']), + }, +}; +</script> + +<template> + <div class="content"> + <diff-discussions + v-if="line.renderDiscussion" + :line="line" + :discussions="line.discussions" + :help-page-path="helpPagePath" + /> + <diff-discussion-reply + v-if="!hasDraft" + :has-form="line.hasCommentForm" + :render-reply-placeholder="Boolean(line.discussions.length)" + @showNewDiscussionForm="showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash })" + > + <template #form> + <diff-line-note-form + :diff-file-hash="diffFileHash" + :line="line" + :note-target-line="line" + :help-page-path="helpPagePath" + :line-position="linePosition" + /> + </template> + </diff-discussion-reply> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index e68260b3e62..401064fb18f 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -10,6 +10,7 @@ import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_prev import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; import InlineDiffView from './inline_diff_view.vue'; import ParallelDiffView from './parallel_diff_view.vue'; +import DiffView from './diff_view.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import NoteForm from '../../notes/components/note_form.vue'; import ImageDiffOverlay from './image_diff_overlay.vue'; @@ -18,12 +19,14 @@ import eventHub from '../../notes/event_hub'; import { IMAGE_DIFF_POSITION_TYPE } from '../constants'; import { getDiffMode } from '../store/utils'; import { diffViewerModes } from '~/ide/constants'; +import { mapInline, mapParallel } from './diff_row_utils'; export default { components: { GlLoadingIcon, InlineDiffView, ParallelDiffView, + DiffView, DiffViewer, NoteForm, DiffDiscussions, @@ -83,6 +86,19 @@ export default { author() { return this.getUserData; }, + mappedLines() { + if (this.glFeatures.unifiedDiffLines && this.glFeatures.unifiedDiffComponents) { + return this.diffLines(this.diffFile, true).map(mapParallel(this)) || []; + } + + // TODO: Everything below this line can be deleted when unifiedDiffComponents FF is removed + 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)) || []; + }, }, updated() { this.$nextTick(() => { @@ -113,19 +129,28 @@ export default { <template> <div class="diff-content"> <div class="diff-viewer"> - <template v-if="isTextFile"> + <template + v-if="isTextFile && glFeatures.unifiedDiffLines && glFeatures.unifiedDiffComponents" + > + <diff-view + :diff-file="diffFile" + :diff-lines="mappedLines" + :help-page-path="helpPagePath" + :inline="isInlineView" + /> + <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" /> + </template> + <template v-else-if="isTextFile"> <inline-diff-view v-if="isInlineView" :diff-file="diffFile" - :diff-lines="diffFile.highlighted_diff_lines" + :diff-lines="mappedLines" :help-page-path="helpPagePath" /> <parallel-diff-view v-else-if="isParallelView" :diff-file="diffFile" - :diff-lines=" - glFeatures.unifiedDiffLines ? diffLines(diffFile) : diffFile.parallel_diff_lines || [] - " + :diff-lines="mappedLines" :help-page-path="helpPagePath" /> <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" /> diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 0094b4f8707..4c49dfb5de9 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -6,7 +6,6 @@ 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 * as utils from '../store/utils'; -import tooltip from '../../vue_shared/directives/tooltip'; const EXPAND_ALL = 0; const EXPAND_UP = 1; @@ -28,9 +27,6 @@ const i18n = { export default { i18n, - directives: { - tooltip, - }, components: { GlIcon, }, @@ -58,11 +54,6 @@ export default { required: false, default: false, }, - colspan: { - type: Number, - required: false, - default: 4, - }, }, computed: { ...mapState({ @@ -235,28 +226,26 @@ export default { </script> <template> - <td :colspan="colspan" class="text-center gl-font-regular"> - <div class="content js-line-expansion-content"> - <a - v-if="canExpandDown" - class="gl-mx-2 gl-cursor-pointer js-unfold-down gl-display-inline-block gl-py-4" - @click="handleExpandLines(EXPAND_DOWN)" - > - <gl-icon :size="12" name="expand-down" aria-hidden="true" /> - <span>{{ $options.i18n.showMore }}</span> - </a> - <a class="gl-mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()"> - <gl-icon :size="12" name="expand" aria-hidden="true" /> - <span>{{ $options.i18n.showAll }}</span> - </a> - <a - v-if="canExpandUp" - class="gl-mx-2 gl-cursor-pointer js-unfold gl-display-inline-block gl-py-4" - @click="handleExpandLines(EXPAND_UP)" - > - <gl-icon :size="12" name="expand-up" aria-hidden="true" /> - <span>{{ $options.i18n.showMore }}</span> - </a> - </div> - </td> + <div class="content js-line-expansion-content"> + <a + v-if="canExpandDown" + class="gl-mx-2 gl-cursor-pointer js-unfold-down gl-display-inline-block gl-py-4" + @click="handleExpandLines(EXPAND_DOWN)" + > + <gl-icon :size="12" name="expand-down" aria-hidden="true" /> + <span>{{ $options.i18n.showMore }}</span> + </a> + <a class="gl-mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()"> + <gl-icon :size="12" name="expand" aria-hidden="true" /> + <span>{{ $options.i18n.showAll }}</span> + </a> + <a + v-if="canExpandUp" + class="gl-mx-2 gl-cursor-pointer js-unfold gl-display-inline-block gl-py-4" + @click="handleExpandLines(EXPAND_UP)" + > + <gl-icon :size="12" name="expand-up" aria-hidden="true" /> + <span>{{ $options.i18n.showMore }}</span> + </a> + </div> </template> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 529723a349d..32191d7e309 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -1,20 +1,31 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { escape } from 'lodash'; -import { GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { __, sprintf } from '~/locale'; +import { sprintf } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; -import eventHub from '../../notes/event_hub'; +import notesEventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; import DiffContent from './diff_content.vue'; import { diffViewerErrors } from '~/ide/constants'; +import { collapsedType, isCollapsed } from '../diff_file'; +import { + DIFF_FILE_AUTOMATIC_COLLAPSE, + DIFF_FILE_MANUAL_COLLAPSE, + EVT_EXPAND_ALL_FILES, + EVT_PERF_MARK_DIFF_FILES_END, + EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, +} from '../constants'; +import { DIFF_FILE, GENERIC_ERROR } from '../i18n'; +import eventHub from '../event_hub'; export default { components: { DiffFileHeader, DiffContent, + GlButton, GlLoadingIcon, }, directives: { @@ -26,6 +37,16 @@ export default { type: Object, required: true, }, + isFirstFile: { + type: Boolean, + required: false, + default: false, + }, + isLastFile: { + type: Boolean, + required: false, + default: false, + }, canCurrentUserFork: { type: Boolean, required: true, @@ -44,16 +65,20 @@ export default { return { isLoadingCollapsedDiff: false, forkMessageVisible: false, - isCollapsed: this.file.viewer.automaticallyCollapsed || false, + isCollapsed: isCollapsed(this.file), }; }, + i18n: { + ...DIFF_FILE, + genericError: GENERIC_ERROR, + }, computed: { ...mapState('diffs', ['currentDiffFileId']), ...mapGetters(['isNotesFetched']), ...mapGetters('diffs', ['getDiffFileDiscussions']), viewBlobLink() { return sprintf( - __('You can %{linkStart}view the blob%{linkEnd} instead.'), + this.$options.i18n.blobView, { linkStart: `<a href="${escape(this.file.view_path)}">`, linkEnd: '</a>', @@ -71,13 +96,11 @@ export default { return this.file.viewer.error === diffViewerErrors.too_large; }, errorMessage() { - return this.file.viewer.error_message; + return !this.manuallyCollapsed ? this.file.viewer.error_message : ''; }, forkMessage() { return sprintf( - __( - "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.", - ), + this.$options.i18n.editInFork, { tag_start: '<span class="js-file-fork-suggestion-section-action">', tag_end: '</span>', @@ -85,62 +108,131 @@ export default { false, ); }, - }, - watch: { - isCollapsed: function fileCollapsedWatch(newVal, oldVal) { - if (!newVal && oldVal && !this.hasDiff) { - this.handleLoadCollapsedDiff(); + hasBodyClasses() { + const domParts = { + header: 'gl-rounded-base!', + contentByHash: '', + content: '', + }; + + if (this.showBody) { + domParts.header = 'gl-rounded-bottom-left-none gl-rounded-bottom-right-none'; + domParts.contentByHash = + 'gl-rounded-none gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-border-1 gl-border-t-0! gl-border-solid gl-border-gray-100'; + domParts.content = 'gl-rounded-bottom-left-base gl-rounded-bottom-right-base'; } - this.setFileCollapsed({ filePath: this.file.file_path, collapsed: newVal }); + return domParts; }, + automaticallyCollapsed() { + return collapsedType(this.file) === DIFF_FILE_AUTOMATIC_COLLAPSE; + }, + manuallyCollapsed() { + return collapsedType(this.file) === DIFF_FILE_MANUAL_COLLAPSE; + }, + showBody() { + return !this.isCollapsed || this.automaticallyCollapsed; + }, + showWarning() { + return this.isCollapsed && (this.automaticallyCollapsed && !this.viewDiffsFileByFile); + }, + showContent() { + return !this.isCollapsed && !this.isFileTooLarge; + }, + }, + watch: { 'file.file_hash': { - handler: function watchFileHash() { - if (this.viewDiffsFileByFile && this.file.viewer.automaticallyCollapsed) { - this.isCollapsed = false; - this.handleLoadCollapsedDiff(); - } else { - this.isCollapsed = this.file.viewer.automaticallyCollapsed || false; + handler: function hashChangeWatch(newHash, oldHash) { + this.isCollapsed = isCollapsed(this.file); + + if (newHash && oldHash && !this.hasDiff) { + this.requestDiff(); } }, immediate: true, }, - 'file.viewer.automaticallyCollapsed': function setIsCollapsed(newVal) { - if (!this.viewDiffsFileByFile) { - this.isCollapsed = newVal; - } + 'file.viewer.automaticallyCollapsed': { + handler: function autoChangeWatch(automaticValue) { + if (collapsedType(this.file) !== DIFF_FILE_MANUAL_COLLAPSE) { + this.isCollapsed = this.viewDiffsFileByFile ? false : automaticValue; + } + }, + immediate: true, + }, + 'file.viewer.manuallyCollapsed': { + handler: function manualChangeWatch(manualValue) { + if (manualValue !== null) { + this.isCollapsed = manualValue; + } + }, + immediate: true, }, }, created() { - eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff); + notesEventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.requestDiff); + eventHub.$on(EVT_EXPAND_ALL_FILES, this.expandAllListener); + }, + mounted() { + if (this.hasDiff) { + this.postRender(); + } + }, + beforeDestroy() { + eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener); }, methods: { ...mapActions('diffs', [ 'loadCollapsedDiff', 'assignDiscussionsToDiff', 'setRenderIt', - 'setFileCollapsed', + 'setFileCollapsedByUser', ]), + expandAllListener() { + if (this.isCollapsed) { + this.handleToggle(); + } + }, + async postRender() { + const eventsForThisFile = []; + + if (this.isFirstFile) { + eventsForThisFile.push(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN); + } + + if (this.isLastFile) { + eventsForThisFile.push(EVT_PERF_MARK_DIFF_FILES_END); + } + + await this.$nextTick(); + + eventsForThisFile.forEach(event => { + eventHub.$emit(event); + }); + }, handleToggle() { - if (!this.hasDiff) { - this.handleLoadCollapsedDiff(); - } else { - this.isCollapsed = !this.isCollapsed; - this.setRenderIt(this.file); + const currentCollapsedFlag = this.isCollapsed; + + this.setFileCollapsedByUser({ + filePath: this.file.file_path, + collapsed: !currentCollapsedFlag, + }); + + if (!this.hasDiff && currentCollapsedFlag) { + this.requestDiff(); } }, - handleLoadCollapsedDiff() { + requestDiff() { this.isLoadingCollapsedDiff = true; this.loadCollapsedDiff(this.file) .then(() => { this.isLoadingCollapsedDiff = false; - this.isCollapsed = false; this.setRenderIt(this.file); }) .then(() => { requestIdleCallback( () => { + this.postRender(); this.assignDiscussionsToDiff(this.getDiffFileDiscussions(this.file)); }, { timeout: 1000 }, @@ -148,7 +240,7 @@ export default { }) .catch(() => { this.isLoadingCollapsedDiff = false; - createFlash(__('Something went wrong on our end. Please try again!')); + createFlash(this.$options.i18n.genericError); }); }, showForkMessage() { @@ -167,9 +259,10 @@ export default { :class="{ 'is-active': currentDiffFileId === file.file_hash, 'comments-disabled': Boolean(file.brokenSymlink), + 'has-body': showBody, }" :data-path="file.new_path" - class="diff-file file-holder" + class="diff-file file-holder gl-border-none" > <diff-file-header :can-current-user-fork="canCurrentUserFork" @@ -178,7 +271,8 @@ export default { :expanded="!isCollapsed" :add-merge-request-buttons="true" :view-diffs-file-by-file="viewDiffsFileByFile" - class="js-file-title file-title" + class="js-file-title file-title gl-border-1 gl-border-solid gl-border-gray-100" + :class="hasBodyClasses.header" @toggleFile="handleToggle" @showForkMessage="showForkMessage" /> @@ -188,31 +282,50 @@ export default { <a :href="file.fork_path" class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success" - >{{ __('Fork') }}</a + >{{ $options.i18n.fork }}</a > <button class="js-cancel-fork-suggestion-button btn btn-grouped" type="button" @click="hideForkMessage" > - {{ __('Cancel') }} + {{ $options.i18n.cancel }} </button> </div> - <gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" /> <template v-else> - <div :id="`diff-content-${file.file_hash}`"> - <div v-if="errorMessage" class="diff-viewer"> + <div + :id="`diff-content-${file.file_hash}`" + :class="hasBodyClasses.contentByHash" + data-testid="content-area" + > + <gl-loading-icon + v-if="showLoadingIcon" + class="diff-content loading gl-my-0 gl-pt-3" + data-testid="loader-icon" + /> + <div v-else-if="errorMessage" class="diff-viewer"> <div v-safe-html="errorMessage" class="nothing-here-block"></div> </div> <template v-else> - <div v-show="isCollapsed" class="nothing-here-block diff-collapsed"> - {{ __('This diff is collapsed.') }} - <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{ - __('Click to expand it.') - }}</a> + <div + v-show="showWarning" + class="collapsed-file-warning gl-p-7 gl-bg-orange-50 gl-text-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + > + <p class="gl-mb-8"> + {{ $options.i18n.autoCollapsed }} + </p> + <gl-button + data-testid="expand-button" + category="secondary" + variant="warning" + @click.prevent="handleToggle" + > + {{ $options.i18n.expand }} + </gl-button> </div> <diff-content - v-show="!isCollapsed && !isFileTooLarge" + v-show="showContent" + :class="hasBodyClasses.content" :diff-file="file" :help-page-path="helpPagePath" /> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 9f451cd759a..0d99a2e8a60 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -10,6 +10,7 @@ import { GlDropdown, GlDropdownItem, GlDropdownDivider, + GlLoadingIcon, } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; @@ -18,6 +19,7 @@ import { __, s__, sprintf } from '~/locale'; import { diffViewerModes } from '~/ide/constants'; import DiffStats from './diff_stats.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; +import { isCollapsed } from '../diff_file'; import { DIFF_FILE_HEADER } from '../i18n'; export default { @@ -31,6 +33,7 @@ export default { GlDropdown, GlDropdownItem, GlDropdownDivider, + GlLoadingIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -125,6 +128,9 @@ export default { isUsingLfs() { return this.diffFile.stored_externally && this.diffFile.external_storage === 'lfs'; }, + isCollapsed() { + return isCollapsed(this.diffFile, { fileByFile: this.viewDiffsFileByFile }); + }, collapseIcon() { return this.expanded ? 'chevron-down' : 'chevron-right'; }, @@ -209,7 +215,7 @@ export default { class="js-file-title file-title file-title-flex-parent" @click.self="handleToggleFile" > - <div class="file-header-content gl-display-flex gl-align-items-center gl-pr-0!"> + <div class="file-header-content"> <gl-icon v-if="collapsible" ref="collapseIcon" @@ -222,11 +228,17 @@ export default { <a ref="titleWrapper" :v-once="!viewDiffsFileByFile" - class="gl-mr-2 gl-text-decoration-none! gl-text-truncate" + class="gl-mr-2 gl-text-decoration-none! gl-word-break-all" :href="titleLink" @click="handleFileNameClick" > - <file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" /> + <file-icon + :file-name="filePath" + :size="18" + aria-hidden="true" + css-classes="gl-mr-2" + :submodule="diffFile.submodule" + /> <span v-if="isFileRenamed"> <strong v-gl-tooltip @@ -270,12 +282,12 @@ export default { {{ diffFile.a_mode }} → {{ diffFile.b_mode }} </small> - <span v-if="isUsingLfs" class="label label-lfs gl-mr-2"> {{ __('LFS') }} </span> + <span v-if="isUsingLfs" class="badge label label-lfs gl-mr-2"> {{ __('LFS') }} </span> </div> <div v-if="!diffFile.submodule && addMergeRequestButtons" - class="file-actions d-flex align-items-center flex-wrap" + class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start" > <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" /> <gl-button-group class="gl-pt-0!"> @@ -334,7 +346,7 @@ export default { </gl-dropdown-item> </template> - <template v-if="!diffFile.viewer.automaticallyCollapsed"> + <template v-if="!isCollapsed"> <gl-dropdown-divider v-if="!diffFile.is_fully_expanded || diffHasDiscussions(diffFile)" /> @@ -355,8 +367,10 @@ export default { <gl-dropdown-item v-if="!diffFile.is_fully_expanded" ref="expandDiffToFullFileButton" + :disabled="diffFile.isLoadingFullFile" @click="toggleFullDiff(diffFile.file_path)" > + <gl-loading-icon v-if="diffFile.isLoadingFullFile" inline /> {{ expandDiffToFullFileTitle }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/diffs/components/diff_file_row.vue b/app/assets/javascripts/diffs/components/diff_file_row.vue index 2856e6ae8eb..3888eb781fb 100644 --- a/app/assets/javascripts/diffs/components/diff_file_row.vue +++ b/app/assets/javascripts/diffs/components/diff_file_row.vue @@ -62,6 +62,7 @@ export default { v-bind="$attrs" :class="{ 'is-active': isActive }" class="diff-file-row" + truncate-middle :file-classes="fileClasses" v-on="$listeners" > 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 700e6302102..55f5a736cdf 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 } from '../constants'; +import { DIFF_NOTE_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; import { commentLineOptions, formatLineRange, @@ -60,7 +60,7 @@ export default { diffViewType: state => state.diffs.diffViewType, }), ...mapState('diffs', ['showSuggestPopover']), - ...mapGetters('diffs', ['getDiffFileByHash']), + ...mapGetters('diffs', ['getDiffFileByHash', 'diffLines']), ...mapGetters([ 'isLoggedIn', 'noteableType', @@ -88,16 +88,30 @@ export default { commentLineOptions() { const combineSides = (acc, { left, right }) => { // ignore null values match lines - if (left && left.type !== 'match') acc.push(left); + if (left) acc.push(left); // if the line_codes are identically, return to avoid duplicates - if (left?.line_code === right?.line_code) return acc; + if ( + left?.line_code === right?.line_code || + left?.type === 'old-nonewline' || + right?.type === 'new-nonewline' + ) { + return acc; + } if (right && right.type !== 'match') acc.push(right); return acc; }; + 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.diffFile.highlighted_diff_lines; + }; const side = this.line.type === 'new' ? 'right' : 'left'; - const lines = this.diffFile.highlighted_diff_lines.length - ? this.diffFile.highlighted_diff_lines - : this.diffFile.parallel_diff_lines.reduce(combineSides, []); + const lines = getDiffLines(); return commentLineOptions(lines, this.line, this.line.line_code, side); }, }, diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue new file mode 100644 index 00000000000..77a97c67f3b --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_row.vue @@ -0,0 +1,271 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { CONTEXT_LINE_CLASS_NAME, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; +import DiffGutterAvatars from './diff_gutter_avatars.vue'; +import * as utils from './diff_row_utils'; + +export default { + components: { + GlIcon, + DiffGutterAvatars, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + fileHash: { + type: String, + required: true, + }, + filePath: { + type: String, + required: true, + }, + line: { + type: Object, + required: true, + }, + isCommented: { + type: Boolean, + required: false, + default: false, + }, + inline: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapGetters('diffs', ['fileLineCoverage']), + ...mapGetters(['isLoggedIn']), + ...mapState({ + isHighlighted(state) { + const line = this.line.left?.line_code ? this.line.left : this.line.right; + return utils.isHighlighted(state, line, this.isCommented); + }, + }), + classNameMap() { + return { + [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLineLeft, + [PARALLEL_DIFF_VIEW_TYPE]: true, + }; + }, + parallelViewLeftLineType() { + return utils.parallelViewLeftLineType(this.line, this.isHighlighted); + }, + coverageState() { + return this.fileLineCoverage(this.filePath, this.line.right.new_line); + }, + classNameMapCellLeft() { + return utils.classNameMapCell(this.line.left, this.isHighlighted, this.isLoggedIn); + }, + classNameMapCellRight() { + return utils.classNameMapCell(this.line.right, this.isHighlighted, this.isLoggedIn); + }, + addCommentTooltipLeft() { + return utils.addCommentTooltip(this.line.left); + }, + addCommentTooltipRight() { + return utils.addCommentTooltip(this.line.right); + }, + shouldRenderCommentButton() { + return ( + this.isLoggedIn && + !this.line.isContextLineLeft && + !this.line.isMetaLineLeft && + !this.line.hasDiscussionsLeft && + !this.line.hasDiscussionsRight + ); + }, + }, + mounted() { + this.scrollToLineIfNeededParallel(this.line); + }, + methods: { + ...mapActions('diffs', [ + 'scrollToLineIfNeededParallel', + 'showCommentForm', + 'setHighlightedRow', + 'toggleLineDiscussions', + ]), + // Prevent text selecting on both sides of parallel diff view + // Backport of the same code from legacy diff notes. + handleParallelLineMouseDown(e) { + const line = e.currentTarget; + const table = line.closest('.diff-table'); + + table.classList.remove('left-side-selected', 'right-side-selected'); + const [lineClass] = ['left-side', 'right-side'].filter(name => line.classList.contains(name)); + + if (lineClass) { + table.classList.add(`${lineClass}-selected`); + } + }, + handleCommentButton(line) { + this.showCommentForm({ lineCode: line.line_code, fileHash: this.fileHash }); + }, + }, +}; +</script> + +<template> + <div :class="classNameMap" class="diff-grid-row diff-tr line_holder"> + <div class="diff-grid-left left-side"> + <template v-if="line.left"> + <div + :class="classNameMapCellLeft" + data-testid="leftLineNumber" + class="diff-td diff-line-num old_line" + > + <span + v-if="shouldRenderCommentButton" + v-gl-tooltip + data-testid="leftCommentButton" + class="add-diff-note tooltip-wrapper" + :title="addCommentTooltipLeft" + > + <button + type="button" + class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" + :disabled="line.left.commentsDisabled" + @click="handleCommentButton(line.left)" + > + <gl-icon :size="12" name="comment" /> + </button> + </span> + <a + v-if="line.left.old_line" + :data-linenumber="line.left.old_line" + :href="line.lineHrefOld" + @click="setHighlightedRow(line.lineCode)" + > + </a> + <diff-gutter-avatars + v-if="line.hasDiscussionsLeft" + :discussions="line.left.discussions" + :discussions-expanded="line.left.discussionsExpanded" + data-testid="leftDiscussions" + @toggleLineDiscussions=" + toggleLineDiscussions({ + lineCode: line.left.line_code, + fileHash, + expanded: !line.left.discussionsExpanded, + }) + " + /> + </div> + <div :class="classNameMapCellLeft" class="diff-td diff-line-num old_line"> + <a + v-if="line.left.old_line" + :data-linenumber="line.left.old_line" + :href="line.lineHrefOld" + @click="setHighlightedRow(line.lineCode)" + > + </a> + </div> + <div :class="parallelViewLeftLineType" class="diff-td line-coverage left-side"></div> + <div + :id="line.left.line_code" + :key="line.left.line_code" + v-safe-html="line.left.rich_text" + :class="parallelViewLeftLineType" + class="diff-td line_content with-coverage parallel left-side" + data-testid="leftContent" + @mousedown="handleParallelLineMouseDown" + ></div> + </template> + <template v-else> + <div data-testid="leftEmptyCell" class="diff-td diff-line-num old_line empty-cell"></div> + <div class="diff-td diff-line-num old_line empty-cell"></div> + <div class="diff-td line-coverage left-side empty-cell"></div> + <div class="diff-td line_content with-coverage parallel left-side empty-cell"></div> + </template> + </div> + <div + v-if="!inline || (line.right && Boolean(line.right.type))" + class="diff-grid-right right-side" + > + <template v-if="line.right"> + <div + :class="classNameMapCellRight" + data-testid="rightLineNumber" + class="diff-td diff-line-num new_line" + > + <span + v-if="shouldRenderCommentButton" + v-gl-tooltip + data-testid="rightCommentButton" + class="add-diff-note tooltip-wrapper" + :title="addCommentTooltipRight" + > + <button + type="button" + class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" + :disabled="line.right.commentsDisabled" + @click="handleCommentButton(line.right)" + > + <gl-icon :size="12" name="comment" /> + </button> + </span> + <a + v-if="line.right.new_line" + :data-linenumber="line.right.new_line" + :href="line.lineHrefNew" + @click="setHighlightedRow(line.lineCode)" + > + </a> + <diff-gutter-avatars + v-if="line.hasDiscussionsRight" + :discussions="line.right.discussions" + :discussions-expanded="line.right.discussionsExpanded" + data-testid="rightDiscussions" + @toggleLineDiscussions=" + toggleLineDiscussions({ + lineCode: line.right.line_code, + fileHash, + expanded: !line.right.discussionsExpanded, + }) + " + /> + </div> + <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line"> + <a + v-if="line.right.new_line" + :data-linenumber="line.right.new_line" + :href="line.lineHrefNew" + @click="setHighlightedRow(line.lineCode)" + > + </a> + </div> + <div + v-gl-tooltip.hover + :title="coverageState.text" + :class="[line.right.type, coverageState.class, { hll: isHighlighted }]" + class="diff-td line-coverage right-side" + ></div> + <div + :id="line.right.line_code" + :key="line.right.rich_text" + v-safe-html="line.right.rich_text" + :class="[ + line.right.type, + { + hll: isHighlighted, + }, + ]" + class="diff-td line_content with-coverage parallel right-side" + @mousedown="handleParallelLineMouseDown" + ></div> + </template> + <template v-else> + <div data-testid="rightEmptyCell" class="diff-td diff-line-num old_line empty-cell"></div> + <div class="diff-td diff-line-num old_line empty-cell"></div> + <div class="diff-td line-coverage right-side empty-cell"></div> + <div class="diff-td line_content with-coverage parallel right-side empty-cell"></div> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js index 08b87a4bade..d5491d3cd56 100644 --- a/app/assets/javascripts/diffs/components/diff_row_utils.js +++ b/app/assets/javascripts/diffs/components/diff_row_utils.js @@ -83,3 +83,76 @@ export const parallelViewLeftLineType = (line, hll) => { export const shouldShowCommentButton = (hover, context, meta, discussions) => { return hover && !context && !meta && !discussions; }; + +export const mapParallel = content => line => { + let { left, right } = line; + + // Dicussions/Comments + const hasExpandedDiscussionOnLeft = + left?.discussions?.length > 0 ? left?.discussionsExpanded : false; + const hasExpandedDiscussionOnRight = + right?.discussions?.length > 0 ? right?.discussionsExpanded : false; + + const renderCommentRow = + hasExpandedDiscussionOnLeft || hasExpandedDiscussionOnRight || left?.hasForm || right?.hasForm; + + if (left) { + left = { + ...left, + renderDiscussion: hasExpandedDiscussionOnLeft, + hasDraft: content.hasParallelDraftLeft(content.diffFile.file_hash, line), + lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'left'), + hasCommentForm: left.hasForm, + }; + } + if (right) { + right = { + ...right, + renderDiscussion: Boolean(hasExpandedDiscussionOnRight && right.type), + hasDraft: content.hasParallelDraftRight(content.diffFile.file_hash, line), + lineDraft: content.draftForLine(content.diffFile.file_hash, line, 'right'), + hasCommentForm: Boolean(right.hasForm && right.type), + }; + } + + return { + ...line, + left, + right, + isMatchLineLeft: isMatchLine(left?.type), + isMatchLineRight: isMatchLine(right?.type), + isContextLineLeft: isContextLine(left?.type), + isContextLineRight: isContextLine(right?.type), + hasDiscussionsLeft: hasDiscussions(left), + hasDiscussionsRight: hasDiscussions(right), + lineHrefOld: lineHref(left), + lineHrefNew: lineHref(right), + lineCode: lineCode(line), + isMetaLineLeft: isMetaLine(left?.type), + isMetaLineRight: isMetaLine(right?.type), + draftRowClasses: left?.lineDraft > 0 || right?.lineDraft > 0 ? '' : 'js-temp-notes-holder', + renderCommentRow, + commentRowClasses: hasDiscussions(left) || hasDiscussions(right) ? '' : 'js-temp-notes-holder', + }; +}; + +// TODO: Delete this function when unifiedDiffComponents FF is removed +export const mapInline = content => line => { + // Discussions/Comments + const renderCommentRow = line.hasForm || (line.discussions?.length && line.discussionsExpanded); + + return { + ...line, + renderDiscussion: Boolean(line.discussions?.length), + isMatchLine: isMatchLine(line.type), + commentRowClasses: line.discussions?.length ? '' : 'js-temp-notes-holder', + renderCommentRow, + hasDraft: content.shouldRenderDraftRow(content.diffFile.file_hash, line), + hasCommentForm: line.hasForm, + isMetaLine: isMetaLine(line.type), + isContextLine: isContextLine(line.type), + hasDiscussions: hasDiscussions(line), + lineHref: lineHref(line), + lineCode: lineCode(line), + }; +}; diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue new file mode 100644 index 00000000000..84429f62a1c --- /dev/null +++ b/app/assets/javascripts/diffs/components/diff_view.vue @@ -0,0 +1,151 @@ +<script> +import { mapGetters, mapState } from 'vuex'; +import draftCommentsMixin from '~/diffs/mixins/draft_comments'; +import DraftNote from '~/batch_comments/components/draft_note.vue'; +import DiffRow from './diff_row.vue'; +import DiffCommentCell from './diff_comment_cell.vue'; +import DiffExpansionCell from './diff_expansion_cell.vue'; +import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; + +export default { + components: { + DiffExpansionCell, + DiffRow, + DiffCommentCell, + DraftNote, + }, + mixins: [draftCommentsMixin], + props: { + diffFile: { + type: Object, + required: true, + }, + diffLines: { + type: Array, + required: true, + }, + helpPagePath: { + type: String, + required: false, + default: '', + }, + inline: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + ...mapGetters('diffs', ['commitId']), + ...mapState({ + selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition, + selectedCommentPositionHover: ({ notes }) => notes.selectedCommentPositionHover, + }), + diffLinesLength() { + return this.diffLines.length; + }, + commentedLines() { + return getCommentedLines( + this.selectedCommentPosition || this.selectedCommentPositionHover, + this.diffLines, + ); + }, + }, + methods: { + showCommentLeft(line) { + return !this.inline || line.left; + }, + showCommentRight(line) { + return !this.inline || (line.right && !line.left); + }, + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> + +<template> + <div + :class="[$options.userColorScheme, { inline }]" + :data-commit-id="commitId" + class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file" + > + <template v-for="(line, index) in diffLines"> + <div + v-if="line.isMatchLineLeft || line.isMatchLineRight" + :key="`expand-${index}`" + class="diff-tr line_expansion match" + > + <div class="diff-td text-center gl-font-regular"> + <diff-expansion-cell + :file-hash="diffFile.file_hash" + :context-lines-path="diffFile.context_lines_path" + :line="line.left" + :is-top="index === 0" + :is-bottom="index + 1 === diffLinesLength" + /> + </div> + </div> + <diff-row + v-if="!line.isMatchLineLeft && !line.isMatchLineRight" + :key="line.line_code" + :file-hash="diffFile.file_hash" + :file-path="diffFile.file_path" + :line="line" + :is-bottom="index + 1 === diffLinesLength" + :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" + :inline="inline" + /> + <div + v-if="line.renderCommentRow" + :key="`dcr-${line.line_code || index}`" + :class="line.commentRowClasses" + class="diff-grid-comments diff-tr notes_holder" + > + <div v-if="showCommentLeft(line)" class="diff-td notes-content parallel old"> + <diff-comment-cell + v-if="line.left" + :line="line.left" + :diff-file-hash="diffFile.file_hash" + :help-page-path="helpPagePath" + :has-draft="line.left.hasDraft" + line-position="left" + /> + </div> + <div v-if="showCommentRight(line)" class="diff-td notes-content parallel new"> + <diff-comment-cell + v-if="line.right" + :line="line.right" + :diff-file-hash="diffFile.file_hash" + :line-index="index" + :help-page-path="helpPagePath" + :has-draft="line.right.hasDraft" + line-position="right" + /> + </div> + </div> + <div + v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)" + :key="`drafts-${index}`" + :class="line.draftRowClasses" + class="diff-grid-drafts diff-tr notes_holder" + > + <div + v-if="!inline || (line.left && line.left.lineDraft.isDraft)" + class="diff-td notes-content parallel old" + > + <div v-if="line.left && line.left.lineDraft.isDraft" class="content"> + <draft-note :draft="line.left.lineDraft" :line="line.left" /> + </div> + </div> + <div + v-if="!inline || (line.right && line.right.lineDraft.isDraft)" + class="diff-td notes-content parallel new" + > + <div v-if="line.right && line.right.lineDraft.isDraft" class="content"> + <draft-note :draft="line.right.lineDraft" :line="line.right" /> + </div> + </div> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue deleted file mode 100644 index 87f0396cf72..00000000000 --- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue +++ /dev/null @@ -1,82 +0,0 @@ -<script> -import { mapActions } from 'vuex'; -import DiffDiscussions from './diff_discussions.vue'; -import DiffLineNoteForm from './diff_line_note_form.vue'; -import DiffDiscussionReply from './diff_discussion_reply.vue'; - -export default { - components: { - DiffDiscussions, - DiffLineNoteForm, - DiffDiscussionReply, - }, - props: { - line: { - type: Object, - required: true, - }, - diffFileHash: { - type: String, - required: true, - }, - helpPagePath: { - type: String, - required: false, - default: '', - }, - hasDraft: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - className() { - return this.line.discussions.length ? '' : 'js-temp-notes-holder'; - }, - shouldRender() { - if (this.line.hasForm) return true; - - if (!this.line.discussions || !this.line.discussions.length) { - return false; - } - return this.line.discussionsExpanded; - }, - }, - methods: { - ...mapActions('diffs', ['showCommentForm']), - }, -}; -</script> - -<template> - <tr v-if="shouldRender" :class="className" class="notes_holder"> - <td class="notes-content" colspan="4"> - <div class="content"> - <diff-discussions - v-if="line.discussions.length" - :line="line" - :discussions="line.discussions" - :help-page-path="helpPagePath" - /> - <diff-discussion-reply - v-if="!hasDraft" - :has-form="line.hasForm" - :render-reply-placeholder="Boolean(line.discussions.length)" - @showNewDiscussionForm=" - showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash }) - " - > - <template #form> - <diff-line-note-form - :diff-file-hash="diffFileHash" - :line="line" - :note-target-line="line" - :help-page-path="helpPagePath" - /> - </template> - </diff-discussion-reply> - </div> - </td> - </tr> -</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue deleted file mode 100644 index 071a988d789..00000000000 --- a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue +++ /dev/null @@ -1,51 +0,0 @@ -<script> -import DiffExpansionCell from './diff_expansion_cell.vue'; -import { MATCH_LINE_TYPE } from '../constants'; - -export default { - components: { - DiffExpansionCell, - }, - props: { - fileHash: { - type: String, - required: true, - }, - contextLinesPath: { - type: String, - required: true, - }, - line: { - type: Object, - required: true, - }, - isTop: { - type: Boolean, - required: false, - default: false, - }, - isBottom: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - isMatchLine() { - return this.line.type === MATCH_LINE_TYPE; - }, - }, -}; -</script> - -<template> - <tr v-if="isMatchLine" class="line_expansion match"> - <diff-expansion-cell - :file-hash="fileHash" - :context-lines-path="contextLinesPath" - :line="line" - :is-top="isTop" - :is-bottom="isBottom" - /> - </tr> -</template> diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue index 99cf79a70d4..2d8ffb047ca 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue @@ -3,7 +3,13 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { GlTooltipDirective, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { CONTEXT_LINE_CLASS_NAME } from '../constants'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; -import * as utils from './diff_row_utils'; +import { + isHighlighted, + shouldShowCommentButton, + shouldRenderCommentButton, + classNameMapCell, + addCommentTooltip, +} from './diff_row_utils'; export default { components: { @@ -48,60 +54,42 @@ export default { ...mapGetters('diffs', ['fileLineCoverage']), ...mapState({ isHighlighted(state) { - return utils.isHighlighted(state, this.line, this.isCommented); + return isHighlighted(state, this.line, this.isCommented); }, }), - isContextLine() { - return utils.isContextLine(this.line.type); - }, classNameMap() { return [ this.line.type, { - [CONTEXT_LINE_CLASS_NAME]: this.isContextLine, + [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLine, }, ]; }, inlineRowId() { return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`; }, - isMatchLine() { - return utils.isMatchLine(this.line.type); - }, coverageState() { return this.fileLineCoverage(this.filePath, this.line.new_line); }, - isMetaLine() { - return utils.isMetaLine(this.line.type); - }, classNameMapCell() { - return utils.classNameMapCell(this.line, this.isHighlighted, this.isLoggedIn, this.isHover); + return classNameMapCell(this.line, this.isHighlighted, this.isLoggedIn, this.isHover); }, addCommentTooltip() { - return utils.addCommentTooltip(this.line); + return addCommentTooltip(this.line); }, shouldRenderCommentButton() { - return utils.shouldRenderCommentButton(this.isLoggedIn, true); + return shouldRenderCommentButton(this.isLoggedIn, true); }, shouldShowCommentButton() { - return utils.shouldShowCommentButton( + return shouldShowCommentButton( this.isHover, - this.isContextLine, - this.isMetaLine, - this.hasDiscussions, + this.line.isContextLine, + this.line.isMetaLine, + this.line.hasDiscussions, ); }, - hasDiscussions() { - return utils.hasDiscussions(this.line); - }, - lineHref() { - return utils.lineHref(this.line); - }, - lineCode() { - return utils.lineCode(this.line); - }, shouldShowAvatarsOnGutter() { - return this.hasDiscussions; + return this.line.hasDiscussions; }, }, mounted() { @@ -128,7 +116,6 @@ export default { <template> <tr - v-if="!isMatchLine" :id="inlineRowId" :class="classNameMap" class="line_holder" @@ -158,8 +145,8 @@ export default { v-if="line.old_line" ref="lineNumberRefOld" :data-linenumber="line.old_line" - :href="lineHref" - @click="setHighlightedRow(lineCode)" + :href="line.lineHref" + @click="setHighlightedRow(line.lineCode)" > </a> <diff-gutter-avatars @@ -167,7 +154,11 @@ export default { :discussions="line.discussions" :discussions-expanded="line.discussionsExpanded" @toggleLineDiscussions=" - toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded }) + toggleLineDiscussions({ + lineCode: line.lineCode, + fileHash, + expanded: !line.discussionsExpanded, + }) " /> </td> @@ -176,8 +167,8 @@ export default { v-if="line.new_line" ref="lineNumberRefNew" :data-linenumber="line.new_line" - :href="lineHref" - @click="setHighlightedRow(lineCode)" + :href="line.lineHref" + @click="setHighlightedRow(line.lineCode)" > </a> </td> diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue index 13805910648..05f5461054f 100644 --- a/app/assets/javascripts/diffs/components/inline_diff_view.vue +++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue @@ -2,18 +2,18 @@ import { mapGetters, mapState } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; -import InlineDraftCommentRow from '~/batch_comments/components/inline_draft_comment_row.vue'; +import DraftNote from '~/batch_comments/components/draft_note.vue'; import inlineDiffTableRow from './inline_diff_table_row.vue'; -import inlineDiffCommentRow from './inline_diff_comment_row.vue'; -import inlineDiffExpansionRow from './inline_diff_expansion_row.vue'; +import DiffCommentCell from './diff_comment_cell.vue'; +import DiffExpansionCell from './diff_expansion_cell.vue'; import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; export default { components: { - inlineDiffCommentRow, + DiffCommentCell, inlineDiffTableRow, - InlineDraftCommentRow, - inlineDiffExpansionRow, + DraftNote, + DiffExpansionCell, }, mixins: [draftCommentsMixin, glFeatureFlagsMixin()], props: { @@ -65,15 +65,19 @@ export default { </colgroup> <tbody> <template v-for="(line, index) in diffLines"> - <inline-diff-expansion-row - :key="`expand-${index}`" - :file-hash="diffFile.file_hash" - :context-lines-path="diffFile.context_lines_path" - :line="line" - :is-top="index === 0" - :is-bottom="index + 1 === diffLinesLength" - /> + <tr v-if="line.isMatchLine" :key="`expand-${index}`" class="line_expansion match"> + <td colspan="4" class="text-center gl-font-regular"> + <diff-expansion-cell + :file-hash="diffFile.file_hash" + :context-lines-path="diffFile.context_lines_path" + :line="line" + :is-top="index === 0" + :is-bottom="index + 1 === diffLinesLength" + /> + </td> + </tr> <inline-diff-table-row + v-if="!line.isMatchLine" :key="`${line.line_code || index}`" :file-hash="diffFile.file_hash" :file-path="diffFile.file_path" @@ -81,20 +85,32 @@ export default { :is-bottom="index + 1 === diffLinesLength" :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" /> - <inline-diff-comment-row + <tr + v-if="line.renderCommentRow" :key="`icr-${line.line_code || index}`" - :diff-file-hash="diffFile.file_hash" - :line="line" - :help-page-path="helpPagePath" - :has-draft="shouldRenderDraftRow(diffFile.file_hash, line) || false" - /> - <inline-draft-comment-row - v-if="shouldRenderDraftRow(diffFile.file_hash, line)" - :key="`draft_${index}`" - :draft="draftForLine(diffFile.file_hash, line)" - :diff-file="diffFile" - :line="line" - /> + :class="line.commentRowClasses" + class="notes_holder" + > + <td class="notes-content" colspan="4"> + <diff-comment-cell + :diff-file-hash="diffFile.file_hash" + :line="line" + :help-page-path="helpPagePath" + :has-draft="line.hasDraft" + /> + </td> + </tr> + <tr v-if="line.hasDraft" :key="`draft_${index}`" class="notes_holder js-temp-notes-holder"> + <td class="notes-content" colspan="4"> + <div class="content"> + <draft-note + :draft="draftForLine(diffFile.file_hash, line)" + :diff-file="diffFile" + :line="line" + /> + </div> + </td> + </tr> </template> </tbody> </table> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue deleted file mode 100644 index 127e3f214cf..00000000000 --- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue +++ /dev/null @@ -1,175 +0,0 @@ -<script> -import { mapActions } from 'vuex'; -import DiffDiscussions from './diff_discussions.vue'; -import DiffLineNoteForm from './diff_line_note_form.vue'; -import DiffDiscussionReply from './diff_discussion_reply.vue'; - -export default { - components: { - DiffDiscussions, - DiffLineNoteForm, - DiffDiscussionReply, - }, - props: { - line: { - type: Object, - required: true, - }, - diffFileHash: { - type: String, - required: true, - }, - lineIndex: { - type: Number, - required: true, - }, - helpPagePath: { - type: String, - required: false, - default: '', - }, - hasDraftLeft: { - type: Boolean, - required: false, - default: false, - }, - hasDraftRight: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - hasExpandedDiscussionOnLeft() { - return this.line.left && this.line.left.discussions.length - ? this.line.left.discussionsExpanded - : false; - }, - hasExpandedDiscussionOnRight() { - return this.line.right && this.line.right.discussions.length - ? this.line.right.discussionsExpanded - : false; - }, - hasAnyExpandedDiscussion() { - return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight; - }, - shouldRenderDiscussionsOnLeft() { - return ( - this.line.left && - this.line.left.discussions && - this.line.left.discussions.length && - this.hasExpandedDiscussionOnLeft - ); - }, - shouldRenderDiscussionsOnRight() { - return ( - this.line.right && - this.line.right.discussions && - this.line.right.discussions.length && - this.hasExpandedDiscussionOnRight && - this.line.right.type - ); - }, - showRightSideCommentForm() { - return this.line.right && this.line.right.type && this.line.right.hasForm; - }, - showLeftSideCommentForm() { - return this.line.left && this.line.left.hasForm; - }, - className() { - return (this.left && this.line.left.discussions.length > 0) || - (this.right && this.line.right.discussions.length > 0) - ? '' - : 'js-temp-notes-holder'; - }, - shouldRender() { - const { line } = this; - const hasDiscussion = - (line.left && line.left.discussions && line.left.discussions.length) || - (line.right && line.right.discussions && line.right.discussions.length); - - if ( - hasDiscussion && - (this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight) - ) { - return true; - } - - const hasCommentFormOnLeft = line.left && line.left.hasForm; - const hasCommentFormOnRight = line.right && line.right.hasForm; - - return hasCommentFormOnLeft || hasCommentFormOnRight; - }, - shouldRenderReplyPlaceholderOnLeft() { - return Boolean( - this.line.left && this.line.left.discussions && this.line.left.discussions.length, - ); - }, - shouldRenderReplyPlaceholderOnRight() { - return Boolean( - this.line.right && this.line.right.discussions && this.line.right.discussions.length, - ); - }, - }, - methods: { - ...mapActions('diffs', ['showCommentForm']), - showNewDiscussionForm(lineCode) { - this.showCommentForm({ lineCode, fileHash: this.diffFileHash }); - }, - }, -}; -</script> - -<template> - <tr v-if="shouldRender" :class="className" class="notes_holder"> - <td class="notes-content parallel old" colspan="3"> - <div v-if="shouldRenderDiscussionsOnLeft" class="content"> - <diff-discussions - :discussions="line.left.discussions" - :line="line.left" - :help-page-path="helpPagePath" - /> - </div> - <diff-discussion-reply - v-if="!hasDraftLeft" - :has-form="showLeftSideCommentForm" - :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft" - @showNewDiscussionForm="showNewDiscussionForm(line.left.line_code)" - > - <template #form> - <diff-line-note-form - :diff-file-hash="diffFileHash" - :line="line.left" - :note-target-line="line.left" - :help-page-path="helpPagePath" - line-position="left" - /> - </template> - </diff-discussion-reply> - </td> - <td class="notes-content parallel new" colspan="3"> - <div v-if="shouldRenderDiscussionsOnRight" class="content"> - <diff-discussions - :discussions="line.right.discussions" - :line="line.right" - :help-page-path="helpPagePath" - /> - </div> - <diff-discussion-reply - v-if="!hasDraftRight" - :has-form="showRightSideCommentForm" - :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight" - @showNewDiscussionForm="showNewDiscussionForm(line.right.line_code)" - > - <template #form> - <diff-line-note-form - :diff-file-hash="diffFileHash" - :line="line.right" - :note-target-line="line.right" - line-position="right" - /> - </template> - </diff-discussion-reply> - </td> - </tr> -</template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue deleted file mode 100644 index 0a80107ced4..00000000000 --- a/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue +++ /dev/null @@ -1,56 +0,0 @@ -<script> -import { MATCH_LINE_TYPE } from '../constants'; -import DiffExpansionCell from './diff_expansion_cell.vue'; - -export default { - components: { - DiffExpansionCell, - }, - props: { - fileHash: { - type: String, - required: true, - }, - contextLinesPath: { - type: String, - required: true, - }, - line: { - type: Object, - required: true, - }, - isTop: { - type: Boolean, - required: false, - default: false, - }, - isBottom: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - isMatchLineLeft() { - return this.line.left && this.line.left.type === MATCH_LINE_TYPE; - }, - isMatchLineRight() { - return this.line.right && this.line.right.type === MATCH_LINE_TYPE; - }, - }, -}; -</script> -<template> - <tr class="line_expansion match"> - <template v-if="isMatchLineLeft || isMatchLineRight"> - <diff-expansion-cell - :file-hash="fileHash" - :context-lines-path="contextLinesPath" - :line="line.left" - :is-top="isTop" - :is-bottom="isBottom" - :colspan="6" - /> - </template> - </tr> -</template> diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue index cdc6db791f0..13cd0651ff2 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue @@ -55,27 +55,15 @@ export default { return utils.isHighlighted(state, line, this.isCommented); }, }), - isContextLineLeft() { - return utils.isContextLine(this.line.left?.type); - }, - isContextLineRight() { - return utils.isContextLine(this.line.right?.type); - }, classNameMap() { return { - [CONTEXT_LINE_CLASS_NAME]: this.isContextLineLeft, + [CONTEXT_LINE_CLASS_NAME]: this.line.isContextLineLeft, [PARALLEL_DIFF_VIEW_TYPE]: true, }; }, parallelViewLeftLineType() { return utils.parallelViewLeftLineType(this.line, this.isHighlighted); }, - isMatchLineLeft() { - return utils.isMatchLine(this.line.left?.type); - }, - isMatchLineRight() { - return utils.isMatchLine(this.line.right?.type); - }, coverageState() { return this.fileLineCoverage(this.filePath, this.line.right.new_line); }, @@ -107,40 +95,19 @@ export default { shouldShowCommentButtonLeft() { return utils.shouldShowCommentButton( this.isLeftHover, - this.isContextLineLeft, - this.isMetaLineLeft, - this.hasDiscussionsLeft, + this.line.isContextLineLeft, + this.line.isMetaLineLeft, + this.line.hasDiscussionsLeft, ); }, shouldShowCommentButtonRight() { return utils.shouldShowCommentButton( this.isRightHover, - this.isContextLineRight, - this.isMetaLineRight, - this.hasDiscussionsRight, + this.line.isContextLineRight, + this.line.isMetaLineRight, + this.line.hasDiscussionsRight, ); }, - hasDiscussionsLeft() { - return utils.hasDiscussions(this.line.left); - }, - hasDiscussionsRight() { - return utils.hasDiscussions(this.line.right); - }, - lineHrefOld() { - return utils.lineHref(this.line.left); - }, - lineHrefNew() { - return utils.lineHref(this.line.right); - }, - lineCode() { - return utils.lineCode(this.line); - }, - isMetaLineLeft() { - return utils.isMetaLine(this.line.left?.type); - }, - isMetaLineRight() { - return utils.isMetaLine(this.line.right?.type); - }, }, mounted() { this.scrollToLineIfNeededParallel(this.line); @@ -203,7 +170,7 @@ export default { @mouseover="handleMouseMove" @mouseout="handleMouseMove" > - <template v-if="line.left && !isMatchLineLeft"> + <template v-if="line.left && !line.isMatchLineLeft"> <td ref="oldTd" :class="classNameMapCellLeft" class="diff-line-num old_line"> <span v-if="shouldRenderCommentButton" @@ -227,12 +194,12 @@ export default { v-if="line.left.old_line" ref="lineNumberRefOld" :data-linenumber="line.left.old_line" - :href="lineHrefOld" - @click="setHighlightedRow(lineCode)" + :href="line.lineHrefOld" + @click="setHighlightedRow(line.lineCode)" > </a> <diff-gutter-avatars - v-if="hasDiscussionsLeft" + v-if="line.hasDiscussionsLeft" :discussions="line.left.discussions" :discussions-expanded="line.left.discussionsExpanded" @toggleLineDiscussions=" @@ -259,7 +226,7 @@ export default { <td class="line-coverage left-side empty-cell"></td> <td class="line_content with-coverage parallel left-side empty-cell"></td> </template> - <template v-if="line.right && !isMatchLineRight"> + <template v-if="line.right && !line.isMatchLineRight"> <td ref="newTd" :class="classNameMapCellRight" class="diff-line-num new_line"> <span v-if="shouldRenderCommentButton" @@ -283,12 +250,12 @@ export default { v-if="line.right.new_line" ref="lineNumberRefNew" :data-linenumber="line.right.new_line" - :href="lineHrefNew" - @click="setHighlightedRow(lineCode)" + :href="line.lineHrefNew" + @click="setHighlightedRow(line.lineCode)" > </a> <diff-gutter-avatars - v-if="hasDiscussionsRight" + v-if="line.hasDiscussionsRight" :discussions="line.right.discussions" :discussions-expanded="line.right.discussionsExpanded" @toggleLineDiscussions=" diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue index 46a691ad22d..67b599fe163 100644 --- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue +++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue @@ -1,18 +1,18 @@ <script> import { mapGetters, mapState } from 'vuex'; import draftCommentsMixin from '~/diffs/mixins/draft_comments'; -import ParallelDraftCommentRow from '~/batch_comments/components/parallel_draft_comment_row.vue'; +import DraftNote from '~/batch_comments/components/draft_note.vue'; import parallelDiffTableRow from './parallel_diff_table_row.vue'; -import parallelDiffCommentRow from './parallel_diff_comment_row.vue'; -import parallelDiffExpansionRow from './parallel_diff_expansion_row.vue'; +import DiffCommentCell from './diff_comment_cell.vue'; +import DiffExpansionCell from './diff_expansion_cell.vue'; import { getCommentedLines } from '~/notes/components/multiline_comment_utils'; export default { components: { - parallelDiffExpansionRow, + DiffExpansionCell, parallelDiffTableRow, - parallelDiffCommentRow, - ParallelDraftCommentRow, + DiffCommentCell, + DraftNote, }, mixins: [draftCommentsMixin], props: { @@ -66,14 +66,21 @@ export default { </colgroup> <tbody> <template v-for="(line, index) in diffLines"> - <parallel-diff-expansion-row + <tr + v-if="line.isMatchLineLeft || line.isMatchLineRight" :key="`expand-${index}`" - :file-hash="diffFile.file_hash" - :context-lines-path="diffFile.context_lines_path" - :line="line" - :is-top="index === 0" - :is-bottom="index + 1 === diffLinesLength" - /> + class="line_expansion match" + > + <td colspan="6" class="text-center gl-font-regular"> + <diff-expansion-cell + :file-hash="diffFile.file_hash" + :context-lines-path="diffFile.context_lines_path" + :line="line.left" + :is-top="index === 0" + :is-bottom="index + 1 === diffLinesLength" + /> + </td> + </tr> <parallel-diff-table-row :key="line.line_code" :file-hash="diffFile.file_hash" @@ -82,21 +89,53 @@ export default { :is-bottom="index + 1 === diffLinesLength" :is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine" /> - <parallel-diff-comment-row + <tr + v-if="line.renderCommentRow" :key="`dcr-${line.line_code || index}`" - :line="line" - :diff-file-hash="diffFile.file_hash" - :line-index="index" - :help-page-path="helpPagePath" - :has-draft-left="hasParallelDraftLeft(diffFile.file_hash, line) || false" - :has-draft-right="hasParallelDraftRight(diffFile.file_hash, line) || false" - /> - <parallel-draft-comment-row + :class="line.commentRowClasses" + class="notes_holder" + > + <td class="notes-content parallel old" colspan="3"> + <diff-comment-cell + v-if="line.left" + :line="line.left" + :diff-file-hash="diffFile.file_hash" + :help-page-path="helpPagePath" + :has-draft="line.left.hasDraft" + line-position="left" + /> + </td> + <td class="notes-content parallel new" colspan="3"> + <diff-comment-cell + v-if="line.right" + :line="line.right" + :diff-file-hash="diffFile.file_hash" + :line-index="index" + :help-page-path="helpPagePath" + :has-draft="line.right.hasDraft" + line-position="right" + /> + </td> + </tr> + <tr v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)" :key="`drafts-${index}`" - :line="line" - :diff-file-content-sha="diffFile.file_hash" - /> + :class="line.draftRowClasses" + class="notes_holder" + > + <td class="notes_line old"></td> + <td class="notes-content parallel old" colspan="2"> + <div v-if="line.left && line.left.lineDraft.isDraft" class="content"> + <draft-note :draft="line.left.lineDraft" :line="line.left" /> + </div> + </td> + <td class="notes_line new"></td> + <td class="notes-content parallel new" colspan="2"> + <div v-if="line.right && line.right.lineDraft.isDraft" class="content"> + <draft-note :draft="line.right.lineDraft" :line="line.right" /> + </div> + </td> + </tr> </template> </tbody> </table> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index dc97d9993da..79f8c08e389 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -73,6 +73,10 @@ export const ALERT_OVERFLOW_HIDDEN = 'overflow'; export const ALERT_MERGE_CONFLICT = 'merge-conflict'; export const ALERT_COLLAPSED_FILES = 'collapsed'; +// Diff File collapse types +export const DIFF_FILE_AUTOMATIC_COLLAPSE = 'automatic'; +export const DIFF_FILE_MANUAL_COLLAPSE = 'manual'; + // State machine states export const STATE_IDLING = 'idle'; export const STATE_LOADING = 'loading'; @@ -91,3 +95,11 @@ export const RENAMED_DIFF_TRANSITIONS = { [`${STATE_ERRORED}:${TRANSITION_LOAD_START}`]: STATE_LOADING, [`${STATE_ERRORED}:${TRANSITION_ACKNOWLEDGE_ERROR}`]: STATE_IDLING, }; + +// MR Diffs known events +export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles'; +export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart'; +export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd'; +export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart'; +export const EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN = 'mr:diffs:perf:firstFileShown'; +export const EVT_PERF_MARK_DIFF_FILES_END = 'mr:diffs:perf:filesEnd'; diff --git a/app/assets/javascripts/diffs/diff_file.js b/app/assets/javascripts/diffs/diff_file.js index 933197a2c7f..a14a30b41a9 100644 --- a/app/assets/javascripts/diffs/diff_file.js +++ b/app/assets/javascripts/diffs/diff_file.js @@ -1,4 +1,9 @@ -import { DIFF_FILE_SYMLINK_MODE, DIFF_FILE_DELETED_MODE } from './constants'; +import { + DIFF_FILE_SYMLINK_MODE, + DIFF_FILE_DELETED_MODE, + DIFF_FILE_MANUAL_COLLAPSE, + DIFF_FILE_AUTOMATIC_COLLAPSE, +} from './constants'; function fileSymlinkInformation(file, fileList) { const duplicates = fileList.filter(iteratedFile => iteratedFile.file_hash === file.file_hash); @@ -23,6 +28,7 @@ function collapsed(file) { return { automaticallyCollapsed: viewer.automaticallyCollapsed || viewer.collapsed || false, + manuallyCollapsed: null, }; } @@ -37,3 +43,19 @@ export function prepareRawDiffFile({ file, allFiles }) { return file; } + +export function collapsedType(file) { + const isManual = typeof file.viewer?.manuallyCollapsed === 'boolean'; + + return isManual ? DIFF_FILE_MANUAL_COLLAPSE : DIFF_FILE_AUTOMATIC_COLLAPSE; +} + +export function isCollapsed(file) { + const type = collapsedType(file); + const collapsedStates = { + [DIFF_FILE_AUTOMATIC_COLLAPSE]: file.viewer?.automaticallyCollapsed || false, + [DIFF_FILE_MANUAL_COLLAPSE]: file.viewer?.manuallyCollapsed, + }; + + return collapsedStates[type]; +} diff --git a/app/assets/javascripts/diffs/event_hub.js b/app/assets/javascripts/diffs/event_hub.js new file mode 100644 index 00000000000..3e0c313f5e8 --- /dev/null +++ b/app/assets/javascripts/diffs/event_hub.js @@ -0,0 +1,3 @@ +import eventHubFactory from '~/helpers/event_hub_factory'; + +export default eventHubFactory(); diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index 8699cd88a18..4ec24d452bf 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -1,5 +1,18 @@ import { __ } from '~/locale'; +export const GENERIC_ERROR = __('Something went wrong on our end. Please try again!'); + export const DIFF_FILE_HEADER = { optionsDropdownTitle: __('Options'), }; + +export const DIFF_FILE = { + blobView: __('You can %{linkStart}view the blob%{linkEnd} instead.'), + editInFork: __( + "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.", + ), + fork: __('Fork'), + cancel: __('Cancel'), + autoCollapsed: __('Files with large changes are collapsed by default.'), + expand: __('Expand file'), +}; diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 966b706fc31..91c4c51487f 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -8,7 +8,8 @@ import { __, s__ } from '~/locale'; import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; import TreeWorker from '../workers/tree_worker'; -import eventHub from '../../notes/event_hub'; +import notesEventHub from '../../notes/event_hub'; +import eventHub from '../event_hub'; import { getDiffPositionByLineCode, getNoteFormData, @@ -40,8 +41,14 @@ import { DIFF_WHITESPACE_COOKIE_NAME, SHOW_WHITESPACE, NO_SHOW_WHITESPACE, + DIFF_FILE_MANUAL_COLLAPSE, + DIFF_FILE_AUTOMATIC_COLLAPSE, + EVT_PERF_MARK_FILE_TREE_START, + EVT_PERF_MARK_FILE_TREE_END, + EVT_PERF_MARK_DIFF_FILES_START, } from '../constants'; import { diffViewerModes } from '~/ide/constants'; +import { isCollapsed } from '../diff_file'; export const setBaseConfig = ({ commit }, options) => { const { @@ -75,6 +82,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { commit(types.SET_BATCH_LOADING, true); commit(types.SET_RETRIEVING_BATCHES, true); + eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START); const getBatch = (page = 1) => axios @@ -136,9 +144,11 @@ export const fetchDiffFilesMeta = ({ commit, state }) => { }; commit(types.SET_LOADING, true); + eventHub.$emit(EVT_PERF_MARK_FILE_TREE_START); worker.addEventListener('message', ({ data }) => { commit(types.SET_TREE_DATA, data); + eventHub.$emit(EVT_PERF_MARK_FILE_TREE_END); worker.terminate(); }); @@ -212,7 +222,7 @@ export const assignDiscussionsToDiff = ( } Vue.nextTick(() => { - eventHub.$emit('scrollToDiscussion'); + notesEventHub.$emit('scrollToDiscussion'); }); }; @@ -237,10 +247,17 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi } if (file.viewer.automaticallyCollapsed) { - eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`); + notesEventHub.$emit(`loadCollapsedDiff/${file.file_hash}`); scrollToElement(document.getElementById(file.file_hash)); + } else if (file.viewer.manuallyCollapsed) { + commit(types.SET_FILE_COLLAPSED, { + filePath: file.file_path, + collapsed: false, + trigger: DIFF_FILE_AUTOMATIC_COLLAPSE, + }); + notesEventHub.$emit('scrollToDiscussion'); } else { - eventHub.$emit('scrollToDiscussion'); + notesEventHub.$emit('scrollToDiscussion'); } } } @@ -252,8 +269,7 @@ export const startRenderDiffsQueue = ({ state, commit }) => { const nextFile = state.diffFiles.find( file => !file.renderIt && - (file.viewer && - (!file.viewer.automaticallyCollapsed || file.viewer.name !== diffViewerModes.text)), + (file.viewer && (!isCollapsed(file) || file.viewer.name !== diffViewerModes.text)), ); if (nextFile) { @@ -355,10 +371,6 @@ export const loadCollapsedDiff = ({ commit, getters, state }, file) => }); }); -export const expandAllFiles = ({ commit }) => { - commit(types.EXPAND_ALL_FILES); -}; - /** * Toggles the file discussions after user clicked on the toggle discussions button. * @@ -480,7 +492,7 @@ export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = fals historyPushState(mergeUrlParams({ w }, window.location.href)); } - eventHub.$emit('refetchDiffData'); + notesEventHub.$emit('refetchDiffData'); }; export const toggleFileFinder = ({ commit }, visible) => { @@ -531,15 +543,20 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => { }), }), }; + const unifiedDiffLinesEnabled = window.gon?.features?.unifiedDiffLines; const currentDiffLinesKey = - state.diffViewType === INLINE_DIFF_VIEW_TYPE ? INLINE_DIFF_LINES_KEY : PARALLEL_DIFF_LINES_KEY; + 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; - commit(types.SET_HIDDEN_VIEW_DIFF_FILE_LINES, { - filePath: file.file_path, - lines: expandedDiffLines[hiddenDiffLinesKey], - }); + 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) { let index = START_RENDERING_INDEX; @@ -621,7 +638,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d .then(({ data }) => { const lines = data.map((line, index) => prepareLineForRenamedFile({ - diffViewType: state.diffViewType, + diffViewType: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType, line, diffFile, index, @@ -633,6 +650,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d viewer: { ...diffFile.alternate_viewer, automaticallyCollapsed: false, + manuallyCollapsed: false, }, }); commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines }); @@ -641,8 +659,9 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d }); } -export const setFileCollapsed = ({ commit }, { filePath, collapsed }) => - commit(types.SET_FILE_COLLAPSED, { filePath, collapsed }); +export const setFileCollapsedByUser = ({ commit }, { filePath, collapsed }) => { + commit(types.SET_FILE_COLLAPSED, { filePath, collapsed, trigger: DIFF_FILE_MANUAL_COLLAPSE }); +}; export const setSuggestPopoverDismissed = ({ commit, state }) => axios diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 91425c7825b..9ee73998177 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -8,8 +8,16 @@ export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE; -export const hasCollapsedFile = state => - state.diffFiles.some(file => file.viewer && file.viewer.automaticallyCollapsed); +export const whichCollapsedTypes = state => { + const automatic = state.diffFiles.some(file => file.viewer?.automaticallyCollapsed); + const manual = state.diffFiles.some(file => file.viewer?.manuallyCollapsed); + + return { + any: automatic || manual, + automatic, + manual, + }; +}; export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null); @@ -157,10 +165,13 @@ export const fileLineCoverage = state => (file, line) => { export const currentDiffIndex = state => Math.max(0, state.diffFiles.findIndex(diff => diff.file_hash === state.currentDiffFileId)); -export const diffLines = state => file => { - if (state.diffViewType === INLINE_DIFF_VIEW_TYPE) { +export const diffLines = state => (file, unifiedDiffComponents) => { + if (!unifiedDiffComponents && state.diffViewType === INLINE_DIFF_VIEW_TYPE) { return null; } - return parallelizeDiffLines(file.highlighted_diff_lines || []); + return parallelizeDiffLines( + file.highlighted_diff_lines || [], + state.diffViewType === INLINE_DIFF_VIEW_TYPE, + ); }; diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js index 5dba2e9d10d..19a9e65edc9 100644 --- a/app/assets/javascripts/diffs/store/mutation_types.js +++ b/app/assets/javascripts/diffs/store/mutation_types.js @@ -13,7 +13,6 @@ export const SET_MERGE_REQUEST_DIFFS = 'SET_MERGE_REQUEST_DIFFS'; export const TOGGLE_LINE_HAS_FORM = 'TOGGLE_LINE_HAS_FORM'; export const ADD_CONTEXT_LINES = 'ADD_CONTEXT_LINES'; export const ADD_COLLAPSED_DIFFS = 'ADD_COLLAPSED_DIFFS'; -export const EXPAND_ALL_FILES = 'EXPAND_ALL_FILES'; export const RENDER_FILE = 'RENDER_FILE'; export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE'; export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE'; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 13ecf6a997d..096c4f69439 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -1,6 +1,10 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { INLINE_DIFF_VIEW_TYPE } from '../constants'; +import { + DIFF_FILE_MANUAL_COLLAPSE, + DIFF_FILE_AUTOMATIC_COLLAPSE, + INLINE_DIFF_VIEW_TYPE, +} from '../constants'; import { findDiffFile, addLineReferences, @@ -16,6 +20,12 @@ function updateDiffFilesInState(state, files) { return Object.assign(state, { diffFiles: files }); } +function renderFile(file) { + Object.assign(file, { + renderIt: true, + }); +} + export default { [types.SET_BASE_CONFIG](state, options) { const { @@ -81,9 +91,7 @@ export default { }, [types.RENDER_FILE](state, file) { - Object.assign(file, { - renderIt: true, - }); + renderFile(file); }, [types.SET_MERGE_REQUEST_DIFFS](state, mergeRequestDiffs) { @@ -168,16 +176,6 @@ export default { Object.assign(selectedFile, { ...newFileData }); }, - [types.EXPAND_ALL_FILES](state) { - state.diffFiles.forEach(file => { - Object.assign(file, { - viewer: Object.assign(file.viewer, { - automaticallyCollapsed: false, - }), - }); - }); - }, - [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) { const { latestDiff } = state; @@ -351,11 +349,24 @@ export default { file.isShowingFullFile = true; file.isLoadingFullFile = false; }, - [types.SET_FILE_COLLAPSED](state, { filePath, collapsed }) { + [types.SET_FILE_COLLAPSED]( + state, + { filePath, collapsed, trigger = DIFF_FILE_AUTOMATIC_COLLAPSE }, + ) { const file = state.diffFiles.find(f => f.file_path === filePath); if (file && file.viewer) { - file.viewer.automaticallyCollapsed = collapsed; + if (trigger === DIFF_FILE_MANUAL_COLLAPSE) { + file.viewer.automaticallyCollapsed = false; + file.viewer.manuallyCollapsed = collapsed; + } else if (trigger === DIFF_FILE_AUTOMATIC_COLLAPSE) { + file.viewer.automaticallyCollapsed = collapsed; + file.viewer.manuallyCollapsed = null; + } + } + + if (file && !collapsed) { + renderFile(file); } }, [types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { @@ -367,8 +378,13 @@ export default { }, [types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) { const file = state.diffFiles.find(f => f.file_path === filePath); - const currentDiffLinesKey = - state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines'; + let currentDiffLinesKey; + + if (window.gon?.features?.unifiedDiffLines || state.diffViewType === 'inline') { + currentDiffLinesKey = 'highlighted_diff_lines'; + } else { + currentDiffLinesKey = 'parallel_diff_lines'; + } file[currentDiffLinesKey] = lines; }, diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 69330ffae2f..f87f57c32c3 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -36,9 +36,12 @@ export const isMeta = line => ['match', 'new-nonewline', 'old-nonewline'].includ * * @param {Object[]} diffLines - inline diff lines * + * @param {Boolean} inline - is inline context or not + * * @returns {Object[]} parallel lines */ -export const parallelizeDiffLines = (diffLines = []) => { + +export const parallelizeDiffLines = (diffLines, inline) => { let freeRightIndex = null; const lines = []; @@ -57,7 +60,7 @@ export const parallelizeDiffLines = (diffLines = []) => { } index += 1; } else if (isAdded(line)) { - if (freeRightIndex !== null) { + if (freeRightIndex !== null && !inline) { // If an old line came before this without a line on the right, this // line can be put to the right of it. lines[freeRightIndex].right = line; @@ -664,6 +667,7 @@ export const generateTreeList = files => { addedLines: file.added_lines, removedLines: file.removed_lines, parentPath: parent ? `${parent.path}/` : '/', + submodule: file.submodule, }); } else { Object.assign(entry, { diff --git a/app/assets/javascripts/diffs/utils/performance.js b/app/assets/javascripts/diffs/utils/performance.js new file mode 100644 index 00000000000..dcde6f4ecc4 --- /dev/null +++ b/app/assets/javascripts/diffs/utils/performance.js @@ -0,0 +1,80 @@ +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { + MR_DIFFS_MARK_FILE_TREE_START, + MR_DIFFS_MARK_FILE_TREE_END, + MR_DIFFS_MARK_DIFF_FILES_START, + MR_DIFFS_MARK_FIRST_DIFF_FILE_SHOWN, + MR_DIFFS_MARK_DIFF_FILES_END, + MR_DIFFS_MEASURE_FILE_TREE_DONE, + MR_DIFFS_MEASURE_DIFF_FILES_DONE, +} from '../../performance/constants'; + +import eventHub from '../event_hub'; +import { + EVT_PERF_MARK_FILE_TREE_START, + EVT_PERF_MARK_FILE_TREE_END, + EVT_PERF_MARK_DIFF_FILES_START, + EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, + EVT_PERF_MARK_DIFF_FILES_END, +} from '../constants'; + +function treeStart() { + performanceMarkAndMeasure({ + mark: MR_DIFFS_MARK_FILE_TREE_START, + }); +} + +function treeEnd() { + performanceMarkAndMeasure({ + mark: MR_DIFFS_MARK_FILE_TREE_END, + measures: [ + { + name: MR_DIFFS_MEASURE_FILE_TREE_DONE, + start: MR_DIFFS_MARK_FILE_TREE_START, + end: MR_DIFFS_MARK_FILE_TREE_END, + }, + ], + }); +} + +function filesStart() { + performanceMarkAndMeasure({ + mark: MR_DIFFS_MARK_DIFF_FILES_START, + }); +} + +function filesEnd() { + performanceMarkAndMeasure({ + mark: MR_DIFFS_MARK_DIFF_FILES_END, + measures: [ + { + name: MR_DIFFS_MEASURE_DIFF_FILES_DONE, + start: MR_DIFFS_MARK_DIFF_FILES_START, + end: MR_DIFFS_MARK_DIFF_FILES_END, + }, + ], + }); +} + +function firstFile() { + performanceMarkAndMeasure({ + mark: MR_DIFFS_MARK_FIRST_DIFF_FILE_SHOWN, + }); +} + +export const diffsApp = { + instrument() { + eventHub.$on(EVT_PERF_MARK_FILE_TREE_START, treeStart); + eventHub.$on(EVT_PERF_MARK_FILE_TREE_END, treeEnd); + eventHub.$on(EVT_PERF_MARK_DIFF_FILES_START, filesStart); + eventHub.$on(EVT_PERF_MARK_DIFF_FILES_END, filesEnd); + eventHub.$on(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, firstFile); + }, + deinstrument() { + eventHub.$off(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, firstFile); + eventHub.$off(EVT_PERF_MARK_DIFF_FILES_END, filesEnd); + eventHub.$off(EVT_PERF_MARK_DIFF_FILES_START, filesStart); + eventHub.$off(EVT_PERF_MARK_FILE_TREE_END, treeEnd); + eventHub.$off(EVT_PERF_MARK_FILE_TREE_START, treeStart); + }, +}; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index f65e22a31c5..69961d2e07a 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -7,6 +7,7 @@ import csrf from './lib/utils/csrf'; import axios from './lib/utils/axios_utils'; import { n__, __ } from '~/locale'; import { getFilename } from '~/lib/utils/file_upload'; +import { spriteIcon } from '~/lib/utils/common_utils'; Dropzone.autoDiscover = false; @@ -25,7 +26,7 @@ function getErrorMessage(res) { export default function dropzoneInput(form, config = { parallelUploads: 2 }) { const divHover = '<div class="div-dropzone-hover"></div>'; - const iconPaperclip = '<i class="fa fa-paperclip div-dropzone-icon"></i>'; + const iconPaperclip = spriteIcon('paperclip', 'div-dropzone-icon s24'); const $attachButton = form.find('.button-attach-file'); const $attachingFileMessage = form.find('.attaching-file-message'); const $cancelButton = form.find('.button-cancel-uploading-files'); diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js index e52e64d4c2d..e7535c211db 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/editor_lite.js @@ -6,6 +6,7 @@ import { registerLanguages } from '~/ide/utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { clearDomElement } from './utils'; import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from './constants'; +import { uuids } from '~/diffs/utils/uuids'; export default class Editor { constructor(options = {}) { @@ -72,7 +73,7 @@ export default class Editor { el = undefined, blobPath = '', blobContent = '', - blobGlobalId = '', + blobGlobalId = uuids()[0], extensions = [], ...instanceOptions } = {}) { diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue index 4c6d233c4d2..e7697f14802 100644 --- a/app/assets/javascripts/environments/components/container.vue +++ b/app/assets/javascripts/environments/components/container.vue @@ -69,7 +69,7 @@ export default { <div class="environments-container"> <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" label="Loading environments" /> - <slot name="emptyState"></slot> + <slot name="empty-state"></slot> <div v-if="!isLoading && environments.length > 0" class="table-holder"> <environment-table diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue index 29aab268fd3..2eb2be351b3 100644 --- a/app/assets/javascripts/environments/components/delete_environment_modal.vue +++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue @@ -1,29 +1,35 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; -import GlModal from '~/vue_shared/components/gl_modal.vue'; +import { GlTooltipDirective, GlModal } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; export default { id: 'delete-environment-modal', name: 'DeleteEnvironmentModal', - components: { GlModal, }, - directives: { GlTooltip: GlTooltipDirective, }, - props: { environment: { type: Object, required: true, }, }, - computed: { + primaryProps() { + return { + text: s__('Environments|Delete environment'), + attributes: [{ variant: 'danger' }], + }; + }, + cancelProps() { + return { + text: s__('Cancel'), + }; + }, confirmDeleteMessage() { return sprintf( s__( @@ -35,8 +41,12 @@ export default { false, ); }, + modalTitle() { + return sprintf(s__(`Environments|Delete '%{environmentName}'?`), { + environmentName: this.environment.name, + }); + }, }, - methods: { onSubmit() { eventHub.$emit('deleteEnvironment', this.environment); @@ -47,20 +57,12 @@ export default { <template> <gl-modal - :id="$options.id" - :footer-primary-button-text="s__('Environments|Delete environment')" - footer-primary-button-variant="danger" - @submit="onSubmit" + :modal-id="$options.id" + :action-primary="primaryProps" + :action-cancel="cancelProps" + :title="modalTitle" + @primary="onSubmit" > - <template #header> - <h4 class="modal-title d-flex mw-100"> - {{ __('Delete') }} - <span v-gl-tooltip :title="environment.name" class="text-truncate mx-1 flex-fill"> - {{ environment.name }}? - </span> - </h4> - </template> - <p>{{ confirmDeleteMessage }}</p> </gl-modal> </template> diff --git a/app/assets/javascripts/environments/components/environment_delete.vue b/app/assets/javascripts/environments/components/environment_delete.vue index 039b40a3596..75d92d3295d 100644 --- a/app/assets/javascripts/environments/components/environment_delete.vue +++ b/app/assets/javascripts/environments/components/environment_delete.vue @@ -1,21 +1,20 @@ <script> /** * Renders the delete button that allows deleting a stopped environment. - * Used in the environments table and the environment detail view. + * Used in the environments table. */ -import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; -import LoadingButton from '../../vue_shared/components/loading_button.vue'; export default { components: { - GlIcon, - LoadingButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, + GlModalDirective, }, props: { environment: { @@ -54,16 +53,16 @@ export default { }; </script> <template> - <loading-button + <gl-button v-gl-tooltip="{ id: $options.deleteEnvironmentTooltipId }" + v-gl-modal-directive="'delete-environment-modal'" :loading="isLoading" :title="title" :aria-label="title" - container-class="btn btn-danger d-none d-md-block" - data-toggle="modal" - data-target="#delete-environment-modal" + class="gl-display-none gl-display-md-block" + variant="danger" + category="primary" + icon="remove" @click="onClick" - > - <gl-icon name="remove" /> - </loading-button> + /> </template> diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index ff74f81c98e..8e100623199 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -4,7 +4,7 @@ * Used in environments table. */ -import { GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; @@ -14,6 +14,7 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, + GlModalDirective, }, props: { environment: { @@ -54,14 +55,13 @@ export default { <template> <gl-button v-gl-tooltip="{ id: $options.stopEnvironmentTooltipId }" + v-gl-modal-directive="'stop-environment-modal'" :loading="isLoading" :title="title" :aria-label="title" icon="stop" category="primary" variant="danger" - data-toggle="modal" - data-target="#stop-environment-modal" @click="onClick" /> </template> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 9bafc7ed153..c1b9ba755a6 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -228,7 +228,7 @@ export default { :deploy-boards-help-path="deployBoardsHelpPath" @onChangePage="onChangePage" > - <template v-if="!isLoading && state.environments.length === 0" #emptyState> + <template v-if="!isLoading && state.environments.length === 0" #empty-state> <empty-state :help-path="helpPagePath" /> </template> </container> diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue index f0dafe0620e..0832822520d 100644 --- a/app/assets/javascripts/environments/components/stop_environment_modal.vue +++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue @@ -1,15 +1,14 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; +import { GlSprintf, GlTooltipDirective, GlModal } from '@gitlab/ui'; import eventHub from '../event_hub'; +import { __, s__ } from '~/locale'; export default { id: 'stop-environment-modal', name: 'StopEnvironmentModal', components: { - GlModal: DeprecatedModal2, + GlModal, GlSprintf, }, @@ -24,6 +23,20 @@ export default { }, }, + computed: { + primaryProps() { + return { + text: s__('Environments|Stop environment'), + attributes: [{ variant: 'danger' }], + }; + }, + cancelProps() { + return { + text: __('Cancel'), + }; + }, + }, + methods: { onSubmit() { eventHub.$emit('stopEnvironment', this.environment); @@ -34,18 +47,23 @@ export default { <template> <gl-modal - :id="$options.id" - :footer-primary-button-text="s__('Environments|Stop environment')" - footer-primary-button-variant="danger" - @submit="onSubmit" + :modal-id="$options.id" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="onSubmit" > - <template #header> - <h4 class="modal-title d-flex mw-100"> - Stopping - <span v-gl-tooltip :title="environment.name" class="text-truncate ml-1 mr-1 flex-fill"> - {{ environment.name }}? - </span> - </h4> + <template #modal-title> + <gl-sprintf :message="s__('Environments|Stopping %{environmentName}')"> + <template #environmentName> + <span + v-gl-tooltip + :title="environment.name" + class="gl-text-truncate gl-ml-2 gl-mr-2 gl-flex-fill" + > + {{ environment.name }}? + </span> + </template> + </gl-sprintf> </template> <p>{{ s__('Environments|Are you sure you want to stop this environment?') }}</p> diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 5dee3ef3ffe..8272260705b 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -8,9 +8,9 @@ import { GlBadge, GlAlert, GlSprintf, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, GlIcon, } from '@gitlab/ui'; import { deprecatedCreateFlash as createFlash } from '~/flash'; @@ -43,9 +43,9 @@ export default { GlBadge, GlAlert, GlSprintf, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, TimeAgoTooltip, }, directives: { @@ -331,38 +331,38 @@ export default { </gl-button> </form> </div> - <gl-deprecated-dropdown + <gl-dropdown text="Options" class="error-details-options d-md-none" right :disabled="issueUpdateInProgress" > - <gl-deprecated-dropdown-item + <gl-dropdown-item data-qa-selector="update_ignore_status_button" @click="onIgnoreStatusUpdate" - >{{ ignoreBtnLabel }}</gl-deprecated-dropdown-item + >{{ ignoreBtnLabel }}</gl-dropdown-item > - <gl-deprecated-dropdown-item + <gl-dropdown-item data-qa-selector="update_resolve_status_button" @click="onResolveStatusUpdate" - >{{ resolveBtnLabel }}</gl-deprecated-dropdown-item + >{{ resolveBtnLabel }}</gl-dropdown-item > - <gl-deprecated-dropdown-divider /> - <gl-deprecated-dropdown-item + <gl-dropdown-divider /> + <gl-dropdown-item v-if="error.gitlabIssuePath" data-qa-selector="view_issue_button" :href="error.gitlabIssuePath" variant="success" - >{{ __('View issue') }}</gl-deprecated-dropdown-item + >{{ __('View issue') }}</gl-dropdown-item > - <gl-deprecated-dropdown-item + <gl-dropdown-item v-if="!error.gitlabIssuePath" :loading="issueCreationInProgress" data-qa-selector="create_issue_button" @click="createIssue" - >{{ __('Create issue') }}</gl-deprecated-dropdown-item + >{{ __('Create issue') }}</gl-dropdown-item > - </gl-deprecated-dropdown> + </gl-dropdown> </div> </div> <div> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index da41dc4c9d9..7ccb6253508 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -8,9 +8,9 @@ import { GlLoadingIcon, GlTable, GlFormInput, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, GlTooltipDirective, GlPagination, } from '@gitlab/ui'; @@ -72,9 +72,9 @@ export default { components: { GlEmptyState, GlButton, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, - GlDeprecatedDropdownDivider, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, GlIcon, GlLink, GlLoadingIcon, @@ -233,30 +233,30 @@ export default { > <div class="search-box flex-fill mb-1 mb-md-0"> <div class="filtered-search-box mb-0"> - <gl-deprecated-dropdown + <gl-dropdown :text="__('Recent searches')" class="filtered-search-history-dropdown-wrapper" - toggle-class="filtered-search-history-dropdown-toggle-button" + toggle-class="filtered-search-history-dropdown-toggle-button gl-shadow-none! gl-border-r-gray-200! gl-border-1! gl-rounded-0!" :disabled="loading" > <div v-if="!$options.hasLocalStorage" class="px-3"> {{ __('This feature requires local storage to be enabled') }} </div> <template v-else-if="recentSearches.length > 0"> - <gl-deprecated-dropdown-item + <gl-dropdown-item v-for="searchQuery in recentSearches" :key="searchQuery" @click="setSearchText(searchQuery)" >{{ searchQuery }} - </gl-deprecated-dropdown-item> - <gl-deprecated-dropdown-divider /> - <gl-deprecated-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches" + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches" >{{ __('Clear recent searches') }} - </gl-deprecated-dropdown-item> + </gl-dropdown-item> </template> <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> - </gl-deprecated-dropdown> - <div class="filtered-search-input-container flex-fill"> + </gl-dropdown> + <div class="filtered-search-input-container gl-flex-fill-1"> <gl-form-input v-model="errorSearchQuery" class="pl-2 filtered-search" @@ -280,49 +280,44 @@ export default { </div> </div> - <gl-deprecated-dropdown + <gl-dropdown :text="$options.statusFilters[statusFilter]" class="status-dropdown mx-md-1 mb-1 mb-md-0" - menu-class="dropdown" :disabled="loading" + right > - <gl-deprecated-dropdown-item + <gl-dropdown-item v-for="(label, status) in $options.statusFilters" :key="status" @click="filterErrors(status, label)" > <span class="d-flex"> <gl-icon - class="flex-shrink-0 append-right-4" + class="gl-new-dropdown-item-check-icon" :class="{ invisible: !isCurrentStatusFilter(status) }" name="mobile-issue-close" /> {{ label }} </span> - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + </gl-dropdown-item> + </gl-dropdown> - <gl-deprecated-dropdown - :text="$options.sortFields[sortField]" - left - :disabled="loading" - menu-class="dropdown" - > - <gl-deprecated-dropdown-item + <gl-dropdown :text="$options.sortFields[sortField]" right :disabled="loading"> + <gl-dropdown-item v-for="(label, field) in $options.sortFields" :key="field" @click="sortByField(field)" > <span class="d-flex"> <gl-icon - class="flex-shrink-0 append-right-4" + class="gl-new-dropdown-item-check-icon" :class="{ invisible: !isCurrentSortField(field) }" name="mobile-issue-close" /> {{ label }} </span> - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + </gl-dropdown-item> + </gl-dropdown> </div> <div v-if="loading" class="py-3"> diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue index 561b2565880..2323370a3aa 100644 --- a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue +++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue @@ -1,11 +1,11 @@ <script> -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { getDisplayName } from '../utils'; export default { components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, }, props: { dropdownLabel: { @@ -52,22 +52,22 @@ export default { <div :class="{ 'gl-show-field-errors': isProjectInvalid }"> <label class="label-bold" for="project-dropdown">{{ __('Project') }}</label> <div class="row"> - <gl-deprecated-dropdown + <gl-dropdown id="project-dropdown" class="col-8 col-md-9 gl-pr-0" :disabled="!hasProjects" menu-class="w-100 mw-100" - toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline" + toggle-class="dropdown-menu-toggle gl-field-error-outline" :text="dropdownLabel" > - <gl-deprecated-dropdown-item + <gl-dropdown-item v-for="project in projects" :key="`${project.organizationSlug}.${project.slug}`" class="w-100" @click="$emit('select-project', project)" - >{{ getDisplayName(project) }}</gl-deprecated-dropdown-item + >{{ getDisplayName(project) }}</gl-dropdown-item > - </gl-deprecated-dropdown> + </gl-dropdown> </div> <p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error"> {{ invalidProjectLabel }} diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue index 686399843dd..bf47d7cf7c0 100644 --- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue +++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue @@ -2,6 +2,7 @@ import { GlFormGroup, GlFormInput, + GlFormInputGroup, GlModal, GlTooltipDirective, GlLoadingIcon, @@ -17,6 +18,7 @@ export default { components: { GlFormGroup, GlFormInput, + GlFormInputGroup, GlModal, ModalCopyButton, GlIcon, @@ -167,63 +169,47 @@ export default { </template> </gl-sprintf> </callout> - <div class="form-group"> - <label for="api_url" class="label-bold">{{ $options.translations.apiUrlLabelText }}</label> - <div class="input-group"> - <input - id="api_url" - :value="unleashApiUrl" - readonly - class="form-control" - type="text" - name="api_url" - /> - <span class="input-group-append"> + <gl-form-group :label="$options.translations.apiUrlLabelText" label-for="api-url"> + <gl-form-input-group id="api-url" :value="unleashApiUrl" readonly type="text" name="api-url"> + <template #append> <modal-copy-button :text="unleashApiUrl" :title="$options.translations.apiUrlCopyText" :modal-id="modalId" - class="input-group-text" /> - </span> - </div> - </div> - <div class="form-group"> - <label for="instance_id" class="label-bold">{{ - $options.translations.instanceIdLabelText - }}</label> - <div class="input-group"> - <input + </template> + </gl-form-input-group> + </gl-form-group> + <gl-form-group :label="$options.translations.instanceIdLabelText" label-for="instance_id"> + <gl-form-input-group> + <gl-form-input id="instance_id" :value="instanceId" - class="form-control" type="text" name="instance_id" readonly :disabled="isRotating" /> - <gl-loading-icon v-if="isRotating" - class="position-absolute align-self-center instance-id-loading-icon" + class="gl-absolute gl-align-self-center gl-right-5 gl-mr-7" /> - <div class="input-group-append"> + <template #append> <modal-copy-button :text="instanceId" :title="$options.translations.instanceIdCopyText" :modal-id="modalId" :disabled="isRotating" - class="input-group-text" /> - </div> - </div> - </div> + </template> + </gl-form-input-group> + </gl-form-group> <div v-if="hasRotateError" - class="text-danger d-flex align-items-center font-weight-normal mb-2" + class="gl-text-red-500 gl-display-flex gl-align-items-center gl-font-weight-normal gl-mb-3" > - <gl-icon name="warning" class="mr-1" /> + <gl-icon name="warning" class="gl-mr-2" /> <span>{{ $options.translations.instanceIdRegenerateError }}</span> </div> <callout diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue index 26b18f9bf5a..9ec65bb0b43 100644 --- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -103,7 +103,7 @@ export default { > {{ $options.translations.newFlagAlert }} </gl-alert> - <gl-loading-icon v-if="isLoading" /> + <gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-7" /> <template v-else-if="!isLoading && !hasError"> <gl-alert v-if="deprecatedAndEditable" variant="warning" :dismissible="false" class="gl-my-5"> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index eb7046a3d9b..340cf68793f 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -278,7 +278,7 @@ export default { /> </feature-flags-tab> <template #tabs-end> - <div + <li class="gl-display-none gl-display-md-flex gl-align-items-center gl-flex-fill-1 gl-justify-content-end" > <gl-button @@ -313,7 +313,7 @@ export default { > {{ s__('FeatureFlags|New feature flag') }} </gl-button> - </div> + </li> </template> </gl-tabs> </div> diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue index 3c1944d91bd..36ebf893486 100644 --- a/app/assets/javascripts/feature_flags/components/form.vue +++ b/app/assets/javascripts/feature_flags/components/form.vue @@ -3,7 +3,7 @@ import Vue from 'vue'; import { memoize, isString, cloneDeep, isNumber, uniqueId } from 'lodash'; import { GlButton, - GlDeprecatedBadge as GlBadge, + GlBadge, GlTooltip, GlTooltipDirective, GlFormTextarea, @@ -11,10 +11,8 @@ import { GlSprintf, GlIcon, } from '@gitlab/ui'; -import Api from '~/api'; import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; import { s__ } from '~/locale'; -import { deprecatedCreateFlash as flash, FLASH_TYPES } from '~/flash'; import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import EnvironmentsDropdown from './environments_dropdown.vue'; @@ -89,7 +87,6 @@ export default { }, }, inject: { - projectId: {}, featureFlagIssuesEndpoint: { default: '', }, @@ -124,7 +121,6 @@ export default { formStrategies: cloneDeep(this.strategies), newScope: '', - userLists: [], }; }, computed: { @@ -155,17 +151,6 @@ export default { ); }, }, - mounted() { - if (this.supportsStrategies) { - Api.fetchFeatureFlagUserLists(this.projectId) - .then(({ data }) => { - this.userLists = data; - }) - .catch(() => { - flash(s__('FeatureFlags|There was an error retrieving user lists'), FLASH_TYPES.WARNING); - }); - } - }, methods: { keyFor(strategy) { if (strategy.id) { @@ -346,7 +331,6 @@ export default { :key="keyFor(strategy)" :strategy="strategy" :index="index" - :user-lists="userLists" @change="onFormStrategyChange($event, index)" @delete="deleteStrategy(strategy)" /> @@ -488,7 +472,9 @@ export default { :target="rolloutPercentageId(index)" > {{ - s__('FeatureFlags|Percent rollout must be a whole number between 0 and 100') + s__( + 'FeatureFlags|Percent rollout must be an integer number between 0 and 100', + ) }} </gl-tooltip> <span class="ml-1">%</span> diff --git a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue index 020a0d43096..4daf8b4e6bf 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/flexible_rollout.vue @@ -17,8 +17,8 @@ export default { }, }, i18n: { - percentageDescription: __('Enter a whole number between 0 and 100'), - percentageInvalid: __('Percent rollout must be a whole number between 0 and 100'), + percentageDescription: __('Enter an integer number number between 0 and 100'), + percentageInvalid: __('Percent rollout must be an integer number between 0 and 100'), percentageLabel: __('Percentage'), stickinessDescription: __('Consistency guarantee method'), stickinessLabel: __('Based on'), diff --git a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue index ec97e8b1350..6a57e9a8759 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/gitlab_user_list.vue @@ -1,11 +1,20 @@ <script> -import { GlFormSelect } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { createNamespacedHelpers } from 'vuex'; +import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { s__ } from '~/locale'; import ParameterFormGroup from './parameter_form_group.vue'; +const { mapActions, mapGetters, mapState } = createNamespacedHelpers('userLists'); + +const { fetchUserLists, setFilter } = mapActions(['fetchUserLists', 'setFilter']); + export default { components: { - GlFormSelect, + GlDropdown, + GlDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, ParameterFormGroup, }, props: { @@ -13,34 +22,40 @@ export default { required: true, type: Object, }, - userLists: { - required: false, - type: Array, - default: () => [], - }, }, translations: { - rolloutUserListLabel: s__('FeatureFlag|List'), + rolloutUserListLabel: s__('FeatureFlag|User List'), rolloutUserListDescription: s__('FeatureFlag|Select a user list'), rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'), + defaultDropdownText: s__('FeatureFlags|No user list selected'), }, computed: { - userListOptions() { - return this.userLists.map(({ name, id }) => ({ value: id, text: name })); - }, - hasUserLists() { - return this.userListOptions.length > 0; - }, + ...mapGetters(['hasUserLists', 'isLoading', 'hasError', 'userListOptions']), + ...mapState(['filter', 'userLists']), userListId() { - return this.strategy?.userListId ?? ''; + return this.strategy?.userList?.id ?? ''; }, + dropdownText() { + return this.strategy?.userList?.name ?? this.$options.translations.defaultDropdownText; + }, + }, + mounted() { + fetchUserLists.apply(this); }, methods: { + setFilter: debounce(setFilter, 250), + fetchUserLists: debounce(fetchUserLists, 250), onUserListChange(list) { this.$emit('change', { - userListId: list, + userList: list, }); }, + isSelectedUserList({ id }) { + return id === this.userListId; + }, + setFocus() { + this.$refs.searchBox.focusInput(); + }, }, }; </script> @@ -52,12 +67,26 @@ export default { :description="hasUserLists ? $options.translations.rolloutUserListDescription : ''" > <template #default="{ inputId }"> - <gl-form-select - :id="inputId" - :value="userListId" - :options="userListOptions" - @change="onUserListChange" - /> + <gl-dropdown :id="inputId" :text="dropdownText" @shown="setFocus"> + <gl-search-box-by-type + ref="searchBox" + class="gl-m-3" + :value="filter" + @input="setFilter" + @focus="fetchUserLists" + @keyup="fetchUserLists" + /> + <gl-loading-icon v-if="isLoading" /> + <gl-dropdown-item + v-for="list in userLists" + :key="list.id" + :is-checked="isSelectedUserList(list)" + is-check-item + @click="onUserListChange(list)" + > + {{ list.name }} + </gl-dropdown-item> + </gl-dropdown> </template> </parameter-form-group> </template> diff --git a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue index d262769c891..91e1b85d66e 100644 --- a/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue +++ b/app/assets/javascripts/feature_flags/components/strategies/percent_rollout.vue @@ -16,9 +16,9 @@ export default { }, }, i18n: { - rolloutPercentageDescription: __('Enter a whole number between 0 and 100'), + rolloutPercentageDescription: __('Enter an integer number between 0 and 100'), rolloutPercentageInvalid: s__( - 'FeatureFlags|Percent rollout must be a whole number between 0 and 100', + 'FeatureFlags|Percent rollout must be an integer number between 0 and 100', ), rolloutPercentageLabel: s__('FeatureFlag|Percentage'), }, diff --git a/app/assets/javascripts/feature_flags/edit.js b/app/assets/javascripts/feature_flags/edit.js index b4d2111acf3..05a9bbce654 100644 --- a/app/assets/javascripts/feature_flags/edit.js +++ b/app/assets/javascripts/feature_flags/edit.js @@ -22,7 +22,7 @@ export default () => { } = el.dataset; return new Vue({ - store: createStore({ endpoint, path: featureFlagsPath }), + store: createStore({ endpoint, projectId, path: featureFlagsPath }), el, provide: { environmentsScopeDocsPath, diff --git a/app/assets/javascripts/feature_flags/new.js b/app/assets/javascripts/feature_flags/new.js index a1efbd87ec4..8e18213cc03 100644 --- a/app/assets/javascripts/feature_flags/new.js +++ b/app/assets/javascripts/feature_flags/new.js @@ -22,7 +22,7 @@ export default () => { return new Vue({ el, - store: createStore({ endpoint, path: featureFlagsPath }), + store: createStore({ endpoint, projectId, path: featureFlagsPath }), provide: { environmentsScopeDocsPath, strategyTypeDocsPagePath, diff --git a/app/assets/javascripts/feature_flags/store/edit/index.js b/app/assets/javascripts/feature_flags/store/edit/index.js index f737e0517fc..81edc791924 100644 --- a/app/assets/javascripts/feature_flags/store/edit/index.js +++ b/app/assets/javascripts/feature_flags/store/edit/index.js @@ -1,4 +1,5 @@ import Vuex from 'vuex'; +import userLists from '../gitlab_user_list'; import state from './state'; import * as actions from './actions'; import mutations from './mutations'; @@ -8,4 +9,7 @@ export default data => actions, mutations, state: state(data), + modules: { + userLists: userLists(data), + }, }); diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/actions.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/actions.js new file mode 100644 index 00000000000..d4587713fed --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/actions.js @@ -0,0 +1,17 @@ +import Api from '~/api'; +import * as types from './mutation_types'; + +const getErrorMessages = error => [].concat(error?.response?.data?.message ?? error.message); + +export const fetchUserLists = ({ commit, state: { filter, projectId } }) => { + commit(types.FETCH_USER_LISTS); + + return Api.searchFeatureFlagUserLists(projectId, filter) + .then(({ data }) => commit(types.RECEIVE_USER_LISTS_SUCCESS, data)) + .catch(error => commit(types.RECEIVE_USER_LISTS_ERROR, getErrorMessages(error))); +}; + +export const setFilter = ({ commit, dispatch }, filter) => { + commit(types.SET_FILTER, filter); + return dispatch('fetchUserLists'); +}; diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/getters.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/getters.js new file mode 100644 index 00000000000..164b0980120 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/getters.js @@ -0,0 +1,11 @@ +import statuses from './status'; + +export const userListOptions = ({ userLists }) => + userLists.map(({ name, id }) => ({ value: id, text: name })); + +export const hasUserLists = ({ userLists, status }) => + [statuses.START, statuses.LOADING].indexOf(status) > -1 || userLists.length > 0; + +export const isLoading = ({ status }) => status === statuses.LOADING; + +export const hasError = ({ status }) => status === statuses.ERROR; diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/index.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/index.js new file mode 100644 index 00000000000..d25b574981f --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/index.js @@ -0,0 +1,12 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; +import * as getters from './getters'; + +export default data => ({ + state: state(data), + actions, + getters, + mutations, + namespaced: true, +}); diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutation_types.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutation_types.js new file mode 100644 index 00000000000..0fe12f06785 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutation_types.js @@ -0,0 +1,5 @@ +export const FETCH_USER_LISTS = 'FETCH_USER_LISTS'; +export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS'; +export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR'; + +export const SET_FILTER = 'SET_FILTER'; diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutations.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutations.js new file mode 100644 index 00000000000..bd7c6f68009 --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/mutations.js @@ -0,0 +1,19 @@ +import statuses from './status'; +import * as types from './mutation_types'; + +export default { + [types.FETCH_USER_LISTS](state) { + state.status = statuses.LOADING; + }, + [types.RECEIVE_USER_LISTS_SUCCESS](state, lists) { + state.userLists = lists; + state.status = statuses.IDLE; + }, + [types.RECEIVE_USER_LISTS_ERROR](state, error) { + state.error = error; + state.status = statuses.ERROR; + }, + [types.SET_FILTER](state, filter) { + state.filter = filter; + }, +}; diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/state.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/state.js new file mode 100644 index 00000000000..2664ec794fc --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/state.js @@ -0,0 +1,9 @@ +import statuses from './status'; + +export default ({ projectId }) => ({ + projectId, + userLists: [], + filter: '', + status: statuses.START, + error: '', +}); diff --git a/app/assets/javascripts/feature_flags/store/gitlab_user_list/status.js b/app/assets/javascripts/feature_flags/store/gitlab_user_list/status.js new file mode 100644 index 00000000000..67f153eb58e --- /dev/null +++ b/app/assets/javascripts/feature_flags/store/gitlab_user_list/status.js @@ -0,0 +1,6 @@ +export default { + START: 'START', + LOADING: 'LOADING', + IDLE: 'IDLE', + ERROR: 'ERROR', +}; diff --git a/app/assets/javascripts/feature_flags/store/helpers.js b/app/assets/javascripts/feature_flags/store/helpers.js index db6da815abf..d42e5c504db 100644 --- a/app/assets/javascripts/feature_flags/store/helpers.js +++ b/app/assets/javascripts/feature_flags/store/helpers.js @@ -174,7 +174,7 @@ export const mapStrategiesToViewModel = strategiesFromRails => id: s.id, name: s.name, parameters: mapStrategiesParametersToViewModel(s.parameters), - userListId: s.user_list?.id, + userList: s.user_list, // eslint-disable-next-line no-underscore-dangle shouldBeDestroyed: Boolean(s._destroy), scopes: mapStrategyScopesToView(s.scopes), @@ -197,7 +197,7 @@ const mapStrategyToRails = strategy => { }; if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) { - mappedStrategy.user_list_id = strategy.userListId; + mappedStrategy.user_list_id = strategy.userList.id; } return mappedStrategy; }; diff --git a/app/assets/javascripts/feature_flags/store/new/index.js b/app/assets/javascripts/feature_flags/store/new/index.js index f737e0517fc..81edc791924 100644 --- a/app/assets/javascripts/feature_flags/store/new/index.js +++ b/app/assets/javascripts/feature_flags/store/new/index.js @@ -1,4 +1,5 @@ import Vuex from 'vuex'; +import userLists from '../gitlab_user_list'; import state from './state'; import * as actions from './actions'; import mutations from './mutations'; @@ -8,4 +9,7 @@ export default data => actions, mutations, state: state(data), + modules: { + userLists: userLists(data), + }, }); diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index 51077296e20..7d4df25816b 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -12,6 +12,7 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { tag: __('Yes or No'), lowercaseValueOnSubmit: true, capitalizeTokenValue: true, + hideNotEqual: true, }, conditions: [ { @@ -30,20 +31,6 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { value: __('No'), operator: '=', }, - { - url: 'not[wip]=yes', - replacementUrl: 'not[draft]=yes', - tokenKey: 'draft', - value: __('Yes'), - operator: '!=', - }, - { - url: 'not[wip]=no', - replacementUrl: 'not[draft]=no', - tokenKey: 'draft', - value: __('No'), - operator: '!=', - }, ], }; @@ -109,43 +96,41 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]); IssuableTokenKeys.conditions.push(...approvedBy.condition); - if (gon?.features?.deploymentFilters) { - const environmentToken = { - formattedKey: __('Environment'), - key: 'environment', - type: 'string', - param: '', - symbol: '', - icon: 'cloud-gear', - tag: 'environment', - }; + const environmentToken = { + formattedKey: __('Environment'), + key: 'environment', + type: 'string', + param: '', + symbol: '', + icon: 'cloud-gear', + tag: 'environment', + }; - const deployedBeforeToken = { - formattedKey: __('Deployed-before'), - key: 'deployed-before', - type: 'string', - param: '', - symbol: '', - icon: 'clock', - tag: 'deployed_before', - }; + const deployedBeforeToken = { + formattedKey: __('Deployed-before'), + key: 'deployed-before', + type: 'string', + param: '', + symbol: '', + icon: 'clock', + tag: 'deployed_before', + }; - const deployedAfterToken = { - formattedKey: __('Deployed-after'), - key: 'deployed-after', - type: 'string', - param: '', - symbol: '', - icon: 'clock', - tag: 'deployed_after', - }; + const deployedAfterToken = { + formattedKey: __('Deployed-after'), + key: 'deployed-after', + type: 'string', + param: '', + symbol: '', + icon: 'clock', + tag: 'deployed_after', + }; - IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken); + IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken); - IssuableTokenKeys.tokenKeysWithAlternative.push( - environmentToken, - deployedBeforeToken, - deployedAfterToken, - ); - } + IssuableTokenKeys.tokenKeysWithAlternative.push( + environmentToken, + deployedBeforeToken, + deployedAfterToken, + ); }; diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index 4652dfe71c3..30f412e590f 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -22,7 +22,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown { ajaxFilterConfig() { return { - endpoint: `${gon.relative_url_root || ''}${this.endpoint}`, + endpoint: this.endpoint, searchKey: 'search', searchValueFunction: this.getSearchInput.bind(this), loadingTemplate: this.loadingTemplate, @@ -33,9 +33,11 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown { } itemClicked(e) { - super.itemClicked(e, selected => - selected.querySelector('.dropdown-light-content').innerText.trim(), - ); + super.itemClicked(e, selected => { + const title = selected.querySelector('.dropdown-light-content').innerText.trim(); + + return DropdownUtils.getEscapedText(title); + }); } renderContent(forceShowList = false) { diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js index 1bbd33b6258..8fee3385de1 100644 --- a/app/assets/javascripts/filtered_search/dropdown_operator.js +++ b/app/assets/javascripts/filtered_search/dropdown_operator.js @@ -39,7 +39,7 @@ export default class DropdownOperator extends FilteredSearchDropdown { this.dispatchInputEvent(); } - renderContent(forceShowList = false) { + renderContent(forceShowList = false, dropdownName = '') { const dropdownData = [ { tag: 'equal', @@ -48,8 +48,9 @@ export default class DropdownOperator extends FilteredSearchDropdown { help: __('is'), }, ]; + const dropdownToken = this.tokenKeys.searchByKey(dropdownName.toLowerCase()); - if (gon.features?.notIssuableQueries) { + if (gon.features?.notIssuableQueries && !dropdownToken?.hideNotEqual) { dropdownData.push({ tag: 'not-equal', type: 'string', diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index 0fb1828fc98..9a23ff25eac 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -5,7 +5,7 @@ export default class DropdownUser extends DropdownAjaxFilter { constructor(options = {}) { super({ ...options, - endpoint: '/-/autocomplete/users.json', + endpoint: `${gon.relative_url_root || ''}/-/autocomplete/users.json`, symbol: '@', }); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js index f7ce2ea01e0..8626e1a3d18 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -83,16 +83,16 @@ export default class FilteredSearchDropdown { } } - render(forceRenderContent = false, forceShowList = false) { + render(forceRenderContent = false, forceShowList = false, hideNotEqual = false) { this.setAsDropdown(); const currentHook = this.getCurrentHook(); const firstTimeInitialized = currentHook === null; if (firstTimeInitialized || forceRenderContent) { - this.renderContent(forceShowList); + this.renderContent(forceShowList, hideNotEqual); } else if (currentHook.list.list.id !== this.dropdown.id) { - this.renderContent(forceShowList); + this.renderContent(forceShowList, hideNotEqual); } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 762383f5a1d..d446e32394b 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -12,6 +12,7 @@ export default class FilteredSearchDropdownManager { runnerTagsEndpoint = '', labelsEndpoint = '', milestonesEndpoint = '', + iterationsEndpoint = '', releasesEndpoint = '', environmentsEndpoint = '', epicsEndpoint = '', @@ -28,6 +29,7 @@ export default class FilteredSearchDropdownManager { this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint); this.labelsEndpoint = removeTrailingSlash(labelsEndpoint); this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint); + this.iterationsEndpoint = removeTrailingSlash(iterationsEndpoint); this.releasesEndpoint = removeTrailingSlash(releasesEndpoint); this.epicsEndpoint = removeTrailingSlash(epicsEndpoint); this.environmentsEndpoint = removeTrailingSlash(environmentsEndpoint); @@ -107,7 +109,7 @@ export default class FilteredSearchDropdownManager { this.mapping[key].reference.setOffset(offset); } - load(key, firstLoad = false) { + load(key, firstLoad = false, dropdownKey = '') { const mappingKey = this.mapping[key]; const glClass = mappingKey.gl; const { element } = mappingKey; @@ -141,12 +143,12 @@ export default class FilteredSearchDropdownManager { } this.updateDropdownOffset(key); - mappingKey.reference.render(firstLoad, forceShowList); + mappingKey.reference.render(firstLoad, forceShowList, dropdownKey); this.currentDropdown = key; } - loadDropdown(dropdownName = '') { + loadDropdown(dropdownName = '', dropdownKey = '') { let firstLoad = false; if (!this.droplab) { @@ -155,7 +157,7 @@ export default class FilteredSearchDropdownManager { } if (dropdownName === DROPDOWN_TYPE.operator) { - this.load(dropdownName, firstLoad); + this.load(dropdownName, firstLoad, dropdownKey); return; } @@ -167,7 +169,7 @@ export default class FilteredSearchDropdownManager { if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { const key = match && match.key ? match.key : DROPDOWN_TYPE.hint; - this.load(key, firstLoad); + this.load(key, firstLoad, dropdownKey); } } @@ -200,11 +202,11 @@ export default class FilteredSearchDropdownManager { dropdownToOpen = hasOperator && lastOperatorToken ? dropdownName : DROPDOWN_TYPE.operator; } - this.loadDropdown(dropdownToOpen); + this.loadDropdown(dropdownToOpen, dropdownName); } else if (lastToken) { const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator(); // Token has been initialized into an object because it has a value - this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator); + this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator, lastToken.key); } else { this.loadDropdown(DROPDOWN_TYPE.hint); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 261532f8867..921d686bb28 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -52,16 +52,24 @@ export default class FilteredSearchManager { this.placeholder = placeholder; this.anchor = anchor; - const { multipleAssignees } = this.filteredSearchInput.dataset; + const { + multipleAssignees, + epicsEndpoint, + iterationsEndpoint, + } = this.filteredSearchInput.dataset; + if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) { this.filteredSearchTokenKeys.enableMultipleAssignees(); } - const { epicsEndpoint } = this.filteredSearchInput.dataset; if (!epicsEndpoint && this.filteredSearchTokenKeys.removeEpicToken) { this.filteredSearchTokenKeys.removeEpicToken(); } + if (!iterationsEndpoint && this.filteredSearchTokenKeys.removeIterationToken) { + this.filteredSearchTokenKeys.removeIterationToken(); + } + this.recentSearchesStore = new RecentSearchesStore({ isLocalStorageAvailable: RecentSearchesService.isAvailable(), allowedKeys: this.filteredSearchTokenKeys.getKeys(), @@ -112,6 +120,7 @@ export default class FilteredSearchManager { releasesEndpoint = '', environmentsEndpoint = '', epicsEndpoint = '', + iterationsEndpoint = '', } = this.filteredSearchInput.dataset; this.dropdownManager = new FilteredSearchDropdownManager({ @@ -121,6 +130,7 @@ export default class FilteredSearchManager { releasesEndpoint, environmentsEndpoint, epicsEndpoint, + iterationsEndpoint, tokenizer: this.tokenizer, page: this.page, isGroup: this.isGroup, diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js index 5b03e1d19db..1998bf4358a 100644 --- a/app/assets/javascripts/frequent_items/index.js +++ b/app/assets/javascripts/frequent_items/index.js @@ -16,63 +16,53 @@ const frequentItemDropdowns = [ }, ]; -const initFrequentItemList = (namespace, key) => { - const el = document.getElementById(`js-${namespace}-dropdown`); - - // Don't do anything if element doesn't exist (No groups dropdown) - // This is for when the user accesses GitLab without logging in - if (!el) { - return; - } - - import('./components/app.vue') - .then(({ default: FrequentItems }) => { - // eslint-disable-next-line no-new - new Vue({ - el, - data() { - const { dataset } = this.$options.el; - const item = { - id: Number(dataset[`${key}Id`]), - name: dataset[`${key}Name`], - namespace: dataset[`${key}Namespace`], - webUrl: dataset[`${key}WebUrl`], - avatarUrl: dataset[`${key}AvatarUrl`] || null, - lastAccessedOn: Date.now(), - }; - - return { - currentUserName: dataset.userName, - currentItem: item, - }; - }, - render(createElement) { - return createElement(FrequentItems, { - props: { - namespace, - currentUserName: this.currentUserName, - currentItem: this.currentItem, - }, - }); - }, - }); - }) - .catch(() => {}); -}; - export default function initFrequentItemDropdowns() { frequentItemDropdowns.forEach(dropdown => { const { namespace, key } = dropdown; + const el = document.getElementById(`js-${namespace}-dropdown`); const navEl = document.getElementById(`nav-${namespace}-dropdown`); // Don't do anything if element doesn't exist (No groups dropdown) // This is for when the user accesses GitLab without logging in - if (!navEl) { + if (!el || !navEl) { return; } + import('./components/app.vue') + .then(({ default: FrequentItems }) => { + // eslint-disable-next-line no-new + new Vue({ + el, + data() { + const { dataset } = this.$options.el; + const item = { + id: Number(dataset[`${key}Id`]), + name: dataset[`${key}Name`], + namespace: dataset[`${key}Namespace`], + webUrl: dataset[`${key}WebUrl`], + avatarUrl: dataset[`${key}AvatarUrl`] || null, + lastAccessedOn: Date.now(), + }; + + return { + currentUserName: dataset.userName, + currentItem: item, + }; + }, + render(createElement) { + return createElement(FrequentItems, { + props: { + namespace, + currentUserName: this.currentUserName, + currentItem: this.currentItem, + }, + }); + }, + }); + }) + .catch(() => {}); + $(navEl).on('shown.bs.dropdown', () => { - initFrequentItemList(namespace, key); eventHub.$emit(`${namespace}-dropdownOpen`); }); }); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 62948f74aaa..202f04f98f6 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,9 +1,12 @@ import $ from 'jquery'; import '~/lib/utils/jquery_at_who'; import { escape, template } from 'lodash'; +import { s__ } from '~/locale'; import SidebarMediator from '~/sidebar/sidebar_mediator'; +import { isUserBusy } from '~/set_status_modal/utils'; import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; +import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from './lib/utils/common_utils'; import * as Emoji from '~/emoji'; @@ -39,6 +42,7 @@ export function membersBeforeSave(members) { title: sanitize(title), search: sanitize(`${member.username} ${member.name}`), icon: avatarIcon, + availability: member.availability, }; }); } @@ -52,6 +56,7 @@ export const defaultAutocompleteConfig = { milestones: true, labels: true, snippets: true, + vulnerabilities: true, }; class GfmAutoComplete { @@ -59,6 +64,7 @@ class GfmAutoComplete { this.dataSources = dataSources; this.cachedData = {}; this.isLoadingData = {}; + this.previousQuery = ''; } setup(input, enableMap = defaultAutocompleteConfig) { @@ -253,13 +259,17 @@ class GfmAutoComplete { alias: 'users', displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; - const { avatarTag, username, title, icon } = value; + const { avatarTag, username, title, icon, availability } = value; if (username != null) { tmpl = GfmAutoComplete.Members.templateFunction({ avatarTag, username, title, icon, + availabilityStatus: + availability && isUserBusy(availability) + ? `<span class="gl-text-gray-500"> ${s__('UserAvailability|(Busy)')}</span>` + : '', }); } return tmpl; @@ -554,7 +564,7 @@ class GfmAutoComplete { } getDefaultCallbacks() { - const fetchData = this.fetchData.bind(this); + const self = this; return { sorter(query, items, searchKey) { @@ -567,7 +577,14 @@ class GfmAutoComplete { }, filter(query, data, searchKey) { if (GfmAutoComplete.isLoading(data)) { - fetchData(this.$inputor, this.at); + self.fetchData(this.$inputor, this.at); + return data; + } else if ( + GfmAutoComplete.isTypeWithBackendFiltering(this.at) && + self.previousQuery !== query + ) { + self.fetchData(this.$inputor, this.at, query); + self.previousQuery = query; return data; } return $.fn.atwho.default.callbacks.filter(query, data, searchKey); @@ -615,13 +632,22 @@ class GfmAutoComplete { }; } - fetchData($input, at) { + fetchData($input, at, search) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]]; - if (this.cachedData[at]) { + if (GfmAutoComplete.isTypeWithBackendFiltering(at)) { + axios + .get(dataSource, { params: { search } }) + .then(({ data }) => { + this.loadData($input, at, data); + }) + .catch(() => { + this.isLoadingData[at] = false; + }); + } else if (this.cachedData[at]) { this.loadData($input, at, this.cachedData[at]); } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { this.loadEmojiData($input, at).catch(() => {}); @@ -707,7 +733,9 @@ class GfmAutoComplete { // https://github.com/ichord/At.js const atSymbolsWithBar = Object.keys(controllers) .join('|') - .replace(/[$]/, '\\$&'); + .replace(/[$]/, '\\$&') + .replace(/([[\]:])/g, '\\$1'); + const atSymbolsWithoutBar = Object.keys(controllers).join(''); const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop(); const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); @@ -738,9 +766,14 @@ GfmAutoComplete.atTypeMap = { '~': 'labels', '%': 'milestones', '/': 'commands', + '[vulnerability:': 'vulnerabilities', $: 'snippets', }; +GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities']; +GfmAutoComplete.isTypeWithBackendFiltering = type => + GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[type]); + function findEmoji(name) { return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => { if (a.index !== b.index) { @@ -775,8 +808,10 @@ GfmAutoComplete.Emoji = { }; // Team Members GfmAutoComplete.Members = { - templateFunction({ avatarTag, username, title, icon }) { - return `<li>${avatarTag} ${username} <small>${escape(title)}</small> ${icon}</li>`; + templateFunction({ avatarTag, username, title, icon, availabilityStatus }) { + return `<li>${avatarTag} ${username} <small>${escape( + title, + )}${availabilityStatus}</small> ${icon}</li>`; }, }; GfmAutoComplete.Labels = { diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index a262fbd9ac3..5487aeb9391 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -7,6 +7,10 @@ * @returns {Number} */ export const getIdFromGraphQLId = (gid = '') => - parseInt((gid || '').replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null; + parseInt(`${gid}`.replace(/gid:\/\/gitlab\/.*\//g, ''), 10) || null; -export default {}; +export const MutationOperationMode = { + Append: 'APPEND', + Remove: 'REMOVE', + Replace: 'REPLACE', +}; diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 871f5c9a845..e057012a246 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -3,9 +3,8 @@ import $ from 'jquery'; import 'vendor/jquery.scrollTo'; -import { GlLoadingIcon } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; -import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; +import { GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; import { HIDDEN_CLASS } from '~/lib/utils/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; @@ -16,8 +15,8 @@ import groupsComponent from './groups.vue'; export default { components: { - DeprecatedModal, groupsComponent, + GlModal, GlLoadingIcon, }, props: { @@ -49,13 +48,30 @@ export default { isLoading: true, isSearchEmpty: false, searchEmptyMessage: '', - showModal: false, - groupLeaveConfirmationMessage: '', targetGroup: null, targetParentGroup: null, }; }, computed: { + primaryProps() { + return { + text: __('Leave group'), + attributes: [{ variant: 'warning' }, { category: 'primary' }], + }; + }, + cancelProps() { + return { + text: __('Cancel'), + }; + }, + groupLeaveConfirmationMessage() { + if (!this.targetGroup) { + return ''; + } + return sprintf(s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'), { + fullName: this.targetGroup.fullName, + }); + }, groups() { return this.store.getGroups(); }, @@ -171,27 +187,17 @@ export default { } }, showLeaveGroupModal(group, parentGroup) { - const { fullName } = group; this.targetGroup = group; this.targetParentGroup = parentGroup; - this.showModal = true; - this.groupLeaveConfirmationMessage = sprintf( - s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'), - { fullName }, - ); - }, - hideLeaveGroupModal() { - this.showModal = false; }, leaveGroup() { - this.showModal = false; this.targetGroup.isBeingRemoved = true; this.service .leaveGroup(this.targetGroup.leavePath) .then(res => { $.scrollTo(0); this.store.removeGroup(this.targetGroup, this.targetParentGroup); - Flash(res.data.notice, 'notice'); + this.$toast.show(res.data.notice); }) .catch(err => { let message = COMMON_STR.FAILURE; @@ -245,21 +251,21 @@ export default { class="loading-animation prepend-top-20" /> <groups-component - v-if="!isLoading" + v-else :groups="groups" :search-empty="isSearchEmpty" :search-empty-message="searchEmptyMessage" :page-info="pageInfo" :action="action" /> - <deprecated-modal - v-show="showModal" - :primary-button-label="__('Leave')" + <gl-modal + modal-id="leave-group-modal" :title="__('Are you sure?')" - :text="groupLeaveConfirmationMessage" - kind="warning" - @cancel="hideLeaveGroupModal" - @submit="leaveGroup" - /> + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="leaveGroup" + > + {{ groupLeaveConfirmationMessage }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index 44349b33386..6e99b6ad4fa 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -1,8 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlLoadingIcon, GlBadge } from '@gitlab/ui'; +import { GlLoadingIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui'; import { visitUrl } from '../../lib/utils/url_utility'; -import tooltip from '../../vue_shared/directives/tooltip'; import identicon from '../../vue_shared/components/identicon.vue'; import eventHub from '../event_hub'; import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants'; @@ -17,7 +16,7 @@ import { showLearnGitLabGroupItemPopover } from '~/onboarding_issues'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlBadge, @@ -127,11 +126,10 @@ export default { <div class="group-text flex-grow-1 flex-shrink-1"> <div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3"> <a - v-tooltip + v-gl-tooltip.bottom :href="group.relativePath" :title="group.fullName" class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!" - data-placement="bottom" >{{ // ending bracket must be by closing tag to prevent // link hover text-decoration from over-extending diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index 2e92a608f76..ff52f5ef51c 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -1,15 +1,15 @@ <script> -import { GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui'; import eventHub from '../event_hub'; import { COMMON_STR } from '../constants'; export default { components: { - GlIcon, + GlButton, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, }, props: { parentGroup: { @@ -45,32 +45,28 @@ export default { <template> <div class="controls d-flex justify-content-end"> - <a + <gl-button v-if="group.canLeave" - v-tooltip - :href="group.leavePath" + v-gl-tooltip.top + v-gl-modal.leave-group-modal :title="leaveBtnTitle" :aria-label="leaveBtnTitle" - data-container="body" - data-placement="bottom" data-testid="leave-group-btn" - class="leave-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5" - @click.prevent="onLeaveGroup" - > - <gl-icon name="leave" class="position-top-0" /> - </a> - <a + size="small" + icon="leave" + class="leave-group gl-ml-3" + @click.stop="onLeaveGroup" + /> + <gl-button v-if="group.canEdit" - v-tooltip + v-gl-tooltip.top :href="group.editPath" :title="editBtnTitle" :aria-label="editBtnTitle" - data-container="body" - data-placement="bottom" data-testid="edit-group-btn" - class="edit-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5" - > - <gl-icon name="settings" class="position-top-0 align-middle" /> - </a> + size="small" + icon="pencil" + class="edit-group gl-ml-3" + /> </div> </template> diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index c538934a37d..e2722d780dc 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -31,14 +31,16 @@ export const GROUP_VISIBILITY_TYPE = { 'Public - The group and any public projects can be viewed without any authentication.', ), internal: __( - 'Internal - The group and any internal projects can be viewed by any logged in user.', + 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', ), private: __('Private - The group and its projects can only be viewed by members.'), }; export const PROJECT_VISIBILITY_TYPE = { public: __('Public - The project can be accessed without any authentication.'), - internal: __('Internal - The project can be accessed by any logged in user.'), + internal: __( + 'Internal - The project can be accessed by any logged in user except external users.', + ), private: __( 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', ), diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index 928f1fe409f..522f1d16df2 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; +import UserCallout from '~/user_callout'; import { parseBoolean } from '~/lib/utils/common_utils'; import Translate from '../vue_shared/translate'; import GroupFilterableList from './groups_filterable_list'; @@ -16,6 +18,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { const containerEl = document.getElementById(containerId); let dataEl; + // eslint-disable-next-line no-new + new UserCallout(); + // Don't do anything if element doesn't exist (No groups) // This is for when the user enters directly to the page via URL if (!containerEl) { @@ -31,6 +36,8 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { Vue.component('group-folder', groupFolderComponent); Vue.component('group-item', groupItemComponent); + Vue.use(GlToast); + // eslint-disable-next-line no-new new Vue({ el, diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js index 3bbef14d199..cb28fb057c9 100644 --- a/app/assets/javascripts/groups/members/index.js +++ b/app/assets/javascripts/groups/members/index.js @@ -5,7 +5,7 @@ import { parseDataAttributes } from 'ee_else_ce/groups/members/utils'; import App from './components/app.vue'; import membersModule from '~/vuex_shared/modules/members'; -export const initGroupMembersApp = (el, tableFields, requestFormatter) => { +export const initGroupMembersApp = (el, tableFields, tableAttrs, requestFormatter) => { if (!el) { return () => {}; } @@ -18,6 +18,7 @@ export const initGroupMembersApp = (el, tableFields, requestFormatter) => { ...parseDataAttributes(el), currentUserId: gon.current_user_id || null, tableFields, + tableAttrs, requestFormatter, }), }); diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js deleted file mode 100644 index bb2aea3ea76..00000000000 --- a/app/assets/javascripts/groups/new_group_child.js +++ /dev/null @@ -1,65 +0,0 @@ -import { visitUrl } from '../lib/utils/url_utility'; -import DropLab from '../droplab/drop_lab'; -import ISetter from '../droplab/plugins/input_setter'; - -const InputSetter = { ...ISetter }; - -const NEW_PROJECT = 'new-project'; -const NEW_SUBGROUP = 'new-subgroup'; - -export default class NewGroupChild { - constructor(buttonWrapper) { - this.buttonWrapper = buttonWrapper; - this.newGroupChildButton = this.buttonWrapper.querySelector('.js-new-group-child'); - this.dropdownToggle = this.buttonWrapper.querySelector('.js-dropdown-toggle'); - this.dropdownList = this.buttonWrapper.querySelector('.dropdown-menu'); - - this.newGroupPath = this.buttonWrapper.dataset.projectPath; - this.subgroupPath = this.buttonWrapper.dataset.subgroupPath; - - this.init(); - } - - init() { - this.initDroplab(); - this.bindEvents(); - } - - initDroplab() { - this.droplab = new DropLab(); - this.droplab.init( - this.dropdownToggle, - this.dropdownList, - [InputSetter], - this.getDroplabConfig(), - ); - } - - getDroplabConfig() { - return { - InputSetter: [ - { - input: this.newGroupChildButton, - valueAttribute: 'data-value', - inputAttribute: 'data-action', - }, - { - input: this.newGroupChildButton, - valueAttribute: 'data-text', - }, - ], - }; - } - - bindEvents() { - this.newGroupChildButton.addEventListener('click', this.onClickNewGroupChildButton.bind(this)); - } - - onClickNewGroupChildButton(e) { - if (e.target.dataset.action === NEW_PROJECT) { - visitUrl(this.newGroupPath); - } else if (e.target.dataset.action === NEW_SUBGROUP) { - visitUrl(this.subgroupPath); - } - } -} diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index b833cca1db6..1cedb557d46 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -1,5 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import Translate from '~/vue_shared/translate'; import { highCountTrim } from '~/lib/utils/text_utility'; import Tracking from '~/tracking'; @@ -34,26 +35,45 @@ function initStatusTriggers() { const statusModalElement = document.createElement('div'); setStatusModalWrapperEl.appendChild(statusModalElement); + Vue.use(GlToast); Vue.use(Translate); // eslint-disable-next-line no-new new Vue({ el: statusModalElement, data() { - const { currentEmoji, currentMessage } = setStatusModalWrapperEl.dataset; + const { + currentEmoji, + defaultEmoji, + currentMessage, + currentAvailability, + canSetUserAvailability, + } = setStatusModalWrapperEl.dataset; return { currentEmoji, + defaultEmoji, currentMessage, + currentAvailability, + canSetUserAvailability, }; }, render(createElement) { - const { currentEmoji, currentMessage } = this; + const { + currentEmoji, + defaultEmoji, + currentMessage, + currentAvailability, + canSetUserAvailability, + } = this; return createElement(SetStatusModalWrapper, { props: { currentEmoji, + defaultEmoji, currentMessage, + currentAvailability, + canSetUserAvailability, }, }); }, diff --git a/app/assets/javascripts/helpers/startup_css_helper.js b/app/assets/javascripts/helpers/startup_css_helper.js index 8e25e1421c0..d41a6209898 100644 --- a/app/assets/javascripts/helpers/startup_css_helper.js +++ b/app/assets/javascripts/helpers/startup_css_helper.js @@ -20,19 +20,9 @@ const handleStartupEvents = () => { } }; -/* Wait for.... The methods can be used: - - with a callback (preferred), - waitFor(action) - - - with then (discouraged), - await waitFor().then(action); - - - with await, - await waitFor; - action(); --*/ +/* For `waitForCSSLoaded` methods, see docs.gitlab.com/ee/development/fe_guide/performance.html#important-considerations */ export const waitForCSSLoaded = (action = () => {}) => { - if (!gon.features.startupCss || allLinksLoaded()) { + if (!gon?.features?.startupCss || allLinksLoaded()) { return new Promise(resolve => { action(); resolve(); diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index f36fe87ccfa..9d2deb1d4d0 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -1,8 +1,7 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlModal, GlSafeHtmlDirective } from '@gitlab/ui'; +import { GlModal, GlSafeHtmlDirective, GlButton } from '@gitlab/ui'; import { n__, __ } from '~/locale'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import CommitMessageField from './message_field.vue'; import Actions from './actions.vue'; import SuccessMessage from './success_message.vue'; @@ -12,10 +11,10 @@ import { createUnexpectedCommitError } from '../../lib/errors'; export default { components: { Actions, - LoadingButton, CommitMessageField, SuccessMessage, GlModal, + GlButton, }, directives: { SafeHtml: GlSafeHtmlDirective, @@ -156,12 +155,16 @@ export default { /> <div class="clearfix gl-mt-5"> <actions /> - <loading-button + <gl-button :loading="submitCommitLoading" - :label="commitButtonText" - container-class="btn btn-success btn-sm float-left qa-commit-button" + class="float-left qa-commit-button" + size="small" + category="primary" + variant="success" @click="commit" - /> + > + {{ __('Commit') }} + </gl-button> <button v-if="!discardDraftButtonDisabled" type="button" @@ -170,14 +173,17 @@ export default { > {{ __('Discard draft') }} </button> - <button + <gl-button v-else type="button" - class="btn btn-default btn-sm float-right" + class="float-right" + category="secondary" + variant="default" + size="small" @click="toggleIsCompact" > {{ __('Collapse') }} - </button> + </gl-button> </div> <gl-modal ref="commitErrorModal" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 609ce287d3f..729ff7c74ec 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,8 +1,7 @@ <script> import { mapActions } from 'vuex'; -import { GlModal, GlIcon } from '@gitlab/ui'; +import { GlModal, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import ListItem from './list_item.vue'; export default { @@ -12,17 +11,13 @@ export default { GlModal, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { fileList: { type: Array, required: true, }, - iconName: { - type: String, - required: true, - }, stagedList: { type: Boolean, required: false, @@ -73,12 +68,11 @@ export default { <div class="ide-commit-list-container"> <header class="multi-file-commit-panel-header d-flex mb-0"> <div class="d-flex align-items-center flex-fill"> - <gl-icon v-once :name="iconName" :size="18" class="gl-mr-3" /> <strong> {{ titleText }} </strong> <div class="d-flex ml-auto"> <button v-if="!stagedList" - v-tooltip + v-gl-tooltip :title="__('Discard all changes')" :aria-label="__('Discard all changes')" :disabled="!filesLength" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue deleted file mode 100644 index 4821b8389ff..00000000000 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue +++ /dev/null @@ -1,103 +0,0 @@ -<script> -import { GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; -import { sprintf, n__, __ } from '~/locale'; - -export default { - components: { - GlIcon, - }, - directives: { - tooltip, - }, - props: { - files: { - type: Array, - required: true, - }, - iconName: { - type: String, - required: true, - }, - title: { - type: String, - required: true, - }, - }, - computed: { - addedFilesLength() { - return this.files.filter(f => f.tempFile).length; - }, - modifiedFilesLength() { - return this.files.filter(f => !f.tempFile).length; - }, - addedFilesIconClass() { - return this.addedFilesLength ? 'multi-file-addition' : ''; - }, - modifiedFilesClass() { - return this.modifiedFilesLength ? 'multi-file-modified' : ''; - }, - additionsTooltip() { - return sprintf( - n__('1 %{type} addition', '%{count} %{type} additions', this.addedFilesLength), - { - type: this.title.toLowerCase(), - count: this.addedFilesLength, - }, - ); - }, - modifiedTooltip() { - return sprintf( - n__('1 %{type} modification', '%{count} %{type} modifications', this.modifiedFilesLength), - { - type: this.title.toLowerCase(), - count: this.modifiedFilesLength, - }, - ); - }, - titleTooltip() { - return sprintf(__('%{title} changes'), { title: this.title }); - }, - additionIconName() { - return this.title.toLowerCase() === 'staged' ? 'file-addition-solid' : 'file-addition'; - }, - modifiedIconName() { - return this.title.toLowerCase() === 'staged' ? 'file-modified-solid' : 'file-modified'; - }, - }, -}; -</script> - -<template> - <div class="multi-file-commit-list-collapsed text-center"> - <div - v-tooltip - :title="titleTooltip" - data-container="body" - data-placement="left" - class="gl-mb-5" - > - <gl-icon v-once :name="iconName" :size="18" /> - </div> - <div - v-tooltip - :title="additionsTooltip" - data-container="body" - data-placement="left" - class="gl-mb-3" - > - <gl-icon :name="additionIconName" :size="18" :class="addedFilesIconClass" /> - </div> - {{ addedFilesLength }} - <div - v-tooltip - :title="modifiedTooltip" - data-container="body" - data-placement="left" - class="gl-mt-3 gl-mb-3" - > - <gl-icon :name="modifiedIconName" :size="18" :class="modifiedFilesClass" /> - </div> - {{ modifiedFilesLength }} - </div> -</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index a0d6cf3c42d..123e0aba959 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -1,7 +1,6 @@ <script> import { mapActions } from 'vuex'; -import { GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { viewerTypes } from '../../constants'; import getCommitIconMap from '../../commit_icon'; @@ -12,7 +11,7 @@ export default { FileIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { file: { @@ -77,7 +76,7 @@ export default { <template> <div class="multi-file-commit-list-item position-relative"> <div - v-tooltip + v-gl-tooltip :title="tooltipTitle" :class="{ 'is-active': isActive, diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 48ab58e1cb7..fb0d00dc6a1 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -1,8 +1,7 @@ <script> import { mapGetters } from 'vuex'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { n__ } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; import NewDropdown from './new_dropdown/index.vue'; import MrFileIcon from './mr_file_icon.vue'; @@ -10,7 +9,7 @@ import MrFileIcon from './mr_file_icon.vue'; export default { name: 'FileRowExtra', directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -70,7 +69,7 @@ export default { <span v-if="showTreeChangesCount" class="ide-tree-changes"> {{ changesCount }} <gl-icon - v-tooltip + v-gl-tooltip.left.viewport :title="folderChangesTooltip" :size="12" data-container="body" diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 8f23856fd6c..e1d2895831a 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -1,6 +1,5 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import { WEBIDE_MARK_APP_START, @@ -10,19 +9,12 @@ import { WEBIDE_MEASURE_TREE_FROM_REQUEST, WEBIDE_MEASURE_FILE_FROM_REQUEST, WEBIDE_MEASURE_FILE_AFTER_INTERACTION, -} from '~/performance_constants'; -import { performanceMarkAndMeasure } from '~/performance_utils'; +} from '~/performance/constants'; +import { performanceMarkAndMeasure } from '~/performance/utils'; import { modalTypes } from '../constants'; import eventHub from '../eventhub'; -import FindFile from '~/vue_shared/components/file_finder/index.vue'; -import NewModal from './new_dropdown/modal.vue'; import IdeSidebar from './ide_side_bar.vue'; -import RepoTabs from './repo_tabs.vue'; -import IdeStatusBar from './ide_status_bar.vue'; import RepoEditor from './repo_editor.vue'; -import RightPane from './panes/right.vue'; -import ErrorMessage from './error_message.vue'; -import CommitEditorHeader from './commit_sidebar/editor_header.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { measurePerformance } from '../utils'; @@ -43,19 +35,24 @@ eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () => export default { components: { - NewModal, IdeSidebar, - RepoTabs, - IdeStatusBar, RepoEditor, - FindFile, - ErrorMessage, - CommitEditorHeader, - GlButton, - GlLoadingIcon, - RightPane, + 'error-message': () => import('./error_message.vue'), + 'gl-button': () => import('@gitlab/ui/src/components/base/button/button.vue'), + 'gl-loading-icon': () => import('@gitlab/ui/src/components/base/loading_icon/loading_icon.vue'), + 'commit-editor-header': () => import('./commit_sidebar/editor_header.vue'), + 'repo-tabs': () => import('./repo_tabs.vue'), + 'ide-status-bar': () => import('./ide_status_bar.vue'), + 'find-file': () => import('~/vue_shared/components/file_finder/index.vue'), + 'right-pane': () => import('./panes/right.vue'), + 'new-modal': () => import('./new_dropdown/modal.vue'), }, mixins: [glFeatureFlagsMixin()], + data() { + return { + loadDeferred: false, + }; + }, computed: { ...mapState([ 'openFiles', @@ -107,6 +104,9 @@ export default { createNewFile() { this.$refs.newModal.open(modalTypes.blob); }, + loadDeferredComponents() { + this.loadDeferred = true; + }, }, }; </script> @@ -118,19 +118,23 @@ export default { > <error-message v-if="errorMessage" :message="errorMessage" /> <div class="ide-view flex-grow d-flex"> - <find-file - v-show="fileFindVisible" - :files="allBlobs" - :visible="fileFindVisible" - :loading="loading" - @toggle="toggleFileFinder" - @click="openFile" - /> - <ide-sidebar /> + <template v-if="loadDeferred"> + <find-file + v-show="fileFindVisible" + :files="allBlobs" + :visible="fileFindVisible" + :loading="loading" + @toggle="toggleFileFinder" + @click="openFile" + /> + </template> + <ide-sidebar @tree-ready="loadDeferredComponents" /> <div class="multi-file-edit-pane"> <template v-if="activeFile"> - <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" /> - <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" /> + <template v-if="loadDeferred"> + <commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" /> + <repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" /> + </template> <repo-editor :file="activeFile" class="multi-file-edit-pane-content" /> </template> <template v-else> @@ -177,9 +181,13 @@ export default { </div> </template> </div> - <right-pane v-if="currentProjectId" /> + <template v-if="loadDeferred"> + <right-pane v-if="currentProjectId" /> + </template> </div> - <ide-status-bar /> - <new-modal ref="newModal" /> + <template v-if="loadDeferred"> + <ide-status-bar /> + <new-modal ref="newModal" /> + </template> </article> </template> diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue index 53dfc133fc8..99215d6c3f1 100644 --- a/app/assets/javascripts/ide/components/ide_side_bar.vue +++ b/app/assets/javascripts/ide/components/ide_side_bar.vue @@ -4,21 +4,19 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import IdeTree from './ide_tree.vue'; import ResizablePanel from './resizable_panel.vue'; import ActivityBar from './activity_bar.vue'; -import RepoCommitSection from './repo_commit_section.vue'; import CommitForm from './commit_sidebar/form.vue'; -import IdeReview from './ide_review.vue'; import IdeProjectHeader from './ide_project_header.vue'; -import { SIDEBAR_INIT_WIDTH } from '../constants'; +import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants'; export default { components: { GlSkeletonLoading, ResizablePanel, ActivityBar, - RepoCommitSection, IdeTree, + [leftSidebarViews.review.name]: () => import('./ide_review.vue'), + [leftSidebarViews.commit.name]: () => import('./repo_commit_section.vue'), CommitForm, - IdeReview, IdeProjectHeader, }, computed: { @@ -49,7 +47,7 @@ export default { <div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner"> <div class="multi-file-commit-panel-inner-content"> <keep-alive> - <component :is="currentActivityView" /> + <component :is="currentActivityView" @tree-ready="$emit('tree-ready')" /> </keep-alive> </div> <commit-form /> diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index caa122f6ed2..aa61c0d9b5e 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -14,6 +14,7 @@ export default { }, computed: { ...mapGetters(['activeFile']), + ...mapGetters('editor', ['activeFileEditor']), activeFileEOL() { return getFileEOL(this.activeFile.content); }, @@ -33,8 +34,10 @@ export default { </gl-link> </div> <div>{{ activeFileEOL }}</div> - <div v-if="activeFileIsText">{{ activeFile.editorRow }}:{{ activeFile.editorColumn }}</div> - <div>{{ activeFile.fileLanguage }}</div> + <div v-if="activeFileIsText"> + {{ activeFileEditor.editorRow }}:{{ activeFileEditor.editorColumn }} + </div> + <div>{{ activeFileEditor.fileLanguage }}</div> </template> <terminal-sync-status-safe /> </div> diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue index 56fcb6c2600..e563de6659a 100644 --- a/app/assets/javascripts/ide/components/ide_tree.vue +++ b/app/assets/javascripts/ide/components/ide_tree.vue @@ -51,7 +51,7 @@ export default { </script> <template> - <ide-tree-list> + <ide-tree-list @tree-ready="$emit('tree-ready')"> <template #header> {{ __('Edit') }} <div class="ide-tree-actions ml-auto d-flex" data-testid="ide-root-actions"> diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index dd226f07fb0..e7e94f5b5da 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -6,8 +6,8 @@ import { WEBIDE_MARK_TREE_START, WEBIDE_MEASURE_TREE_FROM_REQUEST, WEBIDE_MARK_FILE_CLICKED, -} from '~/performance_constants'; -import { performanceMarkAndMeasure } from '~/performance_utils'; +} 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'; @@ -32,6 +32,13 @@ export default { return !this.currentTree || this.currentTree.loading; }, }, + watch: { + showLoading(newVal) { + if (!newVal) { + this.$emit('tree-ready'); + } + }, + }, beforeCreate() { performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START }); }, diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue index a5ae8bbfe9a..d65304034c2 100644 --- a/app/assets/javascripts/ide/components/jobs/detail.vue +++ b/app/assets/javascripts/ide/components/jobs/detail.vue @@ -2,7 +2,7 @@ /* eslint-disable vue/no-v-html */ import { mapActions, mapState } from 'vuex'; import { throttle } from 'lodash'; -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui'; import { __ } from '../../../locale'; import ScrollButton from './detail/scroll_button.vue'; import JobDescription from './detail/description.vue'; @@ -17,6 +17,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { + GlButton, GlIcon, ScrollButton, JobDescription, @@ -75,9 +76,9 @@ export default { <template> <div class="ide-pipeline build-page d-flex flex-column flex-fill"> <header class="ide-job-header d-flex align-items-center"> - <button class="btn btn-default btn-sm d-flex" @click="setDetailJob(null)"> - <gl-icon name="chevron-left" /> {{ __('View jobs') }} - </button> + <gl-button category="secondary" icon="chevron-left" size="small" @click="setDetailJob(null)"> + {{ __('View jobs') }} + </gl-button> </header> <div class="top-bar d-flex border-left-0 mr-3"> <job-description :job="detailJob" /> diff --git a/app/assets/javascripts/ide/components/jobs/item.vue b/app/assets/javascripts/ide/components/jobs/item.vue index db3630bc1d1..f84315b63d2 100644 --- a/app/assets/javascripts/ide/components/jobs/item.vue +++ b/app/assets/javascripts/ide/components/jobs/item.vue @@ -1,9 +1,11 @@ <script> +import { GlButton } from '@gitlab/ui'; import JobDescription from './detail/description.vue'; export default { components: { JobDescription, + GlButton, }, props: { job: { @@ -28,9 +30,9 @@ export default { <div class="ide-job-item"> <job-description :job="job" class="gl-mr-3" /> <div class="ml-auto align-self-center"> - <button v-if="job.started" type="button" class="btn btn-default btn-sm" @click="clickViewLog"> + <gl-button v-if="job.started" category="secondary" size="small" @click="clickViewLog"> {{ __('View log') }} - </button> + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/ide/components/mr_file_icon.vue b/app/assets/javascripts/ide/components/mr_file_icon.vue index c8629a869e0..af297753c28 100644 --- a/app/assets/javascripts/ide/components/mr_file_icon.vue +++ b/app/assets/javascripts/ide/components/mr_file_icon.vue @@ -1,20 +1,19 @@ <script> -import { GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; export default { components: { GlIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, }; </script> <template> <gl-icon - v-tooltip + v-gl-tooltip :title="__('Part of merge request changes')" :size="12" name="git-merge" diff --git a/app/assets/javascripts/ide/components/new_dropdown/button.vue b/app/assets/javascripts/ide/components/new_dropdown/button.vue index 8ae8f97f237..ce80fbee2e0 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/button.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/button.vue @@ -1,10 +1,9 @@ <script> -import { GlIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -45,7 +44,7 @@ export default { <template> <button - v-tooltip + v-gl-tooltip :aria-label="label" :title="tooltipTitle" type="button" diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue index f1b882d8f29..87019c3b2a5 100644 --- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue +++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue @@ -1,13 +1,9 @@ <script> import { mapActions, mapState } from 'vuex'; -import tooltip from '~/vue_shared/directives/tooltip'; import IdeSidebarNav from '../ide_sidebar_nav.vue'; export default { name: 'CollapsibleSidebar', - directives: { - tooltip, - }, components: { IdeSidebarNav, }, diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 91bd64a2c9c..6f15773c9ab 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -1,11 +1,16 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; import { escape } from 'lodash'; -import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { + GlLoadingIcon, + GlIcon, + GlSafeHtmlDirective as SafeHtml, + GlTabs, + GlTab, + GlBadge, +} from '@gitlab/ui'; import { sprintf, __ } from '../../../locale'; import CiIcon from '../../../vue_shared/components/ci_icon.vue'; -import Tabs from '../../../vue_shared/components/tabs/tabs'; -import Tab from '../../../vue_shared/components/tabs/tab.vue'; import EmptyState from '../../../pipelines/components/pipelines_list/empty_state.vue'; import JobsList from '../jobs/list.vue'; @@ -15,11 +20,12 @@ export default { components: { GlIcon, CiIcon, - Tabs, - Tab, JobsList, EmptyState, GlLoadingIcon, + GlTabs, + GlTab, + GlBadge, }, directives: { SafeHtml, @@ -88,22 +94,26 @@ export default { <p class="gl-mb-0 break-word">{{ latestPipeline.yamlError }}</p> <p v-safe-html="ciLintText" class="gl-mb-0"></p> </div> - <tabs v-else class="ide-pipeline-list"> - <tab :active="!pipelineFailed"> + <gl-tabs v-else> + <gl-tab :active="!pipelineFailed"> <template #title> {{ __('Jobs') }} - <span v-if="jobsCount" class="badge badge-pill"> {{ jobsCount }} </span> + <gl-badge v-if="jobsCount" size="sm" class="gl-tab-counter-badge">{{ + jobsCount + }}</gl-badge> </template> <jobs-list :loading="isLoadingJobs" :stages="stages" /> - </tab> - <tab :active="pipelineFailed"> + </gl-tab> + <gl-tab :active="pipelineFailed"> <template #title> {{ __('Failed Jobs') }} - <span v-if="failedJobsCount" class="badge badge-pill"> {{ failedJobsCount }} </span> + <gl-badge v-if="failedJobsCount" size="sm" class="gl-tab-counter-badge">{{ + failedJobsCount + }}</gl-badge> </template> <jobs-list :loading="isLoadingJobs" :stages="failedStages" /> - </tab> - </tabs> + </gl-tab> + </gl-tabs> </template> </div> </template> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 92b99b5c731..dfd25feed08 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -1,6 +1,5 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import tooltip from '~/vue_shared/directives/tooltip'; import CommitFilesList from './commit_sidebar/list.vue'; import EmptyState from './commit_sidebar/empty_state.vue'; import { stageKeys } from '../constants'; @@ -10,9 +9,6 @@ export default { CommitFilesList, EmptyState, }, - directives: { - tooltip, - }, computed: { ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']), @@ -68,7 +64,6 @@ export default { :active-file-key="activeFileKey" :empty-state-text="__('There are no changes')" class="is-first" - icon-name="unstaged" /> </template> <empty-state v-else /> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 56bbb6349cd..c8a825065f1 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -9,8 +9,8 @@ import { WEBIDE_MARK_FILE_START, WEBIDE_MEASURE_FILE_AFTER_INTERACTION, WEBIDE_MEASURE_FILE_FROM_REQUEST, -} from '~/performance_constants'; -import { performanceMarkAndMeasure } from '~/performance_utils'; +} from '~/performance/constants'; +import { performanceMarkAndMeasure } from '~/performance/utils'; import eventHub from '../eventhub'; import { leftSidebarViews, @@ -22,6 +22,7 @@ import Editor from '../lib/editor'; import FileTemplatesBar from './file_templates/bar.vue'; import { __ } from '~/locale'; import { extractMarkdownImagesFromEntries } from '../stores/utils'; +import { getFileEditorOrDefault } from '../stores/modules/editor/utils'; import { getPathParent, readFileAsDataURL, registerSchema, isTextFile } from '../utils'; import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; @@ -49,6 +50,7 @@ export default { ...mapState('rightPane', { rightPaneIsOpen: 'isOpen', }), + ...mapState('editor', ['fileEditors']), ...mapState([ 'viewer', 'panelResizing', @@ -67,6 +69,9 @@ export default { 'getJsonSchemaForPath', ]), ...mapGetters('fileTemplates', ['showFileTemplatesBar']), + fileEditor() { + return getFileEditorOrDefault(this.fileEditors, this.file.path); + }, shouldHideEditor() { return this.file && !this.file.loading && !isTextFile(this.file); }, @@ -80,10 +85,10 @@ export default { return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr; }, isEditorViewMode() { - return this.file.viewMode === FILE_VIEW_MODE_EDITOR; + return this.fileEditor.viewMode === FILE_VIEW_MODE_EDITOR; }, isPreviewViewMode() { - return this.file.viewMode === FILE_VIEW_MODE_PREVIEW; + return this.fileEditor.viewMode === FILE_VIEW_MODE_PREVIEW; }, editTabCSS() { return { @@ -125,8 +130,7 @@ export default { this.initEditor(); if (this.currentActivityView !== leftSidebarViews.edit.name) { - this.setFileViewMode({ - file: this.file, + this.updateEditor({ viewMode: FILE_VIEW_MODE_EDITOR, }); } @@ -134,8 +138,7 @@ export default { }, currentActivityView() { if (this.currentActivityView !== leftSidebarViews.edit.name) { - this.setFileViewMode({ - file: this.file, + this.updateEditor({ viewMode: FILE_VIEW_MODE_EDITOR, }); } @@ -195,13 +198,11 @@ export default { 'getFileData', 'getRawFileData', 'changeFileContent', - 'setFileLanguage', - 'setEditorPosition', - 'setFileViewMode', 'removePendingTab', 'triggerFilesChange', 'addTempImage', ]), + ...mapActions('editor', ['updateFileEditor']), initEditor() { if (this.shouldHideEditor && (this.file.content || this.file.raw)) { return; @@ -284,19 +285,19 @@ export default { // Handle Cursor Position this.editor.onPositionChange((instance, e) => { - this.setEditorPosition({ + this.updateEditor({ editorRow: e.position.lineNumber, editorColumn: e.position.column, }); }); this.editor.setPosition({ - lineNumber: this.file.editorRow, - column: this.file.editorColumn, + lineNumber: this.fileEditor.editorRow, + column: this.fileEditor.editorColumn, }); // Handle File Language - this.setFileLanguage({ + this.updateEditor({ fileLanguage: this.model.language, }); @@ -354,6 +355,16 @@ export default { const schema = this.getJsonSchemaForPath(this.file.path); registerSchema(schema); }, + updateEditor(data) { + // Looks like our model wrapper `.dispose` causes the monaco editor to emit some position changes after + // when disposing. We want to ignore these by only capturing editor changes that happen to the currently active + // file. + if (!this.file.active) { + return; + } + + this.updateFileEditor({ path: this.file.path, data }); + }, }, viewerTypes, FILE_VIEW_MODE_EDITOR, @@ -369,7 +380,7 @@ export default { <a href="javascript:void(0);" role="button" - @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })" + @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })" > {{ __('Edit') }} </a> @@ -378,7 +389,7 @@ export default { <a href="javascript:void(0);" role="button" - @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })" + @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })" >{{ previewMode.previewTitle }}</a > </li> diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue index 1402f7aaf39..72c56daf69c 100644 --- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue +++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue @@ -1,7 +1,6 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; import '~/lib/utils/datetime_utility'; export default { @@ -9,7 +8,7 @@ export default { GlIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { file: { @@ -28,7 +27,7 @@ export default { </script> <template> - <span v-if="file.file_lock" v-tooltip :title="lockTooltip" data-container="body"> + <span v-if="file.file_lock" v-gl-tooltip :title="lockTooltip" data-container="body"> <gl-icon name="lock" class="file-status-icon" /> </span> </template> diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index 60a80a31a8b..e3c41eee15e 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -29,9 +29,9 @@ export default { ...mapGetters(['getUrlForPath']), closeLabel() { if (this.fileHasChanged) { - return sprintf(__(`%{tabname} changed`), { tabname: this.tab.name }); + return sprintf(__('%{tabname} changed'), { tabname: this.tab.name }); } - return sprintf(__(`Close %{tabname}`, { tabname: this.tab.name })); + return sprintf(__('Close %{tabname}'), { tabname: this.tab.name }); }, showChangedIcon() { if (this.tab.pending) return true; diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue index 3668dd24e81..f4dd83b16c7 100644 --- a/app/assets/javascripts/ide/components/terminal/empty_state.vue +++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue @@ -1,10 +1,14 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlAlert, GlSafeHtmlDirective } from '@gitlab/ui'; export default { components: { GlLoadingIcon, + GlButton, + GlAlert, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, }, props: { isLoading: { @@ -41,24 +45,26 @@ export default { }; </script> <template> - <div class="text-center p-3"> + <div class="gl-text-center gl-p-5"> <div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div> <h4>{{ __('Web Terminal') }}</h4> <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" /> <template v-else> <p>{{ __('Run tests against your code live using the Web Terminal') }}</p> <p> - <button + <gl-button :disabled="!isValid" - class="btn btn-info" - type="button" + category="primary" + variant="info" data-qa-selector="start_web_terminal_button" @click="onStart" > {{ __('Start Web Terminal') }} - </button> + </gl-button> </p> - <div v-if="!isValid && message" class="bs-callout text-left" v-html="message"></div> + <gl-alert v-if="!isValid && message" variant="tip" :dismissible="false"> + <span v-safe-html="message"></span> + </gl-alert> <p v-else> <a v-if="helpPath" diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index b8d59f8bd36..1496170447d 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -5,7 +5,7 @@ import { visitUrl } from '~/lib/utils/url_utility'; import { deprecatedCreateFlash as flash } from '~/flash'; import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; -import { stageKeys } from '../constants'; +import { stageKeys, commitActionTypes } from '../constants'; import service from '../services'; import eventHub from '../eventhub'; @@ -242,7 +242,7 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name, } } - dispatch('triggerFilesChange'); + dispatch('triggerFilesChange', { type: commitActionTypes.move, path, newPath }); }; export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index a0df85540f9..4b9b958ddd6 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -164,26 +164,6 @@ export const changeFileContent = ({ commit, state, getters }, { path, content }) } }; -export const setFileLanguage = ({ getters, commit }, { fileLanguage }) => { - if (getters.activeFile) { - commit(types.SET_FILE_LANGUAGE, { file: getters.activeFile, fileLanguage }); - } -}; - -export const setEditorPosition = ({ getters, commit }, { editorRow, editorColumn }) => { - if (getters.activeFile) { - commit(types.SET_FILE_POSITION, { - file: getters.activeFile, - editorRow, - editorColumn, - }); - } -}; - -export const setFileViewMode = ({ commit }, { file, viewMode }) => { - commit(types.SET_FILE_VIEWMODE, { file, viewMode }); -}; - export const restoreOriginalFile = ({ dispatch, state, commit }, path) => { const file = state.entries[path]; const isDestructiveDiscard = file.tempFile || file.prevPath; @@ -289,7 +269,7 @@ export const removePendingTab = ({ commit }, file) => { eventHub.$emit(`editor.update.model.dispose.${file.key}`); }; -export const triggerFilesChange = () => { +export const triggerFilesChange = (ctx, payload = {}) => { // Used in EE for file mirroring - eventHub.$emit('ide.files.change'); + eventHub.$emit('ide.files.change', payload); }; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index 324c5b0c6e4..d543209716a 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -12,6 +12,8 @@ import fileTemplates from './modules/file_templates'; import paneModule from './modules/pane'; import clientsideModule from './modules/clientside'; import routerModule from './modules/router'; +import editorModule from './modules/editor'; +import { setupFileEditorsSync } from './modules/editor/setup'; Vue.use(Vuex); @@ -29,7 +31,14 @@ export const createStoreOptions = () => ({ rightPane: paneModule(), clientside: clientsideModule(), router: routerModule, + editor: editorModule, }, }); -export const createStore = () => new Vuex.Store(createStoreOptions()); +export const createStore = () => { + const store = new Vuex.Store(createStoreOptions()); + + setupFileEditorsSync(store); + + return store; +}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index 37f887bcf0a..416ca88d6c9 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -14,6 +14,8 @@ const createTranslatedTextForFiles = (files, text) => { export const discardDraftButtonDisabled = state => state.commitMessage === '' || state.submitCommitLoading; +// Note: If changing the structure of the placeholder branch name, please also +// update #patch_branch_name in app/helpers/tree_helper.rb export const placeholderBranchName = (state, _, rootState) => `${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr( -BRANCH_SUFFIX_COUNT, diff --git a/app/assets/javascripts/ide/stores/modules/editor/actions.js b/app/assets/javascripts/ide/stores/modules/editor/actions.js new file mode 100644 index 00000000000..cc23a655235 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/editor/actions.js @@ -0,0 +1,19 @@ +import * as types from './mutation_types'; + +/** + * Action to update the current file editor info at the given `path` with the given `data` + * + * @param {} vuex + * @param {{ path: String, data: any }} payload + */ +export const updateFileEditor = ({ commit }, payload) => { + commit(types.UPDATE_FILE_EDITOR, payload); +}; + +export const removeFileEditor = ({ commit }, path) => { + commit(types.REMOVE_FILE_EDITOR, path); +}; + +export const renameFileEditor = ({ commit }, payload) => { + commit(types.RENAME_FILE_EDITOR, payload); +}; diff --git a/app/assets/javascripts/ide/stores/modules/editor/getters.js b/app/assets/javascripts/ide/stores/modules/editor/getters.js new file mode 100644 index 00000000000..dabaafa453a --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/editor/getters.js @@ -0,0 +1,13 @@ +import { getFileEditorOrDefault } from './utils'; + +export const activeFileEditor = (state, getters, rootState, rootGetters) => { + const { activeFile } = rootGetters; + + if (!activeFile) { + return null; + } + + const { path } = rootGetters.activeFile; + + return getFileEditorOrDefault(state.fileEditors, path); +}; diff --git a/app/assets/javascripts/ide/stores/modules/editor/index.js b/app/assets/javascripts/ide/stores/modules/editor/index.js new file mode 100644 index 00000000000..8a7437b427d --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/editor/index.js @@ -0,0 +1,12 @@ +import * as actions from './actions'; +import * as getters from './getters'; +import state from './state'; +import mutations from './mutations'; + +export default { + namespaced: true, + actions, + state, + mutations, + getters, +}; diff --git a/app/assets/javascripts/ide/stores/modules/editor/mutation_types.js b/app/assets/javascripts/ide/stores/modules/editor/mutation_types.js new file mode 100644 index 00000000000..89b7e9cbc76 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/editor/mutation_types.js @@ -0,0 +1,3 @@ +export const UPDATE_FILE_EDITOR = 'UPDATE_FILE_EDITOR'; +export const REMOVE_FILE_EDITOR = 'REMOVE_FILE_EDITOR'; +export const RENAME_FILE_EDITOR = 'RENAME_FILE_EDITOR'; diff --git a/app/assets/javascripts/ide/stores/modules/editor/mutations.js b/app/assets/javascripts/ide/stores/modules/editor/mutations.js new file mode 100644 index 00000000000..f332fe9dce9 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/editor/mutations.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import * as types from './mutation_types'; +import { getFileEditorOrDefault } from './utils'; + +export default { + [types.UPDATE_FILE_EDITOR](state, { path, data }) { + const editor = getFileEditorOrDefault(state.fileEditors, path); + + Vue.set(state.fileEditors, path, Object.assign(editor, data)); + }, + [types.REMOVE_FILE_EDITOR](state, path) { + Vue.delete(state.fileEditors, path); + }, + [types.RENAME_FILE_EDITOR](state, { path, newPath }) { + const existing = state.fileEditors[path]; + + // Gracefully do nothing if fileEditor isn't found. + if (!existing) { + return; + } + + Vue.delete(state.fileEditors, path); + Vue.set(state.fileEditors, newPath, existing); + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/editor/setup.js b/app/assets/javascripts/ide/stores/modules/editor/setup.js new file mode 100644 index 00000000000..c5a613c6baa --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/editor/setup.js @@ -0,0 +1,19 @@ +import eventHub from '~/ide/eventhub'; +import { commitActionTypes } from '~/ide/constants'; + +const removeUnusedFileEditors = store => { + Object.keys(store.state.editor.fileEditors) + .filter(path => !store.state.entries[path]) + .forEach(path => store.dispatch('editor/removeFileEditor', path)); +}; + +export const setupFileEditorsSync = store => { + eventHub.$on('ide.files.change', ({ type, ...payload } = {}) => { + if (type === commitActionTypes.move) { + store.dispatch('editor/renameFileEditor', payload); + } else { + // The files have changed, but the specific change is not known. + removeUnusedFileEditors(store); + } + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/editor/state.js b/app/assets/javascripts/ide/stores/modules/editor/state.js new file mode 100644 index 00000000000..484aeec5cc3 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/editor/state.js @@ -0,0 +1,8 @@ +export default () => ({ + // Object which represents a dictionary of filePath to editor specific properties, including: + // - fileLanguage + // - editorRow + // - editorCol + // - viewMode + fileEditors: {}, +}); diff --git a/app/assets/javascripts/ide/stores/modules/editor/utils.js b/app/assets/javascripts/ide/stores/modules/editor/utils.js new file mode 100644 index 00000000000..bef21d04b2b --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/editor/utils.js @@ -0,0 +1,11 @@ +import { FILE_VIEW_MODE_EDITOR } from '../../../constants'; + +export const createDefaultFileEditor = () => ({ + editorRow: 1, + editorColumn: 1, + fileLanguage: '', + viewMode: FILE_VIEW_MODE_EDITOR, +}); + +export const getFileEditorOrDefault = (fileEditors, path) => + fileEditors[path] || createDefaultFileEditor(); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index ae119c2b1fd..22ff29e8866 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -36,9 +36,6 @@ export const SET_FILE_ACTIVE = 'SET_FILE_ACTIVE'; export const SET_FILE_RAW_DATA = 'SET_FILE_RAW_DATA'; export const SET_FILE_BASE_RAW_DATA = 'SET_FILE_BASE_RAW_DATA'; export const UPDATE_FILE_CONTENT = 'UPDATE_FILE_CONTENT'; -export const SET_FILE_LANGUAGE = 'SET_FILE_LANGUAGE'; -export const SET_FILE_POSITION = 'SET_FILE_POSITION'; -export const SET_FILE_VIEWMODE = 'SET_FILE_VIEWMODE'; export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED'; diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index a981f86fa40..61a55d45128 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -95,17 +95,6 @@ export default { changed, }); }, - [types.SET_FILE_LANGUAGE](state, { file, fileLanguage }) { - Object.assign(state.entries[file.path], { - fileLanguage, - }); - }, - [types.SET_FILE_POSITION](state, { file, editorRow, editorColumn }) { - Object.assign(state.entries[file.path], { - editorRow, - editorColumn, - }); - }, [types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) { let diffMode = diffModes.replaced; if (mrChange.new_file) { @@ -122,11 +111,6 @@ export default { }, }); }, - [types.SET_FILE_VIEWMODE](state, { file, viewMode }) { - Object.assign(state.entries[file.path], { - viewMode, - }); - }, [types.DISCARD_FILE_CHANGES](state, path) { const stagedFile = state.stagedFiles.find(f => f.path === path); const entry = state.entries[path]; diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index b7ced3a271a..96f3caf1e98 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -1,4 +1,4 @@ -import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants'; +import { commitActionTypes } from '../constants'; import { relativePathToAbsolute, isAbsolute, @@ -25,10 +25,6 @@ export const dataStructure = () => ({ rawPath: '', raw: '', content: '', - editorRow: 1, - editorColumn: 1, - fileLanguage: '', - viewMode: FILE_VIEW_MODE_EDITOR, size: 0, parentPath: null, lastOpenedAt: 0, diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 4cf4f5e1d81..1ca1b971de1 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,7 +1,7 @@ import { languages } from 'monaco-editor'; import { flatten, isString } from 'lodash'; import { SIDE_LEFT, SIDE_RIGHT } from './constants'; -import { performanceMarkAndMeasure } from '~/performance_utils'; +import { performanceMarkAndMeasure } from '~/performance/utils'; const toLowerCase = x => x.toLowerCase(); diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js index 7b70d290278..7b7afd13c55 100644 --- a/app/assets/javascripts/import_projects/store/actions.js +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -7,11 +7,14 @@ import { visitUrl, objectToQuery } from '~/lib/utils/url_utility'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import httpStatusCodes from '~/lib/utils/http_status'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; let eTagPoll; const hasRedirectInError = e => e?.response?.data?.error?.redirect; const redirectToUrlInError = e => visitUrl(e.response.data.error.redirect); +const tooManyRequests = e => e.response.status === httpStatusCodes.TOO_MANY_REQUESTS; const pathWithParams = ({ path, ...params }) => { const filteredParams = Object.fromEntries( Object.entries(params).filter(([, value]) => value !== ''), @@ -37,8 +40,6 @@ const restartJobsPolling = () => { if (eTagPoll) eTagPoll.restart(); }; -const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter); - const setImportTarget = ({ commit }, { repoId, importTarget }) => commit(types.SET_IMPORT_TARGET, { repoId, importTarget }); @@ -73,6 +74,14 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) if (hasRedirectInError(e)) { redirectToUrlInError(e); + } else if (tooManyRequests(e)) { + createFlash( + sprintf(s__('ImportProjects|%{provider} rate limit exceeded. Try again later'), { + provider: capitalizeFirstCharacter(provider), + }), + ); + + commit(types.RECEIVE_REPOS_ERROR); } else { createFlash( sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), { @@ -172,12 +181,9 @@ const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) = }); }; -const setPage = ({ state, commit, dispatch }, page) => { - if (page === state.pageInfo.page) { - return null; - } +const setFilter = ({ commit, dispatch }, filter) => { + commit(types.SET_FILTER, filter); - commit(types.SET_PAGE, page); return dispatch('fetchRepos'); }; @@ -188,7 +194,6 @@ export default ({ endpoints = isRequired() }) => ({ setFilter, setImportTarget, importAll, - setPage, fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath }), fetchImport: fetchImportFactory(endpoints.importPath), fetchJobs: fetchJobsFactory(endpoints.jobsPath), diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 349ca14b4e8..078c50ee9c6 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -3,7 +3,7 @@ import { escape } from 'lodash'; import { __, sprintf } from './locale'; import axios from './lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from './flash'; -import { parseBoolean } from './lib/utils/common_utils'; +import { parseBoolean, spriteIcon } from './lib/utils/common_utils'; class ImporterStatus { constructor({ jobsUrl, importUrl, ciCdOnly }) { @@ -108,7 +108,7 @@ class ImporterStatus { switch (job.import_status) { case 'finished': jobItem.removeClass('table-active').addClass('table-success'); - statusField.html(`<span><i class="fa fa-check"></i> ${__('Done')}</span>`); + statusField.html(`<span>${spriteIcon('check', 's16')} ${__('Done')}</span>`); break; case 'scheduled': statusField.html(`${spinner} ${__('Scheduled')}`); diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index e1f9d858f2b..0e3839deaf5 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -69,9 +69,12 @@ export default { { key: 'incidentSla', label: s__('IncidentManagement|Time to SLA'), - thClass: `gl-pointer-events-none gl-text-right gl-w-eighth`, + thClass: `gl-text-right gl-w-eighth`, tdClass: `${tdClass} gl-text-right`, thAttr: TH_INCIDENT_SLA_TEST_ID, + sortKey: 'SLA_DUE_AT', + sortable: true, + sortDirection: 'asc', }, { key: 'assignees', @@ -253,13 +256,22 @@ export default { this.redirecting = true; }, fetchSortedData({ sortBy, sortDesc }) { + let sortKey; + // In bootstrap-vue v2.17.0, sortKey becomes natively supported and we can eliminate this function + const field = this.availableFields.find(({ key }) => key === sortBy); const sortingDirection = sortDesc ? 'DESC' : 'ASC'; - const sortingColumn = convertToSnakeCase(sortBy) - .replace(/_.*/, '') - .toUpperCase(); + + // Use `sortKey` if provided, otherwise fall back to existing algorithm + if (field?.sortKey) { + sortKey = field.sortKey; + } else { + sortKey = convertToSnakeCase(sortBy) + .replace(/_.*/, '') + .toUpperCase(); + } this.pagination = initialPaginationState; - this.sort = `${sortingColumn}_${sortingDirection}`; + this.sort = `${sortKey}_${sortingDirection}`; }, getSeverity(severity) { return INCIDENT_SEVERITY[severity]; @@ -407,7 +419,7 @@ export default { </template> </gl-table> </template> - <template #emtpy-state> + <template #empty-state> <gl-empty-state :title="emptyStateData.title" :svg-path="emptyListSvgPath" diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue index 890381a8f29..93ea1f4f636 100644 --- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue +++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue @@ -8,14 +8,14 @@ export default { GlModal, }, computed: { - ...mapGetters(['isSavingOrTesting']), + ...mapGetters(['isDisabled']), primaryProps() { return { text: __('Save'), attributes: [ { variant: 'success' }, { category: 'primary' }, - { disabled: this.isSavingOrTesting }, + { disabled: this.isDisabled }, ], }; }, @@ -52,7 +52,7 @@ export default { <p class="gl-mb-0"> {{ s__( - 'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.', + 'Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use parent level defaults.', ) }} </p> diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 0fd39c5635d..bbfa865905a 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -12,6 +12,7 @@ import JiraIssuesFields from './jira_issues_fields.vue'; import TriggerFields from './trigger_fields.vue'; import DynamicField from './dynamic_field.vue'; import ConfirmationModal from './confirmation_modal.vue'; +import ResetConfirmationModal from './reset_confirmation_modal.vue'; export default { name: 'IntegrationForm', @@ -23,6 +24,7 @@ export default { TriggerFields, DynamicField, ConfirmationModal, + ResetConfirmationModal, GlButton, }, directives: { @@ -30,23 +32,29 @@ export default { }, mixins: [glFeatureFlagsMixin()], computed: { - ...mapGetters(['currentKey', 'propsSource', 'isSavingOrTesting']), - ...mapState(['defaultState', 'override', 'isSaving', 'isTesting']), + ...mapGetters(['currentKey', 'propsSource', 'isDisabled']), + ...mapState(['defaultState', 'override', 'isSaving', 'isTesting', 'isResetting']), isEditable() { return this.propsSource.editable; }, isJira() { return this.propsSource.type === 'jira'; }, - isInstanceLevel() { - return this.propsSource.integrationLevel === integrationLevels.INSTANCE; + isInstanceOrGroupLevel() { + return ( + this.propsSource.integrationLevel === integrationLevels.INSTANCE || + this.propsSource.integrationLevel === integrationLevels.GROUP + ); }, showJiraIssuesFields() { return this.isJira && this.glFeatures.jiraIssuesIntegration; }, + showReset() { + return this.isInstanceOrGroupLevel && this.propsSource.resetPath; + }, }, methods: { - ...mapActions(['setOverride', 'setIsSaving', 'setIsTesting']), + ...mapActions(['setOverride', 'setIsSaving', 'setIsTesting', 'setIsResetting']), onSaveClick() { this.setIsSaving(true); eventHub.$emit('saveIntegration'); @@ -55,6 +63,7 @@ export default { this.setIsTesting(true); eventHub.$emit('testIntegration'); }, + onResetClick() {}, }, }; </script> @@ -91,13 +100,13 @@ export default { v-bind="propsSource.jiraIssuesProps" /> <div v-if="isEditable" class="footer-block row-content-block"> - <template v-if="isInstanceLevel"> + <template v-if="isInstanceOrGroupLevel"> <gl-button v-gl-modal.confirmSaveIntegration category="primary" variant="success" :loading="isSaving" - :disabled="isSavingOrTesting" + :disabled="isDisabled" data-qa-selector="save_changes_button" > {{ __('Save changes') }} @@ -110,7 +119,7 @@ export default { variant="success" type="submit" :loading="isSaving" - :disabled="isSavingOrTesting" + :disabled="isDisabled" data-qa-selector="save_changes_button" @click.prevent="onSaveClick" > @@ -120,13 +129,27 @@ export default { <gl-button v-if="propsSource.canTest" :loading="isTesting" - :disabled="isSavingOrTesting" + :disabled="isDisabled" :href="propsSource.testPath" @click.prevent="onTestClick" > {{ __('Test settings') }} </gl-button> + <template v-if="showReset"> + <gl-button + v-gl-modal.confirmResetIntegration + category="secondary" + variant="default" + :loading="isResetting" + :disabled="isDisabled" + data-testid="reset-button" + > + {{ __('Reset') }} + </gl-button> + <reset-confirmation-modal @reset="onResetClick" /> + </template> + <gl-button class="btn-cancel" :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button> </div> </div> diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 08f24ce8ab6..123d794912a 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -108,12 +108,7 @@ export default { :label="s__('Integrations|Comment detail:')" data-testid="comment-detail" > - <input - v-if="isInheriting" - name="service[comment_detail]" - type="hidden" - :value="commentDetail" - /> + <input name="service[comment_detail]" type="hidden" :value="commentDetail" /> <gl-form-radio v-for="commentDetailOption in commentDetailOptions" :key="commentDetailOption.value" diff --git a/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue new file mode 100644 index 00000000000..d8503910566 --- /dev/null +++ b/app/assets/javascripts/integrations/edit/components/reset_confirmation_modal.vue @@ -0,0 +1,61 @@ +<script> +import { mapGetters } from 'vuex'; +import { GlModal } from '@gitlab/ui'; + +import { __ } from '~/locale'; + +export default { + components: { + GlModal, + }, + computed: { + ...mapGetters(['isDisabled']), + primaryProps() { + return { + text: __('Reset'), + attributes: [ + { variant: 'warning' }, + { category: 'primary' }, + { disabled: this.isDisabled }, + ], + }; + }, + cancelProps() { + return { + text: __('Cancel'), + }; + }, + }, + methods: { + onReset() { + this.$emit('reset'); + }, + }, +}; +</script> + +<template> + <gl-modal + modal-id="confirmResetIntegration" + size="sm" + :title="s__('Integrations|Reset integration?')" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="onReset" + > + <p> + {{ + s__( + 'Integrations|Resetting this integration will clear the settings and deactivate this integration.', + ) + }} + </p> + <p> + {{ s__('Integrations|All projects inheriting these settings will also be reset.') }} + </p> + + <p class="gl-mb-0"> + {{ s__('Integrations|Projects using custom settings will not be affected.') }} + </p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js index 248ee62d43a..95a53f1beab 100644 --- a/app/assets/javascripts/integrations/edit/index.js +++ b/app/assets/javascripts/integrations/edit/index.js @@ -26,6 +26,7 @@ function parseDatasetToProps(data) { integrationLevel, cancelPath, testPath, + resetPath, ...booleanAttributes } = data; const { @@ -49,6 +50,7 @@ function parseDatasetToProps(data) { editable, canTest, testPath, + resetPath, triggerFieldsProps: { initialTriggerCommit: commitEvents, initialTriggerMergeRequest: mergeRequestEvents, diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js index 199c9074ead..097304be242 100644 --- a/app/assets/javascripts/integrations/edit/store/actions.js +++ b/app/assets/javascripts/integrations/edit/store/actions.js @@ -3,3 +3,5 @@ import * as types from './mutation_types'; export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override); export const setIsSaving = ({ commit }, isSaving) => commit(types.SET_IS_SAVING, isSaving); export const setIsTesting = ({ commit }, isTesting) => commit(types.SET_IS_TESTING, isTesting); +export const setIsResetting = ({ commit }, isResetting) => + commit(types.SET_IS_RESETTING, isResetting); diff --git a/app/assets/javascripts/integrations/edit/store/getters.js b/app/assets/javascripts/integrations/edit/store/getters.js index 4ee5f11855c..310d970c73e 100644 --- a/app/assets/javascripts/integrations/edit/store/getters.js +++ b/app/assets/javascripts/integrations/edit/store/getters.js @@ -1,6 +1,6 @@ export const isInheriting = state => (state.defaultState === null ? false : !state.override); -export const isSavingOrTesting = state => state.isSaving || state.isTesting; +export const isDisabled = state => state.isSaving || state.isTesting || state.isResetting; export const propsSource = (state, getters) => getters.isInheriting ? state.defaultState : state.customState; diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js index 0dae8ea079e..2a84408f658 100644 --- a/app/assets/javascripts/integrations/edit/store/mutation_types.js +++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js @@ -1,3 +1,4 @@ export const SET_OVERRIDE = 'SET_OVERRIDE'; export const SET_IS_SAVING = 'SET_IS_SAVING'; export const SET_IS_TESTING = 'SET_IS_TESTING'; +export const SET_IS_RESETTING = 'SET_IS_RESETTING'; diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js index 8ac3c476f9e..07e3e25ccf0 100644 --- a/app/assets/javascripts/integrations/edit/store/mutations.js +++ b/app/assets/javascripts/integrations/edit/store/mutations.js @@ -10,4 +10,7 @@ export default { [types.SET_IS_TESTING](state, isTesting) { state.isTesting = isTesting; }, + [types.SET_IS_RESETTING](state, isResetting) { + state.isResetting = isResetting; + }, }; diff --git a/app/assets/javascripts/integrations/edit/store/state.js b/app/assets/javascripts/integrations/edit/store/state.js index a9ecee6c539..aae3db1583f 100644 --- a/app/assets/javascripts/integrations/edit/store/state.js +++ b/app/assets/javascripts/integrations/edit/store/state.js @@ -7,5 +7,6 @@ export default ({ defaultState = null, customState = {} } = {}) => { customState, isSaving: false, isTesting: false, + isResetting: false, }; }; diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index d2ea14a658b..b55ef77ae5d 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -6,13 +6,13 @@ import { GlDatepicker, GlLink, GlSprintf, - GlSearchBoxByType, GlButton, GlFormInput, } from '@gitlab/ui'; import eventHub from '../event_hub'; -import { s__, sprintf } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import Api from '~/api'; +import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; export default { name: 'InviteMembersModal', @@ -23,16 +23,20 @@ export default { GlDropdown, GlDropdownItem, GlSprintf, - GlSearchBoxByType, GlButton, GlFormInput, + MembersTokenSelect, }, props: { - groupId: { + id: { type: String, required: true, }, - groupName: { + isProject: { + type: Boolean, + required: true, + }, + name: { type: String, required: true, }, @@ -59,9 +63,16 @@ export default { }; }, computed: { + inviteToName() { + return this.name.toUpperCase(); + }, + inviteToType() { + return this.isProject ? __('project') : __('group'); + }, introText() { - return sprintf(s__("InviteMembersModal|You're inviting members to the %{group_name} group"), { - group_name: this.groupName, + return sprintf(s__("InviteMembersModal|You're inviting members to the %{name} %{type}"), { + name: this.inviteToName, + type: this.inviteToType, }); }, toastOptions() { @@ -110,13 +121,14 @@ export default { this.selectedAccessLevel = item; }, submitForm(formData) { - return Api.inviteGroupMember(this.groupId, formData) - .then(() => { - this.showToastMessageSuccess(); - }) - .catch(error => { - this.showToastMessageError(error); - }); + if (this.isProject) { + return Api.inviteProjectMembers(this.id, formData) + .then(this.showToastMessageSuccess) + .catch(this.showToastMessageError); + } + return Api.inviteGroupMember(this.id, formData) + .then(this.showToastMessageSuccess) + .catch(this.showToastMessageError); }, showToastMessageSuccess() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); @@ -129,44 +141,45 @@ export default { }, labels: { modalTitle: s__('InviteMembersModal|Invite team members'), - userToInvite: s__('InviteMembersModal|GitLab member or Email address'), + newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'), userPlaceholder: s__('InviteMembersModal|Search for members to invite'), accessLevel: s__('InviteMembersModal|Choose a role permission'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), - toastMessageSuccessful: s__('InviteMembersModal|Users were succesfully added'), - toastMessageUnsuccessful: s__('InviteMembersModal|User not invited. Feature coming soon!'), + toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), + toastMessageUnsuccessful: s__('InviteMembersModal|Some of the members could not be added'), readMoreText: s__(`InviteMembersModal|%{linkStart}Read more%{linkEnd} about role permissions`), inviteButtonText: s__('InviteMembersModal|Invite'), cancelButtonText: s__('InviteMembersModal|Cancel'), + headerCloseLabel: s__('InviteMembersModal|Close invite team members'), }, + membersTokenSelectLabelId: 'invite-members-input', }; </script> <template> - <gl-modal :modal-id="modalId" size="sm" :title="$options.labels.modalTitle"> + <gl-modal + :modal-id="modalId" + size="sm" + :title="$options.labels.modalTitle" + :header-close-label="$options.labels.headerCloseLabel" + > <div class="gl-ml-5 gl-mr-5"> <div>{{ introText }}</div> - <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.userToInvite }}</label> + <label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{ + $options.labels.newUsersToInvite + }}</label> <div class="gl-mt-2"> - <gl-search-box-by-type + <members-token-select v-model="newUsersToInvite" + :label="$options.labels.newUsersToInvite" + :aria-labelledby="$options.membersTokenSelectLabelId" :placeholder="$options.labels.userPlaceholder" - type="text" - autocomplete="off" - autocorrect="off" - autocapitalize="off" - spellcheck="false" /> </div> <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-dropdown - menu-class="dropdown-menu-selectable" - class="gl-shadow-none gl-w-full" - v-bind="$attrs" - :text="selectedRoleName" - > + <gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName"> <template v-for="(key, item) in accessLevels"> <gl-dropdown-item :key="key" @@ -215,9 +228,13 @@ export default { {{ $options.labels.cancelButtonText }} </gl-button> <div class="gl-mr-3"></div> - <gl-button ref="inviteButton" variant="success" @click="sendInvite">{{ - $options.labels.inviteButtonText - }}</gl-button> + <gl-button + ref="inviteButton" + :disabled="!newUsersToInvite" + variant="success" + @click="sendInvite" + >{{ $options.labels.inviteButtonText }}</gl-button + > </div> </template> </gl-modal> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue new file mode 100644 index 00000000000..aed2e5e3236 --- /dev/null +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -0,0 +1,120 @@ +<script> +import { debounce } from 'lodash'; +import { GlTokenSelector, GlAvatar, GlAvatarLabeled } from '@gitlab/ui'; +import { USER_SEARCH_DELAY } from '../constants'; +import Api from '~/api'; + +export default { + components: { + GlTokenSelector, + GlAvatar, + GlAvatarLabeled, + }, + props: { + placeholder: { + type: String, + required: false, + default: '', + }, + ariaLabelledby: { + type: String, + required: true, + }, + }, + data() { + return { + loading: false, + query: '', + users: [], + selectedTokens: [], + hasBeenFocused: false, + hideDropdownWithNoItems: true, + }; + }, + computed: { + newUsersToInvite() { + return this.selectedTokens + .map(obj => { + return obj.id; + }) + .join(','); + }, + placeholderText() { + if (this.selectedTokens.length === 0) { + return this.placeholder; + } + return ''; + }, + }, + methods: { + handleTextInput(query) { + this.hideDropdownWithNoItems = false; + this.query = query; + this.loading = true; + this.retrieveUsers(query); + }, + retrieveUsers: debounce(function debouncedRetrieveUsers() { + return Api.users(this.query, this.$options.queryOptions) + .then(response => { + this.users = response.data.map(token => ({ + id: token.id, + name: token.name, + username: token.username, + avatar_url: token.avatar_url, + })); + this.loading = false; + }) + .catch(() => { + this.loading = false; + }); + }, USER_SEARCH_DELAY), + handleInput() { + this.$emit('input', this.newUsersToInvite); + }, + handleBlur() { + this.hideDropdownWithNoItems = false; + }, + handleFocus() { + // The modal auto-focuses on the input when opened. + // This prevents the dropdown from opening when the modal opens. + if (this.hasBeenFocused) { + this.loading = true; + this.retrieveUsers(); + } + + this.hasBeenFocused = true; + }, + }, + queryOptions: { exclude_internal: true, active: true }, +}; +</script> + +<template> + <gl-token-selector + v-model="selectedTokens" + :dropdown-items="users" + :loading="loading" + :allow-user-defined-tokens="false" + :hide-dropdown-with-no-items="hideDropdownWithNoItems" + :placeholder="placeholderText" + :aria-labelledby="ariaLabelledby" + @blur="handleBlur" + @text-input="handleTextInput" + @input="handleInput" + @focus="handleFocus" + > + <template #token-content="{ token }"> + <gl-avatar v-if="token.avatar_url" :src="token.avatar_url" :size="16" /> + {{ token.name }} + </template> + + <template #dropdown-item-content="{ dropdownItem }"> + <gl-avatar-labeled + :src="dropdownItem.avatar_url" + :size="32" + :label="dropdownItem.name" + :sub-label="dropdownItem.username" + /> + </template> + </gl-token-selector> +</template> diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js new file mode 100644 index 00000000000..1ff2125c292 --- /dev/null +++ b/app/assets/javascripts/invite_members/constants.js @@ -0,0 +1 @@ +export const USER_SEARCH_DELAY = 200; diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 92aa3187fc3..db957ecacfd 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -18,7 +18,6 @@ export default function initInviteMembersModal() { props: { ...el.dataset, accessLevels: JSON.parse(el.dataset.accessLevels), - groupName: el.dataset.groupName.toUpperCase(), }, }), }); diff --git a/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue b/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue new file mode 100644 index 00000000000..5ca9e50d854 --- /dev/null +++ b/app/assets/javascripts/issuable_list/components/issuable_bulk_edit_sidebar.vue @@ -0,0 +1,35 @@ +<script> +export default { + props: { + expanded: { + type: Boolean, + required: true, + }, + }, + watch: { + expanded(value) { + const layoutPageEl = document.querySelector('.layout-page'); + + if (layoutPageEl) { + layoutPageEl.classList.toggle('right-sidebar-expanded', value); + layoutPageEl.classList.toggle('right-sidebar-collapsed', !value); + } + }, + }, +}; +</script> + +<template> + <aside + :class="{ 'right-sidebar-expanded': expanded, 'right-sidebar-collapsed': !expanded }" + class="issues-bulk-update right-sidebar" + aria-live="polite" + > + <div + class="gl-display-flex gl-justify-content-space-between gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-gray-100" + > + <slot name="bulk-edit-actions"></slot> + </div> + <slot name="sidebar-items"></slot> + </aside> +</template> diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index d8cb1ab07cd..1ee794ab208 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -1,15 +1,21 @@ <script> -import { GlLink, GlLabel, GlTooltipDirective } from '@gitlab/ui'; +import { GlLink, GlIcon, GlLabel, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getTimeago } from '~/lib/utils/datetime_utility'; import { isScopedLabel } from '~/lib/utils/common_utils'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; + export default { components: { GlLink, + GlIcon, GlLabel, + GlFormCheckbox, + IssuableAssignees, }, directives: { GlTooltip: GlTooltipDirective, @@ -24,25 +30,42 @@ export default { type: Object, required: true, }, + enableLabelPermalinks: { + type: Boolean, + required: true, + }, + showCheckbox: { + type: Boolean, + required: true, + }, + checked: { + type: Boolean, + required: false, + default: false, + }, }, computed: { author() { return this.issuable.author; }, authorId() { - const id = parseInt(this.author.id, 10); - - if (Number.isNaN(id)) { - return this.author.id.includes('gid') - ? this.author.id.split('gid://gitlab/User/').pop() - : ''; + return getIdFromGraphQLId(`${this.author.id}`); + }, + isIssuableUrlExternal() { + // Check if URL is relative, which means it is internal. + if (!/^https?:\/\//g.test(this.issuable.webUrl)) { + return false; } - - return id; + // In case URL is absolute, it may or may not be internal, + // hence use `gon.gitlab_url` which is current instance domain. + return !this.issuable.webUrl.includes(gon.gitlab_url); }, labels() { return this.issuable.labels?.nodes || this.issuable.labels || []; }, + assignees() { + return this.issuable.assignees || []; + }, createdAt() { return sprintf(__('created %{timeAgo}'), { timeAgo: getTimeago().format(this.issuable.createdAt), @@ -53,11 +76,41 @@ export default { timeAgo: getTimeago().format(this.issuable.updatedAt), }); }, + issuableTitleProps() { + if (this.isIssuableUrlExternal) { + return { + target: '_blank', + }; + } + return {}; + }, + showDiscussions() { + return typeof this.issuable.userDiscussionsCount === 'number'; + }, + showIssuableMeta() { + return Boolean( + this.hasSlotContents('status') || this.showDiscussions || this.issuable.assignees, + ); + }, }, methods: { + hasSlotContents(slotName) { + return Boolean(this.$slots[slotName]); + }, scopedLabel(label) { return isScopedLabel(label); }, + labelTitle(label) { + return label.title || label.name; + }, + labelTarget(label) { + if (this.enableLabelPermalinks) { + const key = encodeURIComponent('label_name[]'); + const value = encodeURIComponent(this.labelTitle(label)); + return `?${key}=${value}`; + } + return '#'; + }, /** * This is needed as an independent method since * when user changes current page, `$refs.authorLink` @@ -74,17 +127,28 @@ export default { </script> <template> - <li class="issue"> + <li class="issue gl-px-5!"> <div class="issue-box"> + <div v-if="showCheckbox" class="issue-check"> + <gl-form-checkbox + class="gl-mr-0" + :checked="checked" + @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">{{ issuable.title }}</gl-link> + <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"> - <span data-testid="issuable-reference" class="issuable-reference" + <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"> @@ -96,7 +160,9 @@ export default { >{{ 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" @@ -108,20 +174,52 @@ export default { <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="label.title" + :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!" + > + <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" diff --git a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue index 7535203dea1..b2312c55f01 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -1,17 +1,23 @@ <script> -import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; +import { GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { updateHistory, setUrlParams } from '~/lib/utils/url_utility'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import IssuableTabs from './issuable_tabs.vue'; import IssuableItem from './issuable_item.vue'; +import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue'; + +import { DEFAULT_SKELETON_COUNT } from '../constants'; export default { components: { - GlLoadingIcon, + GlSkeletonLoading, IssuableTabs, FilteredSearchBar, IssuableItem, + IssuableBulkEditSidebar, GlPagination, }, props: { @@ -35,6 +41,11 @@ export default { type: Array, required: true, }, + urlParams: { + type: Object, + required: false, + default: () => ({}), + }, initialFilterValue: { type: Array, required: false, @@ -55,7 +66,8 @@ export default { }, tabCounts: { type: Object, - required: true, + required: false, + default: null, }, currentTab: { type: String, @@ -76,11 +88,21 @@ export default { required: false, default: false, }, + showBulkEditSidebar: { + type: Boolean, + required: false, + default: false, + }, defaultPageSize: { type: Number, required: false, default: 20, }, + totalItems: { + type: Number, + required: false, + default: 0, + }, currentPage: { type: Number, required: false, @@ -96,6 +118,92 @@ export default { required: false, default: 2, }, + enableLabelPermalinks: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + checkedIssuables: {}, + }; + }, + computed: { + skeletonItemCount() { + const { totalItems, defaultPageSize, currentPage } = this; + const totalPages = Math.ceil(totalItems / defaultPageSize); + + if (totalPages) { + return currentPage < totalPages + ? defaultPageSize + : totalItems % defaultPageSize || defaultPageSize; + } + return DEFAULT_SKELETON_COUNT; + }, + allIssuablesChecked() { + return this.bulkEditIssuables.length === this.issuables.length; + }, + /** + * Returns all the checked issuables from `checkedIssuables` map. + */ + bulkEditIssuables() { + return Object.keys(this.checkedIssuables).reduce((acc, issuableId) => { + if (this.checkedIssuables[issuableId].checked) { + acc.push(this.checkedIssuables[issuableId].issuable); + } + return acc; + }, []); + }, + }, + watch: { + issuables(list) { + this.checkedIssuables = list.reduce((acc, issuable) => { + const id = this.issuableId(issuable); + acc[id] = { + // By default, an issuable is not checked, + // But if `checkedIssuables` is already + // populated, use existing value. + checked: + typeof this.checkedIssuables[id] !== 'boolean' + ? false + : this.checkedIssuables[id].checked, + // We're caching issuable reference here + // for ease of populating in `bulkEditIssuables`. + issuable, + }; + return acc; + }, {}); + }, + urlParams: { + deep: true, + immediate: true, + handler(params) { + if (Object.keys(params).length) { + updateHistory({ + url: setUrlParams(params, window.location.href, true), + title: document.title, + replace: true, + }); + } + }, + }, + }, + methods: { + issuableId(issuable) { + return issuable.id || issuable.iid || uniqueId(); + }, + issuableChecked(issuable) { + return this.checkedIssuables[this.issuableId(issuable)]?.checked; + }, + handleIssuableCheckedInput(issuable, value) { + this.checkedIssuables[this.issuableId(issuable)].checked = value; + }, + handleAllIssuablesCheckedInput(value) { + Object.keys(this.checkedIssuables).forEach(issuableId => { + this.checkedIssuables[issuableId].checked = value; + }); + }, }, }; </script> @@ -120,27 +228,60 @@ export default { :sort-options="sortOptions" :initial-filter-value="initialFilterValue" :initial-sort-by="initialSortBy" + :show-checkbox="showBulkEditSidebar" + :checkbox-checked="allIssuablesChecked" class="gl-flex-grow-1 row-content-block" + @checked-input="handleAllIssuablesCheckedInput" @onFilter="$emit('filter', $event)" @onSort="$emit('sort', $event)" /> + <issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar"> + <template #bulk-edit-actions> + <slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot> + </template> + <template #sidebar-items> + <slot name="sidebar-items" :checked-issuables="bulkEditIssuables"></slot> + </template> + </issuable-bulk-edit-sidebar> <div class="issuables-holder"> - <gl-loading-icon v-if="issuablesLoading" size="md" class="gl-mt-5" /> + <ul v-if="issuablesLoading" class="content-list"> + <li v-for="n in skeletonItemCount" :key="n" class="issue gl-px-5! gl-py-5!"> + <gl-skeleton-loading /> + </li> + </ul> <ul v-if="!issuablesLoading && issuables.length" class="content-list issuable-list issues-list" > <issuable-item v-for="issuable in issuables" - :key="issuable.id" + :key="issuableId(issuable)" :issuable-symbol="issuableSymbol" :issuable="issuable" - /> + :enable-label-permalinks="enableLabelPermalinks" + :show-checkbox="showBulkEditSidebar" + :checked="issuableChecked(issuable)" + @checked-input="handleIssuableCheckedInput(issuable, $event)" + > + <template #reference> + <slot name="reference" :issuable="issuable"></slot> + </template> + <template #author> + <slot name="author" :author="issuable.author"></slot> + </template> + <template #timeframe> + <slot name="timeframe" :issuable="issuable"></slot> + </template> + <template #status> + <slot name="status" :issuable="issuable"></slot> + </template> + </issuable-item> </ul> <slot v-if="!issuablesLoading && !issuables.length" name="empty-state"></slot> <gl-pagination v-if="showPaginationControls" :per-page="defaultPageSize" + :total-items="totalItems" :value="currentPage" :prev-page="previousPage" :next-page="nextPage" diff --git a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue index df544ce69e7..d9aab004077 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_tabs.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_tabs.vue @@ -14,7 +14,8 @@ export default { }, tabCounts: { type: Object, - required: true, + required: false, + default: null, }, currentTab: { type: String, @@ -40,7 +41,7 @@ export default { > <template #title> <span :title="tab.titleTooltip">{{ tab.title }}</span> - <gl-badge variant="neutral" size="sm" class="gl-px-2 gl-py-1!">{{ + <gl-badge v-if="tabCounts" variant="neutral" size="sm" class="gl-px-2 gl-py-1!">{{ tabCounts[tab.name] }}</gl-badge> </template> diff --git a/app/assets/javascripts/issuable_list/constants.js b/app/assets/javascripts/issuable_list/constants.js new file mode 100644 index 00000000000..773ad0f8e93 --- /dev/null +++ b/app/assets/javascripts/issuable_list/constants.js @@ -0,0 +1,51 @@ +import { __ } from '~/locale'; + +export const IssuableStates = { + Opened: 'opened', + Closed: 'closed', + All: 'all', +}; + +export const IssuableListTabs = [ + { + id: 'state-opened', + name: IssuableStates.Opened, + title: __('Open'), + titleTooltip: __('Filter by issues that are currently opened.'), + }, + { + id: 'state-closed', + name: IssuableStates.Closed, + title: __('Closed'), + titleTooltip: __('Filter by issues that are currently closed.'), + }, + { + id: 'state-all', + name: IssuableStates.All, + title: __('All'), + titleTooltip: __('Show all issues.'), + }, +]; + +export const AvailableSortOptions = [ + { + id: 1, + title: __('Created date'), + sortDirection: { + descending: 'created_desc', + ascending: 'created_asc', + }, + }, + { + id: 2, + title: __('Last updated'), + sortDirection: { + descending: 'updated_desc', + ascending: 'updated_asc', + }, + }, +]; + +export const DEFAULT_PAGE_SIZE = 20; + +export const DEFAULT_SKELETON_COUNT = 5; diff --git a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue index 7d1339f833d..7cacba1cb65 100644 --- a/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue +++ b/app/assets/javascripts/issuable_sidebar/components/issuable_sidebar_root.vue @@ -29,6 +29,7 @@ export default { }, mounted() { window.addEventListener('resize', this.handleWindowResize); + this.updatePageContainerClass(); }, beforeDestroy() { window.removeEventListener('resize', this.handleWindowResize); diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 0a0cfe918af..f65d9259e7b 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -84,13 +84,7 @@ export default class Issue { projectIssuesCounter.text(addDelimiter(numProjectIssues)); if (this.createMergeRequestDropdown) { - if (isClosed) { - this.createMergeRequestDropdown.unavailable(); - this.createMergeRequestDropdown.disable(); - } else { - // We should check in case a branch was created in another tab - this.createMergeRequestDropdown.checkAbilityToCreateBranch(); - } + this.createMergeRequestDropdown.checkAbilityToCreateBranch(); } } else { flash(issueFailMessage); diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 22db0f1cfc1..61e5db0970a 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -136,6 +136,16 @@ export default { type: String, required: true, }, + isConfidential: { + type: Boolean, + required: false, + default: false, + }, + isLocked: { + type: Boolean, + required: false, + default: false, + }, issuableType: { type: String, required: false, @@ -217,8 +227,8 @@ export default { defaultErrorMessage() { return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType }); }, - isOpenStatus() { - return this.issuableStatus === IssuableStatus.Open; + isClosed() { + return this.issuableStatus === IssuableStatus.Closed; }, pinnedLinkClasses() { return this.showTitleBorder @@ -226,13 +236,13 @@ export default { : ''; }, statusIcon() { - return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close'; + return this.isClosed ? 'mobile-issue-close' : 'issue-open-m'; }, statusText() { return IssuableStatusText[this.issuableStatus]; }, shouldShowStickyHeader() { - return this.isStickyHeaderShowing && this.issuableType === IssuableType.Issue; + return this.issuableType === IssuableType.Issue; }, }, created() { @@ -432,10 +442,14 @@ export default { :show-inline-edit-button="showInlineEditButton" /> - <gl-intersection-observer @appear="hideStickyHeader" @disappear="showStickyHeader"> + <gl-intersection-observer + v-if="shouldShowStickyHeader" + @appear="hideStickyHeader" + @disappear="showStickyHeader" + > <transition name="issuable-header-slide"> <div - v-if="shouldShowStickyHeader" + v-if="isStickyHeaderShowing" class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" data-testid="issue-sticky-header" > @@ -444,11 +458,17 @@ export default { > <p class="issuable-status-box status-box gl-my-0" - :class="[isOpenStatus ? 'status-box-open' : 'status-box-issue-closed']" + :class="[isClosed ? 'status-box-issue-closed' : 'status-box-open']" > <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" /> <span class="gl-display-none d-sm-block">{{ statusText }}</span> </p> + <span v-if="isLocked" data-testid="locked" class="issuable-warning-icon"> + <gl-icon name="lock" :aria-label="__('Locked')" /> + </span> + <span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon"> + <gl-icon name="eye-slash" :aria-label="__('Confidential')" /> + </span> <p class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" :title="state.titleText" diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue new file mode 100644 index 00000000000..4c8c86390f4 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/header_actions.vue @@ -0,0 +1,281 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { IssuableType } from '~/issuable_show/constants'; +import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; +import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql'; +import updateIssueMutation from '../queries/update_issue.mutation.graphql'; + +export default { + components: { + GlButton, + GlDropdown, + GlDropdownItem, + GlIcon, + GlLink, + GlModal, + }, + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: __('Yes, close issue'), + attributes: [{ variant: 'warning' }], + }, + i18n: { + promoteErrorMessage: __( + 'Something went wrong while promoting the issue to an epic. Please try again.', + ), + promoteSuccessMessage: __( + 'The issue was successfully promoted to an epic. Redirecting to epic...', + ), + }, + inject: { + canCreateIssue: { + default: false, + }, + canPromoteToEpic: { + default: false, + }, + canReopenIssue: { + default: false, + }, + canReportSpam: { + default: false, + }, + canUpdateIssue: { + default: false, + }, + iid: { + default: '', + }, + isIssueAuthor: { + default: false, + }, + issueType: { + default: IssuableType.Issue, + }, + newIssuePath: { + default: '', + }, + projectPath: { + default: '', + }, + reportAbusePath: { + default: '', + }, + submitAsSpamPath: { + default: '', + }, + }, + data() { + return { + isUpdatingState: false, + }; + }, + computed: { + ...mapGetters(['getNoteableData']), + isClosed() { + return this.getNoteableData.state === IssuableStatus.Closed; + }, + buttonText() { + return this.isClosed + ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueType }) + : sprintf(__('Close %{issueType}'), { issueType: this.issueType }); + }, + qaSelector() { + return this.isClosed ? 'reopen_issue_button' : 'close_issue_button'; + }, + buttonVariant() { + return this.isClosed ? 'default' : 'warning'; + }, + dropdownText() { + return sprintf(__('%{issueType} actions'), { + issueType: capitalizeFirstCharacter(this.issueType), + }); + }, + newIssueTypeText() { + return sprintf(__('New %{issueType}'), { issueType: this.issueType }); + }, + showToggleIssueStateButton() { + const canClose = !this.isClosed && this.canUpdateIssue; + const canReopen = this.isClosed && this.canReopenIssue; + return canClose || canReopen; + }, + }, + methods: { + toggleIssueState() { + if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) { + this.$refs.blockedByIssuesModal.show(); + return; + } + + this.invokeUpdateIssueMutation(); + }, + invokeUpdateIssueMutation() { + this.isUpdatingState = true; + + this.$apollo + .mutate({ + mutation: updateIssueMutation, + variables: { + input: { + iid: this.iid.toString(), + projectPath: this.projectPath, + stateEvent: this.isClosed ? IssueStateEvent.Reopen : IssueStateEvent.Close, + }, + }, + }) + .then(({ data }) => { + if (data.updateIssue.errors.length) { + createFlash({ message: data.updateIssue.errors.join('. ') }); + return; + } + + const payload = { + detail: { + data: { id: this.iid }, + isClosed: !this.isClosed, + }, + }; + + // Dispatch event which updates open/close state, shared among the issue show page + document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload)); + }) + .catch(() => createFlash({ message: __('Update failed. Please try again.') })) + .finally(() => { + this.isUpdatingState = false; + }); + }, + promoteToEpic() { + this.isUpdatingState = true; + + this.$apollo + .mutate({ + mutation: promoteToEpicMutation, + variables: { + input: { + iid: this.iid, + projectPath: this.projectPath, + }, + }, + }) + .then(({ data }) => { + if (data.promoteToEpic.errors.length) { + createFlash({ message: data.promoteToEpic.errors.join('; ') }); + return; + } + + createFlash({ + message: this.$options.i18n.promoteSuccessMessage, + type: FLASH_TYPES.SUCCESS, + }); + + visitUrl(data.promoteToEpic.epic.webPath); + }) + .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage })) + .finally(() => { + this.isUpdatingState = false; + }); + }, + }, +}; +</script> + +<template> + <div class="detail-page-header-actions"> + <gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="dropdownText"> + <gl-dropdown-item + v-if="showToggleIssueStateButton" + :disabled="isUpdatingState" + @click="toggleIssueState" + > + {{ buttonText }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> + {{ newIssueTypeText }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canPromoteToEpic" :disabled="isUpdatingState" @click="promoteToEpic"> + {{ __('Promote to epic') }} + </gl-dropdown-item> + <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> + {{ __('Report abuse') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canReportSpam" + :href="submitAsSpamPath" + data-method="post" + rel="nofollow" + > + {{ __('Submit as spam') }} + </gl-dropdown-item> + </gl-dropdown> + + <gl-button + v-if="showToggleIssueStateButton" + class="gl-display-none gl-display-sm-inline-flex!" + category="secondary" + :data-qa-selector="qaSelector" + :loading="isUpdatingState" + :variant="buttonVariant" + @click="toggleIssueState" + > + {{ buttonText }} + </gl-button> + + <gl-dropdown + class="gl-display-none gl-display-sm-inline-flex!" + toggle-class="gl-border-0! gl-shadow-none!" + no-caret + right + > + <template #button-content> + <gl-icon name="ellipsis_v" aria-hidden="true" /> + <span class="gl-sr-only">{{ dropdownText }}</span> + </template> + + <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> + {{ newIssueTypeText }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canPromoteToEpic" + :disabled="isUpdatingState" + data-testid="promote-button" + @click="promoteToEpic" + > + {{ __('Promote to epic') }} + </gl-dropdown-item> + <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> + {{ __('Report abuse') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canReportSpam" + :href="submitAsSpamPath" + data-method="post" + rel="nofollow" + > + {{ __('Submit as spam') }} + </gl-dropdown-item> + </gl-dropdown> + + <gl-modal + ref="blockedByIssuesModal" + modal-id="blocked-by-issues-modal" + :action-cancel="$options.actionCancel" + :action-primary="$options.actionPrimary" + :title="__('Are you sure you want to close this blocked issue?')" + @primary="invokeUpdateIssueMutation" + > + <p>{{ __('This issue is currently blocked by the following issues:') }}</p> + <ul> + <li v-for="issue in getNoteableData.blocked_by_issues" :key="issue.iid"> + <gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link> + </li> + </ul> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/constants.js b/app/assets/javascripts/issue_show/constants.js index 6bc6ed2b372..a5ca91dffd4 100644 --- a/app/assets/javascripts/issue_show/constants.js +++ b/app/assets/javascripts/issue_show/constants.js @@ -1,13 +1,15 @@ import { __ } from '~/locale'; export const IssuableStatus = { - Open: 'opened', Closed: 'closed', + Open: 'opened', + Reopened: 'reopened', }; export const IssuableStatusText = { - [IssuableStatus.Open]: __('Open'), [IssuableStatus.Closed]: __('Closed'), + [IssuableStatus.Open]: __('Open'), + [IssuableStatus.Reopened]: __('Open'), }; export const IssuableType = { @@ -16,5 +18,10 @@ export const IssuableType = { MergeRequest: 'merge_request', }; +export const IssueStateEvent = { + Close: 'CLOSE', + Reopen: 'REOPEN', +}; + export const STATUS_PAGE_PUBLISHED = __('Published on status page'); export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); diff --git a/app/assets/javascripts/issue_show/issue.js b/app/assets/javascripts/issue_show/issue.js index f9f61d5aa64..8260460828b 100644 --- a/app/assets/javascripts/issue_show/issue.js +++ b/app/assets/javascripts/issue_show/issue.js @@ -1,16 +1,62 @@ import Vue from 'vue'; -import issuableApp from './components/app.vue'; +import VueApollo from 'vue-apollo'; +import { mapGetters } from 'vuex'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import IssuableApp from './components/app.vue'; +import HeaderActions from './components/header_actions.vue'; -export default function initIssuableApp(issuableData) { +export function initIssuableApp(issuableData, store) { return new Vue({ el: document.getElementById('js-issuable-app'), - components: { - issuableApp, + store, + computed: { + ...mapGetters(['getNoteableData']), }, render(createElement) { - return createElement('issuable-app', { - props: issuableData, + return createElement(IssuableApp, { + props: { + ...issuableData, + isConfidential: this.getNoteableData?.confidential, + isLocked: this.getNoteableData?.discussion_locked, + issuableStatus: this.getNoteableData?.state, + }, }); }, }); } + +export function initIssueHeaderActions(store) { + const el = document.querySelector('.js-issue-header-actions'); + + if (!el) { + return undefined; + } + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + store, + provide: { + canCreateIssue: parseBoolean(el.dataset.canCreateIssue), + canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), + canReopenIssue: parseBoolean(el.dataset.canReopenIssue), + canReportSpam: parseBoolean(el.dataset.canReportSpam), + canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), + iid: el.dataset.iid, + isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), + issueType: el.dataset.issueType, + newIssuePath: el.dataset.newIssuePath, + projectPath: el.dataset.projectPath, + reportAbusePath: el.dataset.reportAbusePath, + submitAsSpamPath: el.dataset.submitAsSpamPath, + }, + render: createElement => createElement(HeaderActions), + }); +} diff --git a/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql b/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql new file mode 100644 index 00000000000..12d05af0f5e --- /dev/null +++ b/app/assets/javascripts/issue_show/queries/promote_to_epic.mutation.graphql @@ -0,0 +1,8 @@ +mutation promoteToEpic($input: PromoteToEpicInput!) { + promoteToEpic(input: $input) { + epic { + webPath + } + errors + } +} diff --git a/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql new file mode 100644 index 00000000000..9c28fdded21 --- /dev/null +++ b/app/assets/javascripts/issue_show/queries/update_issue.mutation.graphql @@ -0,0 +1,5 @@ +mutation updateIssue($input: UpdateIssueInput!) { + updateIssue(input: $input) { + errors + } +} diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue index dc63d613b5b..b12b20d0135 100644 --- a/app/assets/javascripts/issues_list/components/issuable.vue +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -28,7 +28,6 @@ import initUserPopovers from '~/user_popovers'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; import { isScopedLabel } from '~/lib/utils/common_utils'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { convertToCamelCase } from '~/lib/utils/text_utility'; @@ -37,6 +36,7 @@ export default { openedAgo: __('opened %{timeAgoString} by %{user}'), openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'), }, + inject: ['scopedLabelsAvailable'], components: { IssueAssignees, GlLink, @@ -50,7 +50,6 @@ export default { GlTooltip, SafeHtml, }, - mixins: [glFeatureFlagsMixin()], props: { issuable: { type: Object, @@ -85,9 +84,6 @@ export default { return this.issuableLink({ milestone_title: title }); }, - scopedLabelsAvailable() { - return this.glFeatures.scopedLabels; - }, hasWeight() { return isNumber(this.issuable.weight); }, diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 1ff41c20d08..5ef86536865 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -41,10 +41,13 @@ function mountIssuablesListApp() { } document.querySelectorAll('.js-issuables-list').forEach(el => { - const { canBulkEdit, emptyStateMeta = {}, ...data } = el.dataset; + const { canBulkEdit, emptyStateMeta = {}, scopedLabelsAvailable, ...data } = el.dataset; return new Vue({ el, + provide: { + scopedLabelsAvailable: parseBoolean(scopedLabelsAvailable), + }, render(createElement) { return createElement(IssuablesListApp, { props: { diff --git a/app/assets/javascripts/jira_connect/.eslintrc.yml b/app/assets/javascripts/jira_connect/.eslintrc.yml new file mode 100644 index 00000000000..053f8c6b285 --- /dev/null +++ b/app/assets/javascripts/jira_connect/.eslintrc.yml @@ -0,0 +1,5 @@ +globals: + AP: readonly +rules: + '@gitlab/require-i18n-strings': off + '@gitlab/vue-require-i18n-strings': off diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue new file mode 100644 index 00000000000..6d32ba41eae --- /dev/null +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -0,0 +1,7 @@ +<script> +export default {}; +</script> + +<template> + <div></div> +</template> diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js new file mode 100644 index 00000000000..37f00d56a05 --- /dev/null +++ b/app/assets/javascripts/jira_connect/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import App from './components/app.vue'; + +function initJiraConnect() { + const el = document.querySelector('.js-jira-connect-app'); + + return new Vue({ + el, + render(createElement) { + return createElement(App, {}); + }, + }); +} + +document.addEventListener('DOMContentLoaded', initJiraConnect); diff --git a/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue new file mode 100644 index 00000000000..5ce9d08035d --- /dev/null +++ b/app/assets/javascripts/jobs/components/job_retry_forward_deployment_modal.vue @@ -0,0 +1,66 @@ +<script> +import { GlLink, GlModal } from '@gitlab/ui'; +import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '../constants'; + +export default { + name: 'JobRetryForwardDeploymentModal', + components: { + GlLink, + GlModal, + }, + i18n: { + ...JOB_RETRY_FORWARD_DEPLOYMENT_MODAL, + }, + props: { + modalId: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + }, + inject: { + retryOutdatedJobDocsUrl: { + default: '', + }, + }, + data() { + return { + primaryProps: { + text: this.$options.i18n.primaryText, + attributes: [ + { + 'data-method': 'post', + 'data-testid': 'retry-button-modal', + href: this.href, + variant: 'danger', + }, + ], + }, + cancelProps: { + text: this.$options.i18n.cancel, + attributes: [{ category: 'secondary', variant: 'default' }], + }, + }; + }, +}; +</script> + +<template> + <gl-modal + :action-cancel="cancelProps" + :action-primary="primaryProps" + :modal-id="modalId" + :title="$options.i18n.title" + > + <p> + {{ $options.i18n.info }} + <gl-link v-if="retryOutdatedJobDocsUrl" :href="retryOutdatedJobDocsUrl" target="_blank"> + {{ $options.i18n.moreInfo }} + </gl-link> + </p> + <p>{{ $options.i18n.areYouSure }}</p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue new file mode 100644 index 00000000000..258b8cadd63 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue @@ -0,0 +1,45 @@ +<script> +import { GlButton, GlLink, GlModalDirective } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; +import { JOB_SIDEBAR } from '../constants'; + +export default { + name: 'JobSidebarRetryButton', + i18n: { + retryLabel: JOB_SIDEBAR.retry, + }, + components: { + GlButton, + GlLink, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + modalId: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters(['hasForwardDeploymentFailure']), + }, +}; +</script> +<template> + <gl-button + v-if="hasForwardDeploymentFailure" + v-gl-modal="modalId" + :aria-label="$options.i18n.retryLabel" + category="primary" + variant="info" + >{{ $options.i18n.retryLabel }}</gl-button + > + <gl-link v-else :href="href" data-method="post" rel="nofollow" + >{{ $options.i18n.retryLabel }} + </gl-link> +</template> diff --git a/app/assets/javascripts/jobs/components/jobs_container.vue b/app/assets/javascripts/jobs/components/jobs_container.vue index 951bcb36600..df64b6422c7 100644 --- a/app/assets/javascripts/jobs/components/jobs_container.vue +++ b/app/assets/javascripts/jobs/components/jobs_container.vue @@ -24,7 +24,7 @@ export default { }; </script> <template> - <div class="js-jobs-container builds-container"> + <div class="builds-container"> <job-container-item v-for="job in jobs" :key="job.id" diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue index e68d5b8eda4..affaddcdee2 100644 --- a/app/assets/javascripts/jobs/components/log/line.vue +++ b/app/assets/javascripts/jobs/components/log/line.vue @@ -1,4 +1,6 @@ <script> +import { linkRegex } from '../../utils'; + import LineNumber from './line_number.vue'; export default { @@ -16,15 +18,46 @@ export default { render(h, { props }) { const { line, path } = props; - const chars = line.content.map(content => { - return h( - 'span', - { - class: ['gl-white-space-pre-wrap', content.style], - }, - content.text, - ); - }); + let chars; + if (gon?.features?.ciJobLineLinks) { + chars = line.content.map(content => { + return h( + 'span', + { + class: ['gl-white-space-pre-wrap', content.style], + }, + // Simple "tokenization": Split text in chunks of text + // which alternate between text and urls. + content.text.split(linkRegex).map(chunk => { + // Return normal string for non-links + if (!chunk.match(linkRegex)) { + return chunk; + } + return h( + 'a', + { + attrs: { + href: chunk, + class: 'gl-reset-color! gl-text-decoration-underline', + rel: 'nofollow noopener noreferrer', // eslint-disable-line @gitlab/require-i18n-strings + }, + }, + chunk, + ); + }), + ); + }); + } else { + chars = line.content.map(content => { + return h( + 'span', + { + class: ['gl-white-space-pre-wrap', content.style], + }, + content.text, + ); + }); + } return h('div', { class: 'js-line log-line' }, [ h(LineNumber, { diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 8701e05a01f..0789bb54f0f 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -1,33 +1,40 @@ <script> import { isEmpty } from 'lodash'; -import { mapActions, mapState } from 'vuex'; -import { GlLink, GlButton, GlIcon } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; -import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlButton, GlIcon, GlLink } from '@gitlab/ui'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; -import DetailRow from './sidebar_detail_row.vue'; import ArtifactsBlock from './artifacts_block.vue'; +import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; +import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; import TriggerBlock from './trigger_block.vue'; import CommitBlock from './commit_block.vue'; import StagesDropdown from './stages_dropdown.vue'; import JobsContainer from './jobs_container.vue'; +import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; +import { JOB_SIDEBAR } from '../constants'; + +export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; export default { name: 'JobSidebar', + i18n: { + ...JOB_SIDEBAR, + }, + forwardDeploymentFailureModalId, components: { ArtifactsBlock, CommitBlock, - DetailRow, + GlButton, + GlLink, GlIcon, - TriggerBlock, - StagesDropdown, JobsContainer, - GlLink, - GlButton, + JobSidebarRetryButton, + JobRetryForwardDeploymentModal, + JobSidebarDetailsContainer, + StagesDropdown, TooltipOnTruncate, + TriggerBlock, }, - mixins: [timeagoMixin], props: { artifactHelpUrl: { type: String, @@ -41,54 +48,14 @@ export default { }, }, computed: { + ...mapGetters(['hasForwardDeploymentFailure']), ...mapState(['job', 'stages', 'jobs', 'selectedStage']), - coverage() { - return `${this.job.coverage}%`; - }, - duration() { - return timeIntervalInWords(this.job.duration); - }, - queued() { - return timeIntervalInWords(this.job.queued); - }, - runnerId() { - return `${this.job.runner.description} (#${this.job.runner.id})`; - }, retryButtonClass() { - let className = 'js-retry-button btn btn-retry'; + let className = 'btn btn-retry'; className += this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary'; return className; }, - hasTimeout() { - return this.job.metadata != null && this.job.metadata.timeout_human_readable !== null; - }, - timeout() { - if (this.job.metadata == null) { - return ''; - } - - let t = this.job.metadata.timeout_human_readable; - if (this.job.metadata.timeout_source !== '') { - t += sprintf(__(` (from %{timeoutSource})`), { - timeoutSource: this.job.metadata.timeout_source, - }); - } - - return t; - }, - renderBlock() { - return ( - this.job.duration || - this.job.finished_at || - this.job.erased_at || - this.job.queued || - this.hasTimeout || - this.job.runner || - this.job.coverage || - this.job.tags.length - ); - }, hasArtifact() { return !isEmpty(this.job.artifact); }, @@ -96,16 +63,13 @@ export default { return !isEmpty(this.job.trigger); }, hasStages() { - return ( - (this.job && - this.job.pipeline && - this.job.pipeline.stages && - this.job.pipeline.stages.length > 0) || - false - ); + return this.job?.pipeline?.stages?.length > 0; }, commit() { - return this.job.pipeline && this.job.pipeline.commit ? this.job.pipeline.commit : {}; + return this.job?.pipeline?.commit || {}; + }, + shouldShowJobRetryForwardDeploymentModal() { + return this.job.retry_path && this.hasForwardDeploymentFailure; }, }, methods: { @@ -124,29 +88,29 @@ export default { </h4> </tooltip-on-truncate> <div class="flex-grow-1 flex-shrink-0 text-right"> - <gl-link + <job-sidebar-retry-button v-if="job.retry_path" :class="retryButtonClass" :href="job.retry_path" - data-method="post" + :modal-id="$options.forwardDeploymentFailureModalId" data-qa-selector="retry_button" - rel="nofollow" - >{{ __('Retry') }}</gl-link - > + data-testid="retry-button" + /> <gl-link v-if="job.cancel_path" :href="job.cancel_path" - class="js-cancel-job btn btn-default" + class="btn btn-default" data-method="post" + data-testid="cancel-button" rel="nofollow" - >{{ __('Cancel') }}</gl-link - > + >{{ $options.i18n.cancel }} + </gl-link> </div> <gl-button - :aria-label="__('Toggle Sidebar')" - class="d-md-none gl-ml-2 js-sidebar-build-toggle" + :aria-label="$options.i18n.toggleSidebar" category="tertiary" + class="gl-display-md-none gl-ml-2 js-sidebar-build-toggle" icon="chevron-double-lg-right" @click="toggleSidebar" /> @@ -158,77 +122,43 @@ export default { :href="job.new_issue_path" class="btn btn-success btn-inverted float-left mr-2" data-testid="job-new-issue" - >{{ __('New issue') }}</gl-link - > + >{{ $options.i18n.newIssue }} + </gl-link> <gl-link v-if="job.terminal_path" :href="job.terminal_path" - class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left" + class="btn btn-primary btn-inverted visible-md-block visible-lg-block float-left" target="_blank" + data-testid="terminal-link" > - {{ __('Debug') }} <gl-icon name="external-link" :size="14" /> + {{ $options.i18n.debug }} + <gl-icon :size="14" name="external-link" /> </gl-link> </div> - - <div v-if="renderBlock" class="block"> - <detail-row - v-if="job.duration" - :value="duration" - class="js-job-duration" - title="Duration" - /> - <detail-row - v-if="job.finished_at" - :value="timeFormatted(job.finished_at)" - class="js-job-finished" - title="Finished" - /> - <detail-row - v-if="job.erased_at" - :value="timeFormatted(job.erased_at)" - class="js-job-erased" - title="Erased" - /> - <detail-row v-if="job.queued" :value="queued" class="js-job-queued" title="Queued" /> - <detail-row - v-if="hasTimeout" - :help-url="runnerHelpUrl" - :value="timeout" - class="js-job-timeout" - title="Timeout" - /> - <detail-row v-if="job.runner" :value="runnerId" class="js-job-runner" title="Runner" /> - <detail-row - v-if="job.coverage" - :value="coverage" - class="js-job-coverage" - title="Coverage" - /> - <p v-if="job.tags.length" class="build-detail-row js-job-tags"> - <span class="font-weight-bold">{{ __('Tags:') }}</span> - <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{ - tag - }}</span> - </p> - </div> - + <job-sidebar-details-container :runner-help-url="runnerHelpUrl" /> <artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" /> <trigger-block v-if="hasTriggers" :trigger="job.trigger" /> <commit-block - :is-last-block="hasStages" :commit="commit" + :is-last-block="hasStages" :merge-request="job.merge_request" /> <stages-dropdown - :stages="stages" + v-if="job.pipeline" :pipeline="job.pipeline" :selected-stage="selectedStage" + :stages="stages" @requestSidebarStageDropdown="fetchJobsForStage" /> </div> - <jobs-container v-if="jobs.length" :jobs="jobs" :job-id="job.id" /> + <jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" /> </div> + <job-retry-forward-deployment-modal + v-if="shouldShowJobRetryForwardDeploymentModal" + :modal-id="$options.forwardDeploymentFailureModalId" + :href="job.retry_path" + /> </aside> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue new file mode 100644 index 00000000000..8ad1008278e --- /dev/null +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -0,0 +1,102 @@ +<script> +import { mapState } from 'vuex'; +import DetailRow from './sidebar_detail_row.vue'; +import { __, sprintf } from '~/locale'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; + +export default { + name: 'JobSidebarDetailsContainer', + components: { + DetailRow, + }, + mixins: [timeagoMixin], + props: { + runnerHelpUrl: { + type: String, + required: false, + default: '', + }, + }, + computed: { + ...mapState(['job']), + coverage() { + return `${this.job.coverage}%`; + }, + duration() { + return timeIntervalInWords(this.job.duration); + }, + erasedAt() { + return this.timeFormatted(this.job.erased_at); + }, + finishedAt() { + return this.timeFormatted(this.job.finished_at); + }, + hasTags() { + return this.job?.tags?.length; + }, + hasTimeout() { + return this.job?.metadata?.timeout_human_readable ?? false; + }, + hasAnyDetail() { + return Boolean( + this.job.duration || + this.job.finished_at || + this.job.erased_at || + this.job.queued || + this.job.runner || + this.job.coverage, + ); + }, + queued() { + return timeIntervalInWords(this.job.queued); + }, + runnerId() { + return `${this.job.runner.description} (#${this.job.runner.id})`; + }, + shouldRenderBlock() { + return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags); + }, + timeout() { + return `${this.job?.metadata?.timeout_human_readable}${this.timeoutSource}`; + }, + timeoutSource() { + if (!this.job?.metadata?.timeout_source) { + return ''; + } + + return sprintf(__(` (from %{timeoutSource})`), { + timeoutSource: this.job.metadata.timeout_source, + }); + }, + }, +}; +</script> + +<template> + <div v-if="shouldRenderBlock" class="block"> + <detail-row v-if="job.duration" :value="duration" title="Duration" /> + <detail-row + v-if="job.finished_at" + :value="finishedAt" + data-testid="job-finished" + title="Finished" + /> + <detail-row v-if="job.erased_at" :value="erasedAt" title="Erased" /> + <detail-row v-if="job.queued" :value="queued" title="Queued" /> + <detail-row + v-if="hasTimeout" + :help-url="runnerHelpUrl" + :value="timeout" + data-testid="job-timeout" + title="Timeout" + /> + <detail-row v-if="job.runner" :value="runnerId" title="Runner" /> + <detail-row v-if="job.coverage" :value="coverage" title="Coverage" /> + + <p v-if="hasTags" class="build-detail-row" data-testid="job-tags"> + <span class="font-weight-bold">{{ __('Tags:') }}</span> + <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{ tag }}</span> + </p> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 116331d9549..aeae9f26ed3 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -1,11 +1,13 @@ <script> import { isEmpty } from 'lodash'; -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; export default { components: { CiIcon, + GlDropdown, + GlDropdownItem, GlLink, }, props: { @@ -78,20 +80,15 @@ export default { </template> </div> - <button - type="button" - data-toggle="dropdown" - class="js-selected-stage dropdown-menu-toggle gl-mt-3" - > - {{ selectedStage }} <i class="fa fa-chevron-down"></i> - </button> - - <ul class="dropdown-menu"> - <li v-for="stage in stages" :key="stage.name"> - <button type="button" class="js-stage-item stage-item" @click="onStageClick(stage)"> - {{ stage.name }} - </button> - </li> - </ul> + <gl-dropdown :text="selectedStage" class="js-selected-stage gl-w-full gl-mt-3"> + <gl-dropdown-item + v-for="stage in stages" + :key="stage.name" + class="js-stage-item stage-item" + @click="onStageClick(stage)" + > + {{ stage.name }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/jobs/constants.js b/app/assets/javascripts/jobs/constants.js new file mode 100644 index 00000000000..d0d625d794d --- /dev/null +++ b/app/assets/javascripts/jobs/constants.js @@ -0,0 +1,24 @@ +import { __, s__ } from '~/locale'; + +const cancel = __('Cancel'); +const moreInfo = __('More information'); + +export const JOB_SIDEBAR = { + cancel, + debug: __('Debug'), + newIssue: __('New issue'), + retry: __('Retry'), + toggleSidebar: __('Toggle Sidebar'), +}; + +export const JOB_RETRY_FORWARD_DEPLOYMENT_MODAL = { + cancel, + info: s__( + `Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. + Retrying this job could result in overwriting the environment with the older source code.`, + ), + areYouSure: s__('Jobs|Are you sure you want to proceed?'), + moreInfo, + primaryText: __('Retry job'), + title: s__('Jobs|Are you sure you want to retry this job?'), +}; diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index 6e15360b66c..1ad6292a030 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -10,27 +10,31 @@ export default () => { // Let's start initializing the store (i.e. fetching data) right away store.dispatch('init', element.dataset); + const { + artifactHelpUrl, + deploymentHelpUrl, + runnerHelpUrl, + runnerSettingsUrl, + variablesSettingsUrl, + subscriptionsMoreMinutesUrl, + endpoint, + pagePath, + logState, + buildStatus, + projectPath, + retryOutdatedJobDocsUrl, + } = element.dataset; + return new Vue({ el: element, store, components: { JobApp, }, + provide: { + retryOutdatedJobDocsUrl, + }, render(createElement) { - const { - artifactHelpUrl, - deploymentHelpUrl, - runnerHelpUrl, - runnerSettingsUrl, - variablesSettingsUrl, - subscriptionsMoreMinutesUrl, - endpoint, - pagePath, - logState, - buildStatus, - projectPath, - } = element.dataset; - return createElement('job-app', { props: { artifactHelpUrl, diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index dc4a3578a86..8c2d1dd8ab2 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -3,8 +3,11 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at); +export const hasForwardDeploymentFailure = state => + state?.job?.failure_reason === 'forward_deployment_failure'; + export const hasUnmetPrerequisitesFailure = state => - state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites'; + state?.job?.failure_reason === 'unmet_prerequisites'; export const shouldRenderCalloutMessage = state => !isEmpty(state.job.status) && !isEmpty(state.job.callout_message); diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js new file mode 100644 index 00000000000..28a125b2b8f --- /dev/null +++ b/app/assets/javascripts/jobs/utils.js @@ -0,0 +1,4 @@ +// capture anything starting with http:// or https:// +// up until a disallowed character or whitespace +export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+)/g; +export default { linkRegex }; diff --git a/app/assets/javascripts/lib/ace.js b/app/assets/javascripts/lib/ace.js deleted file mode 100644 index e90b3d2eec7..00000000000 --- a/app/assets/javascripts/lib/ace.js +++ /dev/null @@ -1,4 +0,0 @@ -/*= require ace/ace */ -/*= require ace/ext-modelist */ -/*= require ace/ext-searchbox */ -/*= require ./ace/ace_config_paths */ diff --git a/app/assets/javascripts/lib/ace/ace_config_paths.js.erb b/app/assets/javascripts/lib/ace/ace_config_paths.js.erb deleted file mode 100644 index 976769ba84a..00000000000 --- a/app/assets/javascripts/lib/ace/ace_config_paths.js.erb +++ /dev/null @@ -1,34 +0,0 @@ -<% -ace_gem_path = Bundler.rubygems.find_name('ace-rails-ap').first.full_gem_path -ace_workers = Dir[ace_gem_path + '/vendor/assets/javascripts/ace/worker-*.js'].sort.map do |file| - File.basename(file, '.js').sub(/^worker-/, '') -end -ace_modes = Dir[ace_gem_path + '/vendor/assets/javascripts/ace/mode-*.js'].sort.map do |file| - File.basename(file, '.js').sub(/^mode-/, '') -end -%> -// Lazy-load configuration when ace.edit is called -(function() { - var basePath; - var ace = window.ace; - var edit = ace.edit; - ace.edit = function() { - window.gon = window.gon || {}; - basePath = (window.gon.relative_url_root || '').replace(/\/$/, '') + '/assets/ace'; - ace.config.set('basePath', basePath); - - // configure paths for all worker modules -<% ace_workers.each do |worker| %> - ace.config.setModuleUrl('ace/mode/<%= worker %>_worker', basePath + '/<%= File.basename(asset_path("ace/worker-#{worker}.js")) %>'); -<% end %> - - // configure paths for all mode modules -<% ace_modes.each do |mode| %> - ace.config.setModuleUrl('ace/mode/<%= mode %>', basePath + '/<%= File.basename(asset_path("ace/mode-#{mode}.js")) %>'); -<% end %> - - // restore original method - ace.edit = edit; - return ace.edit.apply(ace, arguments); - }; -})(); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 0e07f7d8e44..e0d9a903e0a 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -5,6 +5,7 @@ import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; import csrf from '~/lib/utils/csrf'; import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; +import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; export const fetchPolicies = { CACHE_FIRST: 'cache-first', @@ -62,7 +63,7 @@ export default (resolvers = {}, config = {}) => { return new ApolloClient({ typeDefs: config.typeDefs, - link: ApolloLink.from([performanceBarLink, uploadsLink]), + link: ApolloLink.from([performanceBarLink, new StartupJSLink(), uploadsLink]), cache: new InMemoryCache({ ...config.cacheConfig, freezeResults: config.assumeImmutableResults, diff --git a/app/assets/javascripts/lib/utils/ace_utils.js b/app/assets/javascripts/lib/utils/ace_utils.js deleted file mode 100644 index ee71ae0e61a..00000000000 --- a/app/assets/javascripts/lib/utils/ace_utils.js +++ /dev/null @@ -1,6 +0,0 @@ -/* global ace */ - -export default function getModeByFileExtension(path) { - const modelist = ace.require('ace/ext/modelist'); - return modelist.getModeForPath(path).mode; -} diff --git a/app/assets/javascripts/lib/utils/apollo_startup_js_link.js b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js new file mode 100644 index 00000000000..5c120dd532f --- /dev/null +++ b/app/assets/javascripts/lib/utils/apollo_startup_js_link.js @@ -0,0 +1,106 @@ +import { ApolloLink, Observable } from 'apollo-link'; +import { parse } from 'graphql'; +import { isEqual, pickBy } from 'lodash'; + +/** + * Remove undefined values from object + * @param obj + * @returns {Dictionary<unknown>} + */ +const pickDefinedValues = obj => pickBy(obj, x => x !== undefined); + +/** + * Compares two set of variables, order independent + * + * Ignores undefined values (in the top level) and supports arrays etc. + */ +const variablesMatch = (var1 = {}, var2 = {}) => { + return isEqual(pickDefinedValues(var1), pickDefinedValues(var2)); +}; + +export class StartupJSLink extends ApolloLink { + constructor() { + super(); + this.startupCalls = new Map(); + this.parseStartupCalls(window.gl?.startup_graphql_calls || []); + } + + // Extract operationNames from the queries and ensure that we can + // match operationName => element from result array + parseStartupCalls(calls) { + calls.forEach(call => { + const { query, variables, fetchCall } = call; + const operationName = parse(query)?.definitions?.find(x => x.kind === 'OperationDefinition') + ?.name?.value; + + if (operationName) { + this.startupCalls.set(operationName, { + variables, + fetchCall, + }); + } + }); + } + + static noopRequest = (operation, forward) => forward(operation); + + disable() { + this.request = StartupJSLink.noopRequest; + this.startupCalls = null; + } + + request(operation, forward) { + // Disable StartupJSLink in case all calls are done or none are set up + if (this.startupCalls && this.startupCalls.size === 0) { + this.disable(); + return forward(operation); + } + + const { operationName } = operation; + + // Skip startup call if the operationName doesn't match + if (!this.startupCalls.has(operationName)) { + return forward(operation); + } + + const { variables: startupVariables, fetchCall } = this.startupCalls.get(operationName); + this.startupCalls.delete(operationName); + + // Skip startup call if the variables values do not match + if (!variablesMatch(startupVariables, operation.variables)) { + return forward(operation); + } + + return new Observable(observer => { + fetchCall + .then(response => { + // Handle HTTP errors + if (!response.ok) { + throw new Error('fetchCall failed'); + } + operation.setContext({ response }); + return response.json(); + }) + .then(result => { + if (result && (result.errors || !result.data)) { + throw new Error('Received GraphQL error'); + } + + // we have data and can send it to back up the link chain + observer.next(result); + observer.complete(); + }) + .catch(() => { + forward(operation).subscribe({ + next: result => { + observer.next(result); + }, + error: error => { + observer.error(error); + }, + complete: observer.complete.bind(observer), + }); + }); + }); + } +} diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index fe1ac00fd1d..42a5de68cfa 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -61,9 +61,6 @@ export const rstrip = val => { return val; }; -export const updateTooltipTitle = ($tooltipEl, newTitle) => - $tooltipEl.attr('title', newTitle).tooltip('_fixTitle'); - export const disableButtonIfEmptyField = (fieldSelector, buttonSelector, eventName = 'input') => { const field = $(fieldSelector); const closestSubmit = field.closest('form').find(buttonSelector); @@ -744,6 +741,24 @@ export const roundOffFloat = (number, precision = 0) => { }; /** + * Method to round down values with decimal places + * with provided precision. + * + * Eg; roundDownFloat(3.141592, 3) = 3.141 + * + * Refer to spec/javascripts/lib/utils/common_utils_spec.js for + * more supported examples. + * + * @param {Float} number + * @param {Number} precision + */ +export const roundDownFloat = (number, precision = 0) => { + // eslint-disable-next-line no-restricted-properties + const multiplier = Math.pow(10, precision); + return Math.floor(number * multiplier) / multiplier; +}; + +/** * Represents navigation type constants of the Performance Navigation API. * Detailed explanation see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceNavigation. */ diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index 1a4ecc12f01..993d51370ec 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -1,5 +1,4 @@ export const BYTES_IN_KIB = 1024; -export const BYTES_IN_KB = 1000; export const HIDDEN_CLASS = 'hidden'; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; diff --git a/app/assets/javascripts/lib/utils/css_utils.js b/app/assets/javascripts/lib/utils/css_utils.js index 90213221443..02f092e73e1 100644 --- a/app/assets/javascripts/lib/utils/css_utils.js +++ b/app/assets/javascripts/lib/utils/css_utils.js @@ -1,5 +1,7 @@ export function loadCSSFile(path) { return new Promise(resolve => { + if (!path) resolve(); + if (document.querySelector(`link[href="${path}"]`)) { resolve(); } else { diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 753245147d2..46b0f0cbc70 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -206,10 +206,6 @@ export const localTimeAgo = ($timeagoEls, setTimeago = true) => { $timeagoEls.each((i, el) => { // Recreate with custom template el.setAttribute('title', formatDate(el.dateTime)); - $(el).tooltip({ - template: - '<div class="tooltip local-timeago" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>', - }); }); } diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js index d9b0e8c4476..7bba7ba2f45 100644 --- a/app/assets/javascripts/lib/utils/dom_utils.js +++ b/app/assets/javascripts/lib/utils/dom_utils.js @@ -47,3 +47,25 @@ export const parseBooleanDataAttributes = ({ dataset }, names) => return acc; }, {}); + +/** + * Returns whether or not the provided element is currently visible. + * This function operates identically to jQuery's `:visible` pseudo-selector. + * Documentation for this selector: https://api.jquery.com/visible-selector/ + * Implementation of this selector: https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/css/hiddenVisibleSelectors.js#L8 + * @param {HTMLElement} element The element to test + * @returns {Boolean} `true` if the element is currently visible, otherwise false + */ +export const isElementVisible = element => + Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length); + +/** + * The opposite of `isElementVisible`. + * Returns whether or not the provided element is currently hidden. + * This function operates identically to jQuery's `:hidden` pseudo-selector. + * Documentation for this selector: https://api.jquery.com/hidden-selector/ + * Implementation of this selector: https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/css/hiddenVisibleSelectors.js#L6 + * @param {HTMLElement} element The element to test + * @returns {Boolean} `true` if the element is currently hidden, otherwise false + */ +export const isElementHidden = element => !isElementVisible(element); diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 7132986a7e6..06529f06a66 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -22,6 +22,7 @@ const httpStatusCodes = { CONFLICT: 409, GONE: 410, UNPROCESSABLE_ENTITY: 422, + TOO_MANY_REQUESTS: 429, INTERNAL_SERVER_ERROR: 500, SERVICE_UNAVAILABLE: 503, }; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 2424d6cbf3b..bc87232f40b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -1,4 +1,4 @@ -import { BYTES_IN_KIB, BYTES_IN_KB } from './constants'; +import { BYTES_IN_KIB } from './constants'; import { sprintf, __ } from '~/locale'; /** @@ -35,18 +35,6 @@ export function formatRelevantDigits(number) { } /** - * Utility function that calculates KB of the given bytes. - * Note: This method calculates KiloBytes as opposed to - * Kibibytes. For Kibibytes, bytesToKiB should be used. - * - * @param {Number} number bytes - * @return {Number} KiB - */ -export function bytesToKB(number) { - return number / BYTES_IN_KB; -} - -/** * Utility function that calculates KiB of the given bytes. * * @param {Number} number bytes diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 8ac6a44cba9..a81ca3f211f 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -399,3 +399,15 @@ export const truncateNamespace = (string = '') => { * @returns {Boolean} */ export const hasContent = obj => isString(obj) && obj.trim() !== ''; + +/** + * A utility function that validates if a + * string is valid SHA1 hash format. + * + * @param {String} hash to validate + * + * @return {Boolean} true if valid + */ +export const isValidSha1Hash = str => { + return /^[0-9a-f]{5,40}$/.test(str); +}; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index d60f949c49d..b404f390a2d 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -40,6 +40,7 @@ import { initUserTracking, initDefaultTrackers } from './tracking'; import { __ } from './locale'; import * as tooltips from '~/tooltips'; +import * as popovers from '~/popovers'; import 'ee_else_ce/main_ee'; @@ -81,7 +82,7 @@ document.addEventListener('beforeunload', () => { // Close any open tooltips tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]')); // Close any open popover - $('[data-toggle="popover"]').popover('dispose'); + popovers.dispose(); }); window.addEventListener('hashchange', handleLocationHash); @@ -166,13 +167,7 @@ function deferredInitialisation() { }); // Initialize popovers - $body.popover({ - selector: '[data-toggle="popover"]', - trigger: 'focus', - // set the viewport to the main content, excluding the navigation bar, so - // the navigation can't overlap the popover - viewport: '.layout-page', - }); + popovers.initPopovers(); // Adding a helper class to activate animations only after all is rendered setTimeout(() => $body.addClass('page-initialised'), 1000); diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js index 3a67d0ad64a..356d8619fed 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js @@ -1,11 +1,10 @@ /* eslint-disable no-param-reassign */ -/* global ace */ import Vue from 'vue'; +import { debounce } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; -import getModeByFileExtension from '~/lib/utils/ace_utils'; (global => { global.mergeConflicts = global.mergeConflicts || {}; @@ -28,7 +27,6 @@ import getModeByFileExtension from '~/lib/utils/ace_utils'; data() { return { saved: false, - loading: false, fileLoaded: false, originalContent: '', }; @@ -37,7 +35,6 @@ import getModeByFileExtension from '~/lib/utils/ace_utils'; classObject() { return { saved: this.saved, - 'is-loading': this.loading, }; }, }, @@ -45,7 +42,7 @@ import getModeByFileExtension from '~/lib/utils/ace_utils'; 'file.showEditor': function showEditorWatcher(val) { this.resetEditorContent(); - if (!val || this.fileLoaded || this.loading) { + if (!val || this.fileLoaded) { return; } @@ -59,30 +56,25 @@ import getModeByFileExtension from '~/lib/utils/ace_utils'; }, methods: { loadEditor() { - this.loading = true; + const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite'); + const DataPromise = axios.get(this.file.content_path); - axios - .get(this.file.content_path) - .then(({ data }) => { - const content = this.$el.querySelector('pre'); - const fileContent = document.createTextNode(data.content); + Promise.all([EditorPromise, DataPromise]) + .then(([{ default: EditorLite }, { data: { content, new_path: path } }]) => { + const contentEl = this.$el.querySelector('.editor'); - content.textContent = fileContent.textContent; - - this.originalContent = data.content; + this.originalContent = content; this.fileLoaded = true; - this.editor = ace.edit(content); - this.editor.$blockScrolling = Infinity; // Turn off annoying warning - this.editor.getSession().setMode(getModeByFileExtension(data.new_path)); - this.editor.on('change', () => { - this.saveDiffResolution(); + + this.editor = new EditorLite().createInstance({ + el: contentEl, + blobPath: path, + blobContent: content, }); - this.saveDiffResolution(); - this.loading = false; + this.editor.onDidChangeModelContent(debounce(this.saveDiffResolution.bind(this), 250)); }) .catch(() => { flash(__('An error occurred while loading the file')); - this.loading = false; }); }, saveDiffResolution() { @@ -95,7 +87,7 @@ import getModeByFileExtension from '~/lib/utils/ace_utils'; }, resetEditorContent() { if (this.fileLoaded) { - this.editor.setValue(this.originalContent, -1); + this.editor.setValue(this.originalContent); } }, cancelDiscardConfirmation(file) { diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue new file mode 100644 index 00000000000..08fd5a5994f --- /dev/null +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -0,0 +1,250 @@ +<script> +import { + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + GlIcon, +} from '@gitlab/ui'; +import { debounce, isEqual } from 'lodash'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import { s__, __, sprintf } from '~/locale'; +import createStore from '../stores'; +import MilestoneResultsSection from './milestone_results_section.vue'; + +const SEARCH_DEBOUNCE_MS = 250; + +export default { + name: 'MilestoneCombobox', + store: createStore(), + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + GlIcon, + MilestoneResultsSection, + }, + props: { + value: { + type: Array, + required: false, + default: () => [], + }, + projectId: { + type: String, + required: true, + }, + groupId: { + type: String, + required: false, + default: '', + }, + groupMilestonesAvailable: { + type: Boolean, + required: false, + default: false, + }, + extraLinks: { + type: Array, + default: () => [], + required: false, + }, + }, + data() { + return { + searchQuery: '', + }; + }, + translations: { + milestone: s__('MilestoneCombobox|Milestone'), + selectMilestone: s__('MilestoneCombobox|Select milestone'), + noMilestone: s__('MilestoneCombobox|No milestone'), + noResultsLabel: s__('MilestoneCombobox|No matching results'), + searchMilestones: s__('MilestoneCombobox|Search Milestones'), + searchErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'), + projectMilestones: s__('MilestoneCombobox|Project milestones'), + groupMilestones: s__('MilestoneCombobox|Group milestones'), + }, + computed: { + ...mapState(['matches', 'selectedMilestones']), + ...mapGetters(['isLoading', 'groupMilestonesEnabled']), + selectedMilestonesLabel() { + const { selectedMilestones } = this; + const firstMilestoneName = selectedMilestones[0]; + + if (selectedMilestones.length === 0) { + return this.$options.translations.noMilestone; + } + + if (selectedMilestones.length === 1) { + return firstMilestoneName; + } + + const numberOfOtherMilestones = selectedMilestones.length - 1; + return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), { + firstMilestoneName, + numberOfOtherMilestones, + }); + }, + showProjectMilestoneSection() { + return Boolean( + this.matches.projectMilestones.totalCount > 0 || this.matches.projectMilestones.error, + ); + }, + showGroupMilestoneSection() { + return ( + this.groupMilestonesEnabled && + Boolean(this.matches.groupMilestones.totalCount > 0 || this.matches.groupMilestones.error) + ); + }, + showNoResults() { + return !this.showProjectMilestoneSection && !this.showGroupMilestoneSection; + }, + }, + watch: { + // Keep the Vuex store synchronized if the parent + // component updates the selected milestones through v-model + value: { + immediate: true, + handler() { + const milestoneTitles = this.value.map(milestone => + milestone.title ? milestone.title : milestone, + ); + if (!isEqual(milestoneTitles, this.selectedMilestones)) { + this.setSelectedMilestones(milestoneTitles); + } + }, + }, + }, + created() { + // This method is defined here instead of in `methods` + // because we need to access the .cancel() method + // lodash attaches to the function, which is + // made inaccessible by Vue. More info: + // https://stackoverflow.com/a/52988020/1063392 + this.debouncedSearch = debounce(function search() { + this.search(this.searchQuery); + }, SEARCH_DEBOUNCE_MS); + + this.setProjectId(this.projectId); + this.setGroupId(this.groupId); + this.setGroupMilestonesAvailable(this.groupMilestonesAvailable); + this.fetchMilestones(); + }, + methods: { + ...mapActions([ + 'setProjectId', + 'setGroupId', + 'setGroupMilestonesAvailable', + 'setSelectedMilestones', + 'clearSelectedMilestones', + 'toggleMilestones', + 'search', + 'fetchMilestones', + ]), + focusSearchBox() { + this.$refs.searchBox.$el.querySelector('input').focus(); + }, + onSearchBoxEnter() { + this.debouncedSearch.cancel(); + this.search(this.searchQuery); + }, + onSearchBoxInput() { + this.debouncedSearch(); + }, + selectMilestone(milestone) { + this.toggleMilestones(milestone); + this.$emit('input', this.selectedMilestones); + }, + selectNoMilestone() { + this.clearSelectedMilestones(); + this.$emit('input', this.selectedMilestones); + }, + }, +}; +</script> + +<template> + <gl-dropdown v-bind="$attrs" class="milestone-combobox" @shown="focusSearchBox"> + <template slot="button-content"> + <span data-testid="milestone-combobox-button-content" class="gl-flex-grow-1 text-muted">{{ + selectedMilestonesLabel + }}</span> + <gl-icon name="chevron-down" /> + </template> + + <gl-dropdown-section-header> + <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span> + </gl-dropdown-section-header> + + <gl-dropdown-divider /> + + <gl-search-box-by-type + ref="searchBox" + v-model.trim="searchQuery" + class="gl-m-3" + :placeholder="this.$options.translations.searchMilestones" + @input="onSearchBoxInput" + @keydown.enter.prevent="onSearchBoxEnter" + /> + + <gl-dropdown-item @click="selectNoMilestone()"> + <span :class="{ 'gl-pl-6': true, 'selected-item': selectedMilestones.length === 0 }"> + {{ $options.translations.noMilestone }} + </span> + </gl-dropdown-item> + + <gl-dropdown-divider /> + + <template v-if="isLoading"> + <gl-loading-icon /> + <gl-dropdown-divider /> + </template> + <template v-else-if="showNoResults"> + <div class="dropdown-item-space"> + <span data-testid="milestone-combobox-no-results" class="gl-pl-6">{{ + $options.translations.noResultsLabel + }}</span> + </div> + <gl-dropdown-divider /> + </template> + <template v-else> + <milestone-results-section + v-if="showProjectMilestoneSection" + :section-title="$options.translations.projectMilestones" + :total-count="matches.projectMilestones.totalCount" + :items="matches.projectMilestones.list" + :selected-milestones="selectedMilestones" + :error="matches.projectMilestones.error" + :error-message="$options.translations.searchErrorMessage" + data-testid="project-milestones-section" + @selected="selectMilestone($event)" + /> + + <milestone-results-section + v-if="showGroupMilestoneSection" + :section-title="$options.translations.groupMilestones" + :total-count="matches.groupMilestones.totalCount" + :items="matches.groupMilestones.list" + :selected-milestones="selectedMilestones" + :error="matches.groupMilestones.error" + :error-message="$options.translations.searchErrorMessage" + data-testid="group-milestones-section" + @selected="selectMilestone($event)" + /> + </template> + <gl-dropdown-item + v-for="(item, idx) in extraLinks" + :key="idx" + :href="item.url" + data-testid="milestone-combobox-extra-links" + > + <span class="gl-pl-6">{{ item.text }}</span> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/milestones/components/milestone_results_section.vue b/app/assets/javascripts/milestones/components/milestone_results_section.vue new file mode 100644 index 00000000000..d53a59e58d4 --- /dev/null +++ b/app/assets/javascripts/milestones/components/milestone_results_section.vue @@ -0,0 +1,93 @@ +<script> +import { + GlDropdownSectionHeader, + GlDropdownDivider, + GlDropdownItem, + GlBadge, + GlIcon, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'MilestoneResultsSection', + components: { + GlDropdownSectionHeader, + GlDropdownDivider, + GlDropdownItem, + GlBadge, + GlIcon, + }, + props: { + sectionTitle: { + type: String, + required: true, + }, + totalCount: { + type: Number, + required: true, + }, + items: { + type: Array, + required: true, + }, + selectedMilestones: { + type: Array, + required: true, + default: () => [], + }, + error: { + type: Error, + required: false, + default: null, + }, + errorMessage: { + type: String, + required: false, + default: '', + }, + }, + computed: { + totalCountText() { + return this.totalCount > 999 ? s__('TotalMilestonesIndicator|1000+') : `${this.totalCount}`; + }, + }, + methods: { + isSelectedMilestone(item) { + return this.selectedMilestones.includes(item); + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown-section-header> + <div + class="gl-display-flex gl-align-items-center gl-pl-6" + data-testid="milestone-results-section-header" + > + <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> + <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> + </div> + </gl-dropdown-section-header> + <template v-if="error"> + <div class="gl-display-flex align-items-start gl-text-red-500 gl-ml-4 gl-mr-4 gl-mb-3"> + <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> + <span>{{ errorMessage }}</span> + </div> + </template> + <template v-else> + <gl-dropdown-item + v-for="{ title } in items" + :key="title" + role="milestone option" + @click="$emit('selected', title)" + > + <span class="gl-pl-6" :class="{ 'selected-item': isSelectedMilestone(title) }"> + {{ title }} + </span> + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue deleted file mode 100644 index 0fa5585e858..00000000000 --- a/app/assets/javascripts/milestones/project_milestone_combobox.vue +++ /dev/null @@ -1,249 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIcon, -} from '@gitlab/ui'; -import { intersection, debounce } from 'lodash'; -import { __, sprintf } from '~/locale'; -import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; - -const SEARCH_DEBOUNCE_MS = 250; - -export default { - components: { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIcon, - }, - model: { - prop: 'preselectedMilestones', - event: 'change', - }, - props: { - projectId: { - type: String, - required: true, - }, - preselectedMilestones: { - type: Array, - default: () => [], - required: false, - }, - extraLinks: { - type: Array, - default: () => [], - required: false, - }, - }, - data() { - return { - searchQuery: '', - projectMilestones: [], - searchResults: [], - selectedMilestones: [], - requestCount: 0, - }; - }, - translations: { - milestone: __('Milestone'), - selectMilestone: __('Select milestone'), - noMilestone: __('No milestone'), - noResultsLabel: __('No matching results'), - searchMilestones: __('Search Milestones'), - }, - computed: { - selectedMilestonesLabel() { - if (this.milestoneTitles.length === 1) { - return this.milestoneTitles[0]; - } - - if (this.milestoneTitles.length > 1) { - const firstMilestoneName = this.milestoneTitles[0]; - const numberOfOtherMilestones = this.milestoneTitles.length - 1; - return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), { - firstMilestoneName, - numberOfOtherMilestones, - }); - } - - return this.$options.translations.noMilestone; - }, - milestoneTitles() { - return this.preselectedMilestones.map(milestone => milestone.title); - }, - dropdownItems() { - return this.searchResults.length ? this.searchResults : this.projectMilestones; - }, - noResults() { - return this.searchQuery.length > 2 && this.searchResults.length === 0; - }, - isLoading() { - return this.requestCount !== 0; - }, - }, - created() { - // This method is defined here instead of in `methods` - // because we need to access the .cancel() method - // lodash attaches to the function, which is - // made inaccessible by Vue. More info: - // https://stackoverflow.com/a/52988020/1063392 - this.debouncedSearchMilestones = debounce(this.searchMilestones, SEARCH_DEBOUNCE_MS); - }, - mounted() { - this.fetchMilestones(); - }, - methods: { - focusSearchBox() { - this.$refs.searchBox.$el.querySelector('input').focus(); - }, - fetchMilestones() { - this.requestCount += 1; - - Api.projectMilestones(this.projectId) - .then(({ data }) => { - this.projectMilestones = this.getTitles(data); - this.selectedMilestones = intersection(this.projectMilestones, this.milestoneTitles); - }) - .catch(() => { - createFlash(__('An error occurred while loading milestones')); - }) - .finally(() => { - this.requestCount -= 1; - }); - }, - searchMilestones() { - this.requestCount += 1; - const options = { - search: this.searchQuery, - scope: 'milestones', - }; - - if (this.searchQuery.length < 3) { - this.requestCount -= 1; - this.searchResults = []; - return; - } - - Api.projectSearch(this.projectId, options) - .then(({ data }) => { - const searchResults = this.getTitles(data); - - this.searchResults = searchResults.length ? searchResults : []; - }) - .catch(() => { - createFlash(__('An error occurred while searching for milestones')); - }) - .finally(() => { - this.requestCount -= 1; - }); - }, - onSearchBoxInput() { - this.debouncedSearchMilestones(); - }, - onSearchBoxEnter() { - this.debouncedSearchMilestones.cancel(); - this.searchMilestones(); - }, - toggleMilestoneSelection(clickedMilestone) { - if (!clickedMilestone) return []; - - let milestones = [...this.preselectedMilestones]; - const hasMilestone = this.milestoneTitles.includes(clickedMilestone); - - if (hasMilestone) { - milestones = milestones.filter(({ title }) => title !== clickedMilestone); - } else { - milestones.push({ title: clickedMilestone }); - } - - return milestones; - }, - onMilestoneClicked(clickedMilestone) { - const milestones = this.toggleMilestoneSelection(clickedMilestone); - this.$emit('change', milestones); - - this.selectedMilestones = intersection( - this.projectMilestones, - milestones.map(milestone => milestone.title), - ); - }, - isSelectedMilestone(milestoneTitle) { - return this.selectedMilestones.includes(milestoneTitle); - }, - getTitles(milestones) { - return milestones.filter(({ state }) => state === 'active').map(({ title }) => title); - }, - }, -}; -</script> - -<template> - <gl-dropdown v-bind="$attrs" class="project-milestone-combobox" @shown="focusSearchBox"> - <template slot="button-content"> - <span ref="buttonText" class="flex-grow-1 ml-1 text-muted">{{ - selectedMilestonesLabel - }}</span> - <gl-icon name="chevron-down" /> - </template> - - <gl-dropdown-section-header> - <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span> - </gl-dropdown-section-header> - - <gl-dropdown-divider /> - - <gl-search-box-by-type - ref="searchBox" - v-model.trim="searchQuery" - :placeholder="this.$options.translations.searchMilestones" - @input="onSearchBoxInput" - @keydown.enter.prevent="onSearchBoxEnter" - /> - - <gl-dropdown-item @click="onMilestoneClicked(null)"> - <span :class="{ 'pl-4': true, 'selected-item': selectedMilestones.length === 0 }"> - {{ $options.translations.noMilestone }} - </span> - </gl-dropdown-item> - - <gl-dropdown-divider /> - - <template v-if="isLoading"> - <gl-loading-icon /> - <gl-dropdown-divider /> - </template> - <template v-else-if="noResults"> - <div class="dropdown-item-space"> - <span ref="noResults" class="pl-4">{{ $options.translations.noResultsLabel }}</span> - </div> - <gl-dropdown-divider /> - </template> - <template v-else-if="dropdownItems.length"> - <gl-dropdown-item - v-for="item in dropdownItems" - :key="item" - role="milestone option" - @click="onMilestoneClicked(item)" - > - <span :class="{ 'pl-4': true, 'selected-item': isSelectedMilestone(item) }"> - {{ item }} - </span> - </gl-dropdown-item> - <gl-dropdown-divider /> - </template> - - <gl-dropdown-item v-for="(item, idx) in extraLinks" :key="idx" :href="item.url"> - <span class="pl-4">{{ item.text }}</span> - </gl-dropdown-item> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/milestones/stores/actions.js b/app/assets/javascripts/milestones/stores/actions.js index 3859771aeba..df45c7156ad 100644 --- a/app/assets/javascripts/milestones/stores/actions.js +++ b/app/assets/javascripts/milestones/stores/actions.js @@ -2,10 +2,15 @@ import Api from '~/api'; import * as types from './mutation_types'; export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId); +export const setGroupId = ({ commit }, groupId) => commit(types.SET_GROUP_ID, groupId); +export const setGroupMilestonesAvailable = ({ commit }, groupMilestonesAvailable) => + commit(types.SET_GROUP_MILESTONES_AVAILABLE, groupMilestonesAvailable); export const setSelectedMilestones = ({ commit }, selectedMilestones) => commit(types.SET_SELECTED_MILESTONES, selectedMilestones); +export const clearSelectedMilestones = ({ commit }) => commit(types.CLEAR_SELECTED_MILESTONES); + export const toggleMilestones = ({ commit, state }, selectedMilestone) => { const removeMilestone = state.selectedMilestones.includes(selectedMilestone); @@ -16,13 +21,23 @@ export const toggleMilestones = ({ commit, state }, selectedMilestone) => { } }; -export const search = ({ dispatch, commit }, query) => { - commit(types.SET_QUERY, query); +export const search = ({ dispatch, commit, getters }, searchQuery) => { + commit(types.SET_SEARCH_QUERY, searchQuery); + + dispatch('searchProjectMilestones'); + if (getters.groupMilestonesEnabled) { + dispatch('searchGroupMilestones'); + } +}; - dispatch('searchMilestones'); +export const fetchMilestones = ({ dispatch, getters }) => { + dispatch('fetchProjectMilestones'); + if (getters.groupMilestonesEnabled) { + dispatch('fetchGroupMilestones'); + } }; -export const fetchMilestones = ({ commit, state }) => { +export const fetchProjectMilestones = ({ commit, state }) => { commit(types.REQUEST_START); Api.projectMilestones(state.projectId) @@ -37,14 +52,29 @@ export const fetchMilestones = ({ commit, state }) => { }); }; -export const searchMilestones = ({ commit, state }) => { +export const fetchGroupMilestones = ({ commit, state }) => { commit(types.REQUEST_START); + Api.groupMilestones(state.groupId) + .then(response => { + commit(types.RECEIVE_GROUP_MILESTONES_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_GROUP_MILESTONES_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; + +export const searchProjectMilestones = ({ commit, state }) => { const options = { - search: state.query, + search: state.searchQuery, scope: 'milestones', }; + commit(types.REQUEST_START); + Api.projectSearch(state.projectId, options) .then(response => { commit(types.RECEIVE_PROJECT_MILESTONES_SUCCESS, response); @@ -56,3 +86,22 @@ export const searchMilestones = ({ commit, state }) => { commit(types.REQUEST_FINISH); }); }; + +export const searchGroupMilestones = ({ commit, state }) => { + const options = { + search: state.searchQuery, + }; + + commit(types.REQUEST_START); + + Api.groupMilestones(state.groupId, options) + .then(response => { + commit(types.RECEIVE_GROUP_MILESTONES_SUCCESS, response); + }) + .catch(error => { + commit(types.RECEIVE_GROUP_MILESTONES_ERROR, error); + }) + .finally(() => { + commit(types.REQUEST_FINISH); + }); +}; diff --git a/app/assets/javascripts/milestones/stores/getters.js b/app/assets/javascripts/milestones/stores/getters.js index d8a283403ec..b5fcfbe35d5 100644 --- a/app/assets/javascripts/milestones/stores/getters.js +++ b/app/assets/javascripts/milestones/stores/getters.js @@ -1,2 +1,6 @@ /** Returns `true` if there is at least one in-progress request */ export const isLoading = ({ requestCount }) => requestCount > 0; + +/** Returns `true` if there is a group ID and group milestones are available */ +export const groupMilestonesEnabled = ({ groupId, groupMilestonesAvailable }) => + Boolean(groupId && groupMilestonesAvailable); diff --git a/app/assets/javascripts/milestones/stores/mutation_types.js b/app/assets/javascripts/milestones/stores/mutation_types.js index 370d386dba2..22e50571e34 100644 --- a/app/assets/javascripts/milestones/stores/mutation_types.js +++ b/app/assets/javascripts/milestones/stores/mutation_types.js @@ -1,13 +1,19 @@ export const SET_PROJECT_ID = 'SET_PROJECT_ID'; +export const SET_GROUP_ID = 'SET_GROUP_ID'; +export const SET_GROUP_MILESTONES_AVAILABLE = 'SET_GROUP_MILESTONES_AVAILABLE'; export const SET_SELECTED_MILESTONES = 'SET_SELECTED_MILESTONES'; +export const CLEAR_SELECTED_MILESTONES = 'CLEAR_SELECTED_MILESTONES'; export const ADD_SELECTED_MILESTONE = 'ADD_SELECTED_MILESTONE'; export const REMOVE_SELECTED_MILESTONE = 'REMOVE_SELECTED_MILESTONE'; -export const SET_QUERY = 'SET_QUERY'; +export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; export const REQUEST_START = 'REQUEST_START'; export const REQUEST_FINISH = 'REQUEST_FINISH'; export const RECEIVE_PROJECT_MILESTONES_SUCCESS = 'RECEIVE_PROJECT_MILESTONES_SUCCESS'; export const RECEIVE_PROJECT_MILESTONES_ERROR = 'RECEIVE_PROJECT_MILESTONES_ERROR'; + +export const RECEIVE_GROUP_MILESTONES_SUCCESS = 'RECEIVE_GROUP_MILESTONES_SUCCESS'; +export const RECEIVE_GROUP_MILESTONES_ERROR = 'RECEIVE_GROUP_MILESTONES_ERROR'; diff --git a/app/assets/javascripts/milestones/stores/mutations.js b/app/assets/javascripts/milestones/stores/mutations.js index 7c75d09766c..601b88cb62a 100644 --- a/app/assets/javascripts/milestones/stores/mutations.js +++ b/app/assets/javascripts/milestones/stores/mutations.js @@ -1,14 +1,22 @@ import Vue from 'vue'; import * as types from './mutation_types'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; export default { [types.SET_PROJECT_ID](state, projectId) { state.projectId = projectId; }, + [types.SET_GROUP_ID](state, groupId) { + state.groupId = groupId; + }, + [types.SET_GROUP_MILESTONES_AVAILABLE](state, groupMilestonesAvailable) { + state.groupMilestonesAvailable = groupMilestonesAvailable; + }, [types.SET_SELECTED_MILESTONES](state, selectedMilestones) { Vue.set(state, 'selectedMilestones', selectedMilestones); }, + [types.CLEAR_SELECTED_MILESTONES](state) { + Vue.set(state, 'selectedMilestones', []); + }, [types.ADD_SELECTED_MILESTONE](state, selectedMilestone) { state.selectedMilestones.push(selectedMilestone); }, @@ -18,8 +26,8 @@ export default { ); Vue.set(state, 'selectedMilestones', filteredMilestones); }, - [types.SET_QUERY](state, query) { - state.query = query; + [types.SET_SEARCH_QUERY](state, searchQuery) { + state.searchQuery = searchQuery; }, [types.REQUEST_START](state) { state.requestCount += 1; @@ -29,7 +37,7 @@ export default { }, [types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response) { state.matches.projectMilestones = { - list: convertObjectPropsToCamelCase(response.data).map(({ title }) => ({ title })), + list: response.data.map(({ title }) => ({ title })), totalCount: parseInt(response.headers['x-total'], 10), error: null, }; @@ -41,4 +49,18 @@ export default { error, }; }, + [types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response) { + state.matches.groupMilestones = { + list: response.data.map(({ title }) => ({ title })), + totalCount: parseInt(response.headers['x-total'], 10), + error: null, + }; + }, + [types.RECEIVE_GROUP_MILESTONES_ERROR](state, error) { + state.matches.groupMilestones = { + list: [], + totalCount: 0, + error, + }; + }, }; diff --git a/app/assets/javascripts/milestones/stores/state.js b/app/assets/javascripts/milestones/stores/state.js index 0944539f367..82723ab32f9 100644 --- a/app/assets/javascripts/milestones/stores/state.js +++ b/app/assets/javascripts/milestones/stores/state.js @@ -1,13 +1,19 @@ export default () => ({ projectId: null, groupId: null, - query: '', + groupMilestonesAvailable: false, + searchQuery: '', matches: { projectMilestones: { list: [], totalCount: 0, error: null, }, + groupMilestones: { + list: [], + totalCount: 0, + error: null, + }, }, selectedMilestones: [], requestCount: 0, diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue index d7d01def45e..511f77a441b 100644 --- a/app/assets/javascripts/monitoring/components/charts/column.vue +++ b/app/assets/javascripts/monitoring/components/charts/column.vue @@ -35,18 +35,14 @@ export default { }; }, computed: { - chartData() { - const queryData = this.graphData.metrics.reduce((acc, query) => { + barChartData() { + return this.graphData.metrics.reduce((acc, query) => { const series = makeDataSeries(query.result || [], { name: this.formatLegendLabel(query), }); return acc.concat(series); }, []); - - return { - values: queryData[0].data, - }; }, chartOptions() { const xAxis = getTimeAxisOptions({ timezone: this.timezone }); @@ -109,7 +105,7 @@ export default { <gl-column-chart ref="columnChart" v-bind="$attrs" - :data="chartData" + :bars="barChartData" :option="chartOptions" :width="width" :height="height" diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue index 9bcd4419a14..66b4d0d86e6 100644 --- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue +++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue @@ -61,14 +61,16 @@ export default { }, computed: { chartData() { - return this.graphData.metrics.map(({ result }) => { - // This needs a fix. Not only metrics[0] should be shown. - // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492 - if (!result || result.length === 0) { - return []; - } - return result[0].values.map(val => val[1]); - }); + return this.graphData.metrics + .map(({ label: name, result }) => { + // This needs a fix. Not only metrics[0] should be shown. + // See https://gitlab.com/gitlab-org/gitlab/-/issues/220492 + if (!result || result.length === 0) { + return []; + } + return { name, data: result[0].values.map(val => val[1]) }; + }) + .slice(0, 1); }, xAxisTitle() { return this.graphData.x_label !== undefined ? this.graphData.x_label : ''; @@ -136,7 +138,7 @@ export default { <gl-stacked-column-chart ref="chart" v-bind="$attrs" - :data="chartData" + :bars="chartData" :option="chartOptions" :x-axis-title="xAxisTitle" :y-axis-title="yAxisTitle" @@ -144,7 +146,6 @@ export default { :group-by="groupBy" :width="width" :height="height" - :series-names="seriesNames" :legend-layout="legendLayout" :legend-average-text="legendAverageText" :legend-current-text="legendCurrentText" diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 6bae3fdcc2e..bda2adeb62a 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -402,21 +402,21 @@ export default { @updated="onChartUpdated" > <template v-if="tooltip.type === 'deployments'"> - <template slot="tooltipTitle"> + <template slot="tooltip-title"> {{ __('Deployed') }} </template> - <div slot="tooltipContent" class="d-flex align-items-center"> + <div slot="tooltip-content" class="d-flex align-items-center"> <gl-icon name="commit" class="mr-2" /> <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> </div> </template> <template v-else> - <template slot="tooltipTitle"> + <template slot="tooltip-title"> <div class="text-nowrap"> {{ tooltip.title }} </div> </template> - <template slot="tooltipContent" :tooltip="tooltip"> + <template slot="tooltip-content" :tooltip="tooltip"> <div v-for="(content, key) in tooltip.content" :key="key" diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index cbfacd73b5b..16c2c87a4b7 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -423,7 +423,7 @@ export default { :prometheus-alerts-available="prometheusAlertsAvailable" @timerangezoom="onTimeRangeZoom" > - <template #topLeft> + <template #top-left> <gl-button ref="goBackBtn" v-gl-tooltip diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 18310f7c71e..597600bba07 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -365,7 +365,7 @@ export default { <template> <div v-gl-resize-observer="onResize" class="prometheus-graph"> <div class="d-flex align-items-center"> - <slot name="topLeft"></slot> + <slot name="top-left"></slot> <h5 ref="graphTitle" class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3" diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue index 88d5a35146f..0a1b1cd2c08 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue @@ -85,7 +85,7 @@ export default { <template> <div class="prometheus-panel-builder"> <div class="gl-xs-flex-direction-column gl-display-flex gl-mx-n3"> - <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3"> + <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5"> <template #header> <h2 class="gl-font-size-h2 gl-my-3">{{ s__('Metrics|1. Define and preview panel') }}</h2> </template> @@ -124,7 +124,7 @@ export default { </gl-card> <gl-card - class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3" + class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5" body-class="gl-display-flex gl-flex-direction-column" > <template #header> diff --git a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue b/app/assets/javascripts/monitoring/components/embeds/embed_group.vue index f07483c34b8..481ba3636cb 100644 --- a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue +++ b/app/assets/javascripts/monitoring/components/embeds/embed_group.vue @@ -73,7 +73,7 @@ export default { <template> <gl-card v-show="numCharts > 0" - class="collapsible-card border p-0 mb-3" + class="collapsible-card border p-0 gl-mb-5" header-class="d-flex align-items-center border-bottom-0 py-2" :body-class="bodyClass" > diff --git a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue index 5563a27301d..4e48292c48d 100644 --- a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue +++ b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue @@ -1,11 +1,11 @@ <script> -import { GlFormGroup, GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; export default { components: { GlFormGroup, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, }, props: { name: { @@ -41,16 +41,13 @@ export default { </script> <template> <gl-form-group :label="label"> - <gl-deprecated-dropdown - toggle-class="dropdown-menu-toggle" - :text="text || s__('Metrics|Select a value')" - > - <gl-deprecated-dropdown-item + <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')"> + <gl-dropdown-item v-for="val in options.values" :key="val.value" @click="onUpdate(val.value)" - >{{ val.text }}</gl-deprecated-dropdown-item + >{{ val.text }}</gl-dropdown-item > - </gl-deprecated-dropdown> + </gl-dropdown> </gl-form-group> </template> diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index 4d527baf730..a3d7ddd5bad 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -37,6 +37,6 @@ export default { <template> <div class="output"> <prompt type="Out" :count="count" :show-output="showOutput" /> - <div v-html="sanitizedOutput"></div> + <div class="gl-overflow-auto" v-html="sanitizedOutput"></div> </div> </template> diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index cfdadbceaf6..9cc53a320b8 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -3,7 +3,7 @@ import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import { isEmpty } from 'lodash'; import Autosize from 'autosize'; -import { GlAlert, GlIntersperse, GlLink, GlSprintf, GlButton } from '@gitlab/ui'; +import { GlAlert, GlIntersperse, GlLink, GlSprintf, GlButton, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { deprecatedCreateFlash as Flash } from '../../flash'; @@ -38,6 +38,7 @@ export default { GlIntersperse, GlLink, GlSprintf, + GlIcon, }, mixins: [issuableStateMixin], props: { @@ -342,7 +343,7 @@ export default { <ul v-else-if="canCreateNote" class="notes notes-form timeline"> <timeline-entry-item class="note-form"> <div class="flash-container error-alert timeline-content"></div> - <div class="timeline-icon d-none d-sm-none d-md-block"> + <div class="timeline-icon d-none d-md-block"> <user-avatar-link v-if="author" :link-href="author.path" @@ -457,7 +458,7 @@ export default { class="btn btn-transparent" @click.prevent="setNoteType('comment')" > - <i aria-hidden="true" class="fa fa-check icon"></i> + <gl-icon name="check" class="icon" /> <div class="description"> <strong>{{ __('Comment') }}</strong> <p> @@ -476,7 +477,7 @@ export default { data-qa-selector="discussion_menu_item" @click.prevent="setNoteType('discussion')" > - <i aria-hidden="true" class="fa fa-check icon"></i> + <gl-icon name="check" class="icon" /> <div class="description"> <strong>{{ __('Start thread') }}</strong> <p>{{ startDiscussionDescription }}</p> diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue index a4271852563..91cf682943e 100644 --- a/app/assets/javascripts/notes/components/diff_with_note.vue +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -7,6 +7,7 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { getDiffMode } from '~/diffs/store/utils'; import { diffViewerModes } from '~/ide/constants'; +import { isCollapsed } from '../../diffs/diff_file'; const FIRST_CHAR_REGEX = /^(\+|-| )/; @@ -46,6 +47,9 @@ export default { this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0 ); }, + isCollapsed() { + return isCollapsed(this.discussion.diff_file); + }, }, mounted() { if (this.isTextFile && !this.hasTruncatedDiffLines) { @@ -76,7 +80,7 @@ export default { :discussion-path="discussion.discussion_path" :diff-file="discussion.diff_file" :can-current-user-fork="false" - :expanded="!discussion.diff_file.viewer.automaticallyCollapsed" + :expanded="!isCollapsed" /> <div v-if="isTextFile" class="diff-content"> <table class="code js-syntax-highlight" :class="$options.userColorSchemeClass"> diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 878a748e99a..0272790a75d 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -45,7 +45,7 @@ export default { return this.discussion.notes.filter(x => x.resolvable); }, userCanResolveDiscussion() { - return this.resolvableNotes.every(note => note.current_user && note.current_user.can_resolve); + return this.resolvableNotes.every(note => note.current_user?.can_resolve_discussion); }, }, }; diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue index e4b191b55a7..08c22f0b4c6 100644 --- a/app/assets/javascripts/notes/components/discussion_filter.vue +++ b/app/assets/javascripts/notes/components/discussion_filter.vue @@ -116,7 +116,8 @@ export default { <gl-dropdown v-if="displayFilters" id="discussion-filter-dropdown" - class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container qa-discussion-filter" + class="gl-mr-3 full-width-mobile discussion-filter-container js-discussion-filter-container" + data-qa-selector="discussion_filter_dropdown" :text="currentFilter.title" > <div v-for="filter in filters" :key="filter.value" class="dropdown-item-wrapper"> @@ -125,7 +126,7 @@ export default { :is-checked="filter.value === currentValue" :class="{ 'is-active': filter.value === currentValue }" :data-filter-type="filterType(filter.value)" - class="qa-filter-options" + data-qa-selector="filter_menu_item" @click.prevent="selectFilter(filter.value)" > {{ filter.title }} diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue index ae6646cf96c..83326279423 100644 --- a/app/assets/javascripts/notes/components/discussion_filter_note.vue +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -1,28 +1,19 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlButton, GlIcon } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; +import { GlButton, GlIcon, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; import notesEventHub from '../event_hub'; export default { + i18n: { + information: s__( + "Notes|You're only seeing %{boldStart}other activity%{boldEnd} in the feed. To add a comment, switch to one of the following options.", + ), + }, components: { GlButton, GlIcon, - }, - computed: { - timelineContent() { - return sprintf( - __( - "You're only seeing %{startTag}other activity%{endTag} in the feed. To add a comment, switch to one of the following options.", - ), - { - startTag: `<b>`, - endTag: `</b>`, - }, - false, - ); - }, + GlSprintf, }, methods: { selectFilter(value) { @@ -33,17 +24,26 @@ export default { </script> <template> - <li class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note"> + <li + class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note" + data-qa-selector="discussion_filter_container" + > <div class="timeline-icon d-none d-lg-flex"> <gl-icon name="comment" /> </div> <div class="timeline-content"> - <div ref="timelineContent" v-html="timelineContent"></div> + <div data-testid="discussion-filter-timeline-content"> + <gl-sprintf :message="$options.i18n.information"> + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + </div> <div class="discussion-filter-actions mt-2"> - <gl-button ref="showAllActivity" variant="default" @click="selectFilter(0)"> + <gl-button variant="default" @click="selectFilter(0)"> {{ __('Show all activity') }} </gl-button> - <gl-button ref="showComments" variant="default" @click="selectFilter(1)"> + <gl-button variant="default" @click="selectFilter(1)"> {{ __('Show comments only') }} </gl-button> </div> diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index a1e887c47d0..8ac915c3c03 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -127,6 +127,7 @@ export default { :help-page-path="helpPagePath" :show-reply-button="userCanReply" :discussion-root="true" + :discussion-resolve-path="discussion.resolve_path" @handleDeleteNote="$emit('deleteNote')" @startReplying="$emit('startReplying')" > @@ -171,6 +172,7 @@ export default { :help-page-path="helpPagePath" :line="diffLine" :discussion-root="index === 0" + :discussion-resolve-path="discussion.resolve_path" @handleDeleteNote="$emit('deleteNote')" > <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index c2f40b2d21a..fc131f548b4 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,6 +1,6 @@ <script> import { mapGetters } from 'vuex'; -import { GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; import ReplyButton from './note_actions/reply_button.vue'; @@ -14,7 +14,8 @@ export default { components: { GlIcon, ReplyButton, - GlLoadingIcon, + GlButton, + GlDropdownItem, }, directives: { GlTooltip: GlTooltipDirective, @@ -170,6 +171,15 @@ export default { name: this.projectName, }); }, + resolveIcon() { + if (!this.isResolving) { + return this.isResolved ? 'check-circle-filled' : 'check-circle'; + } + return null; + }, + resolveVariant() { + return this.isResolved ? 'success' : 'default'; + }, }, methods: { onEdit() { @@ -233,24 +243,23 @@ export default { :title="displayContributorBadgeText" >{{ __('Contributor') }}</span > - <div v-if="canResolve" class="note-actions-item"> - <button + <div v-if="canResolve" class="gl-ml-2"> + <gl-button ref="resolveButton" v-gl-tooltip + size="small" + category="tertiary" + :variant="resolveVariant" :class="{ 'is-disabled': !resolvable, 'is-active': isResolved }" :title="resolveButtonTitle" :aria-label="resolveButtonTitle" - type="button" + :icon="resolveIcon" + :loading="isResolving" class="line-resolve-btn note-action-button" @click="onResolve" - > - <template v-if="!isResolving"> - <gl-icon :name="isResolved ? 'check-circle-filled' : 'check-circle'" /> - </template> - <gl-loading-icon v-else inline /> - </button> + /> </div> - <div v-if="canAwardEmoji" class="note-actions-item"> + <div v-if="canAwardEmoji" class="gl-ml-3 gl-mr-2"> <a v-gl-tooltip :class="{ 'js-user-authored': isAuthoredByCurrentUser }" @@ -261,7 +270,7 @@ export default { > <gl-icon class="link-highlight award-control-icon-neutral" name="slight-smile" /> <gl-icon class="link-highlight award-control-icon-positive" name="smiley" /> - <gl-icon class="link-highlight award-control-icon-super-positive" name="smiley" /> + <gl-icon class="link-highlight award-control-icon-super-positive" name="smile" /> </a> </div> <reply-button @@ -270,72 +279,57 @@ export default { class="js-reply-button" @startReplying="$emit('startReplying')" /> - <div v-if="canEdit" class="note-actions-item"> - <button + <div v-if="canEdit" class="gl-ml-2"> + <gl-button v-gl-tooltip - type="button" title="Edit comment" + icon="pencil" + size="small" + category="tertiary" class="note-action-button js-note-edit btn btn-transparent" data-qa-selector="note_edit_button" @click="onEdit" - > - <gl-icon name="pencil" class="link-highlight" /> - </button> + /> </div> - <div v-if="showDeleteAction" class="note-actions-item"> - <button + <div v-if="showDeleteAction" class="gl-ml-2"> + <gl-button v-gl-tooltip - type="button" title="Delete comment" + size="small" + icon="remove" + category="tertiary" class="note-action-button js-note-delete btn btn-transparent" @click="onDelete" - > - <gl-icon name="remove" class="link-highlight" /> - </button> + /> </div> - <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item"> - <button + <div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions gl-ml-2"> + <gl-button v-gl-tooltip - type="button" title="More actions" + icon="ellipsis_v" + size="small" + category="tertiary" class="note-action-button more-actions-toggle btn btn-transparent" data-toggle="dropdown" @click="closeTooltip" - > - <gl-icon class="icon" name="ellipsis_v" /> - </button> + /> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> - <li v-if="canReportAsAbuse"> - <a :href="reportAbusePath">{{ __('Report abuse to admin') }}</a> - </li> - <li v-if="noteUrl"> - <button - :data-clipboard-text="noteUrl" - type="button" - class="btn-default btn-transparent js-btn-copy-note-link" - > - {{ __('Copy link') }} - </button> - </li> - <li v-if="canAssign"> - <button - class="btn-default btn-transparent" - data-testid="assign-user" - type="button" - @click="assignUser" - > - {{ displayAssignUserText }} - </button> - </li> - <li v-if="canEdit"> - <button - class="btn btn-transparent js-note-delete js-note-delete" - type="button" - @click.prevent="onDelete" - > - <span class="text-danger">{{ __('Delete comment') }}</span> - </button> - </li> + <gl-dropdown-item v-if="canReportAsAbuse" :href="reportAbusePath"> + {{ __('Report abuse to admin') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="noteUrl" + class="js-btn-copy-note-link" + :data-clipboard-text="noteUrl" + > + {{ __('Copy link') }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canAssign" data-testid="assign-user" @click="assignUser"> + {{ displayAssignUserText }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canEdit" class="js-note-delete" @click.prevent="onDelete"> + <span class="text-danger">{{ __('Delete comment') }}</span> + </gl-dropdown-item> </ul> </div> </div> diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue index f19b7667fb2..acbbee13a6d 100644 --- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue +++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue @@ -13,7 +13,7 @@ export default { </script> <template> - <div class="note-actions-item"> + <div class="gl-ml-2"> <gl-button ref="button" v-gl-tooltip diff --git a/app/assets/javascripts/notes/components/note_attachment.vue b/app/assets/javascripts/notes/components/note_attachment.vue index 72f9a4c7e74..b20facc4032 100644 --- a/app/assets/javascripts/notes/components/note_attachment.vue +++ b/app/assets/javascripts/notes/components/note_attachment.vue @@ -1,6 +1,11 @@ <script> +import { GlIcon } from '@gitlab/ui'; + export default { name: 'NoteAttachment', + components: { + GlIcon, + }, props: { attachment: { type: Object, @@ -29,7 +34,7 @@ export default { target="_blank" rel="noopener noreferrer" > - <i class="fa fa-paperclip" aria-hidden="true"> </i> {{ attachment.filename }} + <gl-icon name="paperclip" /> {{ attachment.filename }} </a> </div> </div> diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 4b3f23e742d..43f17c5d65c 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -121,7 +121,13 @@ export default { return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit; }, showResolveDiscussionToggle() { - return (this.discussion?.id && this.discussion.resolvable) || this.isDraft; + if (!this.discussion?.notes) return false; + + return ( + this.discussion?.notes + .filter(n => n.resolvable) + .some(n => n.current_user?.can_resolve_discussion) || this.isDraft + ); }, noteHash() { if (this.noteId) { diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index a13a0dbbf30..cacf209ed81 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -1,7 +1,8 @@ <script> /* eslint-disable vue/no-v-html */ import { mapActions } from 'vuex'; -import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui'; +import { isUserBusy } from '~/set_status_modal/utils'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { @@ -11,6 +12,7 @@ export default { import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'), GlIcon, GlLoadingIcon, + GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, @@ -65,8 +67,8 @@ export default { }; }, computed: { - toggleChevronClass() { - return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; + toggleChevronIconName() { + return this.expanded ? 'chevron-up' : 'chevron-down'; }, noteTimestampLink() { return this.noteId ? `#note_${this.noteId}` : undefined; @@ -85,9 +87,16 @@ export default { authorStatus() { return this.author.status_tooltip_html; }, + authorIsBusy() { + const { status } = this.author; + return status?.availability && isUserBusy(status.availability); + }, emojiElement() { return this.$refs?.authorStatus?.querySelector('gl-emoji'); }, + authorName() { + return this.author.name; + }, }, mounted() { this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : ''; @@ -133,7 +142,7 @@ export default { type="button" @click="handleToggle" > - <i ref="chevronIcon" :class="toggleChevronClass" class="fa" aria-hidden="true"></i> + <gl-icon ref="chevronIcon" :name="toggleChevronIconName" aria-hidden="true" /> {{ __('Toggle thread') }} </button> </div> @@ -146,7 +155,12 @@ export default { :data-username="author.username" > <slot name="note-header-info"></slot> - <span class="note-header-author-name bold">{{ author.name }}</span> + <span class="note-header-author-name gl-font-weight-bold"> + <gl-sprintf v-if="authorIsBusy" :message="s__('UserAvailability|%{author} (Busy)')"> + <template #author>{{ authorName }}</template> + </gl-sprintf> + <template v-else>{{ authorName }}</template> + </span> </a> <span v-if="authorStatus" @@ -170,7 +184,9 @@ export default { </template> <span v-else>{{ __('A deleted user') }}</span> <span class="note-headline-light note-headline-meta"> - <span class="system-note-message"> <slot></slot> </span> + <span class="system-note-message" data-qa-selector="system_note_content"> + <slot></slot> + </span> <template v-if="createdAt"> <span ref="actionText" class="system-note-separator"> <template v-if="actionText">{{ actionText }}</template> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 4f45fcb0062..9be53fe60f2 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -73,6 +73,11 @@ export default { required: false, default: false, }, + discussionResolvePath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -81,6 +86,7 @@ export default { isRequesting: false, isResolving: false, commentLineStart: {}, + resolveAsThread: this.glFeatures.removeResolveNote, }; }, computed: { @@ -133,6 +139,10 @@ export default { return this.note.isDraft; }, canResolve() { + if (this.glFeatures.removeResolveNote && !this.discussionRoot) return false; + + if (this.glFeatures.removeResolveNote) return this.note.current_user.can_resolve_discussion; + return ( this.note.current_user.can_resolve || (this.note.isDraft && this.note.discussion_id !== null) @@ -345,7 +355,8 @@ export default { :class="classNameBindings" :data-award-url="note.toggle_award_path" :data-note-id="note.id" - class="note note-wrapper qa-noteable-note-item" + class="note note-wrapper" + data-qa-selector="noteable_note_container" > <div v-if="showMultiLineComment" diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue index f49fd2c3fa3..0628e1d8647 100644 --- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue +++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue @@ -1,11 +1,12 @@ <script> import { uniqBy } from 'lodash'; -import { GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { + GlButton, GlIcon, UserAvatarLink, TimeAgoTooltip, @@ -57,14 +58,15 @@ export default { tooltip-placement="bottom" /> </div> - <button - class="btn btn-link js-replies-text" + <gl-button + class="js-replies-text" + category="tertiary" + variant="link" data-qa-selector="expand_replies_button" - type="button" @click="toggle" > {{ replies.length }} {{ n__('reply', 'replies', replies.length) }} - </button> + </gl-button> {{ __('Last reply by') }} <a :href="lastReply.author.path" class="btn btn-link author-link"> {{ lastReply.author.name }} diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index 087b5828cce..cef4475ed1d 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -1,12 +1,18 @@ import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { + mixins: [glFeatureFlagsMixin()], computed: { discussionResolved() { if (this.discussion) { const { notes, resolved } = this.discussion; + if (this.glFeatures.removeResolveNote) { + return Boolean(resolved); + } + if (notes) { // Decide resolved state using store. Only valid for discussions. return notes.filter(note => !note.system).every(note => note.resolved); @@ -38,7 +44,12 @@ export default { this.isResolving = true; const isResolved = this.discussionResolved || resolvedState; const discussion = this.resolveAsThread; - const endpoint = discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`; + let endpoint = + discussion && this.discussion ? this.discussion.resolve_path : `${this.note.path}/resolve`; + + if (this.glFeatures.removeResolveNote && this.discussionResolvePath) { + endpoint = this.discussionResolvePath; + } return this.toggleResolveNote({ endpoint, isResolved, discussion }) .then(() => { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 37986c8a02d..2c60b5ee84a 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -167,7 +167,7 @@ export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes) if (discussion) { commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); - } else if (note.type === constants.DIFF_NOTE) { + } else if (note.type === constants.DIFF_NOTE && !note.base_discussion) { debouncedFetchDiscussions(state.currentlyFetchingDiscussions); } else { commit(types.ADD_NEW_NOTE, note); diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 6c11d53dba3..7cc619ec1c5 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -11,24 +11,30 @@ export default { const isDiscussion = type === constants.DISCUSSION_NOTE || type === constants.DIFF_NOTE; if (!exists) { - const noteData = { - expanded: true, - id: discussion_id, - individual_note: !isDiscussion, - notes: [note], - reply_id: discussion_id, - }; - - if (isDiscussion && isInMRPage()) { - noteData.resolvable = note.resolvable; - noteData.resolved = false; - noteData.active = true; - noteData.resolve_path = note.resolve_path; - noteData.resolve_with_issue_path = note.resolve_with_issue_path; - noteData.diff_discussion = false; + let discussion = data.discussion || note.base_discussion; + + if (!discussion) { + discussion = { + expanded: true, + id: discussion_id, + individual_note: !isDiscussion, + reply_id: discussion_id, + }; + + if (isDiscussion && isInMRPage()) { + discussion.resolvable = note.resolvable; + discussion.resolved = false; + discussion.active = true; + discussion.resolve_path = note.resolve_path; + discussion.resolve_with_issue_path = note.resolve_with_issue_path; + discussion.diff_discussion = false; + } } - state.discussions.push(noteData); + note.base_discussion = undefined; // No point keeping a reference to this + discussion.notes = [note]; + + state.discussions.push(discussion); } }, diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index 0d1b95f75f8..1b12fece23a 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -22,12 +22,8 @@ export default class NotificationsForm { // eslint-disable-next-line class-methods-use-this showCheckboxLoadingSpinner($parent) { - $parent - .addClass('is-loading') - .find('.custom-notification-event-loading') - .removeClass('fa-check') - .addClass('spinner align-middle') - .removeClass('is-done'); + $parent.find('.is-loading').removeClass('gl-display-none'); + $parent.find('.is-done').addClass('gl-display-none'); } saveEvent($checkbox, $parent) { @@ -39,14 +35,11 @@ export default class NotificationsForm { .then(({ data }) => { $checkbox.enable(); if (data.saved) { - $parent - .find('.custom-notification-event-loading') - .toggleClass('spinner fa-check is-done align-middle'); + $parent.find('.is-loading').addClass('gl-display-none'); + $parent.find('.is-done').removeClass('gl-display-none'); + setTimeout(() => { - $parent - .removeClass('is-loading') - .find('.custom-notification-event-loading') - .toggleClass('spinner fa-check is-done align-middle'); + $parent.find('.is-done').addClass('gl-display-none'); }, 2000); } }) diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue index 2789be30818..6b7eeacb964 100644 --- a/app/assets/javascripts/packages/details/components/package_title.vue +++ b/app/assets/javascripts/packages/details/components/package_title.vue @@ -1,6 +1,8 @@ <script> +/* eslint-disable vue/v-slot-style */ import { mapState, mapGetters } from 'vuex'; -import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { GlIcon, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; import PackageTags from '../../shared/components/package_tags.vue'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import timeagoMixin from '~/vue_shared/mixins/timeago'; @@ -16,11 +18,20 @@ export default { GlSprintf, PackageTags, MetadataItem, + GlBadge, }, directives: { GlTooltip: GlTooltipDirective, }, mixins: [timeagoMixin], + i18n: { + packageInfo: __('v%{version} published %{timeAgo}'), + }, + data() { + return { + isDesktop: true, + }; + }, computed: { ...mapState(['packageEntity', 'packageFiles']), ...mapGetters(['packageTypeDisplay', 'packagePipeline', 'packageIcon']), @@ -31,8 +42,13 @@ export default { return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0)); }, }, - i18n: { - packageInfo: __('v%{version} published %{timeAgo}'), + mounted() { + this.isDesktop = GlBreakpointInstance.isDesktop(); + }, + methods: { + dynamicSlotName(index) { + return `metadata-tag${index}`; + }, }, }; </script> @@ -75,10 +91,21 @@ export default { <metadata-item data-testid="package-ref" icon="branch" :text="packagePipeline.ref" /> </template> - <template v-if="hasTagsToDisplay" #metadata-tags> + <template v-if="isDesktop && hasTagsToDisplay" #metadata-tags> <package-tags :tag-display-limit="2" :tags="packageEntity.tags" hide-label /> </template> + <!-- we need to duplicate the package tags on mobile to ensure proper styling inside the flex wrap --> + <template + v-for="(tag, index) in packageEntity.tags" + v-else-if="hasTagsToDisplay" + v-slot:[dynamicSlotName(index)] + > + <gl-badge :key="index" class="gl-my-1" data-testid="tag-badge" variant="info" size="sm"> + {{ tag.name }} + </gl-badge> + </template> + <template #right-actions> <slot name="delete-button"></slot> </template> diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js index 88967d82b2f..038bbe392ba 100644 --- a/app/assets/javascripts/pages/admin/admin.js +++ b/app/assets/javascripts/pages/admin/admin.js @@ -1,13 +1,13 @@ import $ from 'jquery'; import { refreshCurrentPage } from '../../lib/utils/url_utility'; -function showBlacklistType() { - if ($('input[name="blacklist_type"]:checked').val() === 'file') { - $('.blacklist-file').show(); - $('.blacklist-raw').hide(); +function showDenylistType() { + if ($('input[name="denylist_type"]:checked').val() === 'file') { + $('.js-denylist-file').show(); + $('.js-denylist-raw').hide(); } else { - $('.blacklist-file').hide(); - $('.blacklist-raw').show(); + $('.js-denylist-file').hide(); + $('.js-denylist-raw').show(); } } @@ -60,6 +60,6 @@ export default function adminInit() { $('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage); - $("input[name='blacklist_type']").on('click', showBlacklistType); - showBlacklistType(); + $("input[name='denylist_type']").on('click', showDenylistType); + showDenylistType(); } diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js index 8183e81fb02..af1595398a8 100644 --- a/app/assets/javascripts/pages/admin/application_settings/general/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js @@ -1,3 +1,21 @@ +import Vue from 'vue'; import initUserInternalRegexPlaceholder from '../account_and_limits'; +import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; -document.addEventListener('DOMContentLoaded', initUserInternalRegexPlaceholder()); +document.addEventListener('DOMContentLoaded', () => { + initUserInternalRegexPlaceholder(); + + const gitpodSettingEl = document.querySelector('#js-gitpod-settings-help-text'); + if (!gitpodSettingEl) { + return; + } + + // eslint-disable-next-line no-new + new Vue({ + el: gitpodSettingEl, + name: 'GitpodSettings', + components: { + IntegrationHelpText, + }, + }); +}); diff --git a/app/assets/javascripts/pages/admin/dev_ops_report/index.js b/app/assets/javascripts/pages/admin/dev_ops_report/index.js index 643497003ba..220fc049562 100644 --- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js +++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js @@ -1,27 +1,5 @@ -import Vue from 'vue'; -import UserCallout from '~/user_callout'; -import UsagePingDisabled from '~/admin/dev_ops_report/components/usage_ping_disabled.vue'; +import initDevopAdoption from 'ee_else_ce/admin/dev_ops_report/devops_adoption'; +import initDevOpsScoreEmptyState from '~/admin/dev_ops_report/devops_score_empty_state'; -document.addEventListener('DOMContentLoaded', () => { - // eslint-disable-next-line no-new - new UserCallout(); - - const emptyStateContainer = document.getElementById('js-devops-empty-state'); - - if (!emptyStateContainer) return false; - - const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset; - - return new Vue({ - el: emptyStateContainer, - provide: { - isAdmin: Boolean(isAdmin), - svgPath: emptyStateSvgPath, - primaryButtonPath: enableUsagePingLink, - docsLink, - }, - render(h) { - return h(UsagePingDisabled); - }, - }); -}); +initDevOpsScoreEmptyState(); +initDevopAdoption(); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index 120512bf15e..4b6f52c09be 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -1,13 +1,13 @@ <script> +import { GlModal } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { redirectTo } from '~/lib/utils/url_utility'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; export default { components: { - GlModal: DeprecatedModal2, + GlModal, }, props: { url: { @@ -36,17 +36,24 @@ export default { }); }, }, + primaryAction: { + text: s__('AdminArea|Stop jobs'), + attributes: [{ variant: 'danger' }], + }, + cancelAction: { + text: __('Cancel'), + }, }; </script> <template> <gl-modal - id="stop-jobs-modal" - :header-title-text="s__('AdminArea|Stop all jobs?')" - :footer-primary-button-text="s__('AdminArea|Stop jobs')" - footer-primary-button-variant="danger" - @submit="onSubmit" + modal-id="stop-jobs-modal" + :action-primary="$options.primaryAction" + :action-cancel="$options.cancelAction" + @primary="onSubmit" > + <template #modal-title>{{ s__('AdminArea|Stop all jobs?') }}</template> {{ text }} </gl-modal> </template> diff --git a/app/assets/javascripts/pages/admin/jobs/index/index.js b/app/assets/javascripts/pages/admin/jobs/index/index.js index 5a4f8c6e745..4df210debb5 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/index.js +++ b/app/assets/javascripts/pages/admin/jobs/index/index.js @@ -5,19 +5,24 @@ import stopJobsModal from './components/stop_jobs_modal.vue'; Vue.use(Translate); document.addEventListener('DOMContentLoaded', () => { - const stopJobsButton = document.getElementById('stop-jobs-button'); + const buttonId = 'js-stop-jobs-button'; + const modalId = 'stop-jobs-modal'; + const stopJobsButton = document.getElementById(buttonId); if (stopJobsButton) { // eslint-disable-next-line no-new new Vue({ - el: '#stop-jobs-modal', + el: `#js-${modalId}`, components: { stopJobsModal, }, mounted() { stopJobsButton.classList.remove('disabled'); + stopJobsButton.addEventListener('click', () => { + this.$root.$emit('bv::show::modal', modalId, `#${buttonId}`); + }); }, render(createElement) { - return createElement('stop-jobs-modal', { + return createElement(modalId, { props: { url: stopJobsButton.dataset.url, }, diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index.js index e60c6133c7c..104b7eeaf96 100644 --- a/app/assets/javascripts/pages/admin/runners/index.js +++ b/app/assets/javascripts/pages/admin/runners/index.js @@ -1,11 +1,12 @@ import initFilteredSearch from '~/pages/search/init_filtered_search'; import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; +import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; -document.addEventListener('DOMContentLoaded', () => { - initFilteredSearch({ - page: FILTERED_SEARCH.ADMIN_RUNNERS, - filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys, - useDefaultState: true, - }); +initFilteredSearch({ + page: FILTERED_SEARCH.ADMIN_RUNNERS, + filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys, + useDefaultState: true, }); + +initInstallRunner(); diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index 86c4b4f4f48..5f3cdc0bfc6 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -5,12 +5,12 @@ import ModalManager from './components/user_modal_manager.vue'; import DeleteUserModal from './components/delete_user_modal.vue'; import UserOperationConfirmationModal from './components/user_operation_confirmation_modal.vue'; import csrf from '~/lib/utils/csrf'; +import initConfirmModal from '~/confirm_modal'; const MODAL_TEXTS_CONTAINER_SELECTOR = '#modal-texts'; const MODAL_MANAGER_SELECTOR = '#user-modal'; const ACTION_MODALS = { deactivate: UserOperationConfirmationModal, - block: UserOperationConfirmationModal, delete: DeleteUserModal, 'delete-with-contributions': DeleteUserModal, }; @@ -62,4 +62,6 @@ document.addEventListener('DOMContentLoaded', () => { }); }, }); + + initConfirmModal(); }); diff --git a/app/assets/javascripts/pages/groups/boards/index.js b/app/assets/javascripts/pages/groups/boards/index.js index 79c3be771d0..922f39627c9 100644 --- a/app/assets/javascripts/pages/groups/boards/index.js +++ b/app/assets/javascripts/pages/groups/boards/index.js @@ -2,8 +2,6 @@ import UsersSelect from '~/users_select'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initBoards from '~/boards'; -document.addEventListener('DOMContentLoaded', () => { - new UsersSelect(); // eslint-disable-line no-new - new ShortcutsNavigation(); // eslint-disable-line no-new - initBoards(); -}); +new UsersSelect(); // eslint-disable-line no-new +new ShortcutsNavigation(); // eslint-disable-line no-new +initBoards(); diff --git a/app/assets/javascripts/pages/groups/clusters/destroy/index.js b/app/assets/javascripts/pages/groups/clusters/destroy/index.js index 8001d2dd1da..487e7a14a16 100644 --- a/app/assets/javascripts/pages/groups/clusters/destroy/index.js +++ b/app/assets/javascripts/pages/groups/clusters/destroy/index.js @@ -1,5 +1,3 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new -}); +new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/clusters/edit/index.js b/app/assets/javascripts/pages/groups/clusters/edit/index.js index 8001d2dd1da..487e7a14a16 100644 --- a/app/assets/javascripts/pages/groups/clusters/edit/index.js +++ b/app/assets/javascripts/pages/groups/clusters/edit/index.js @@ -1,5 +1,3 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new -}); +new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/clusters/index.js b/app/assets/javascripts/pages/groups/clusters/index.js index 9f466e0d60a..3b92c244346 100644 --- a/app/assets/javascripts/pages/groups/clusters/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index.js @@ -1,7 +1,5 @@ import initCreateCluster from '~/create_cluster/init_create_cluster'; import initIntegrationForm from '~/clusters/forms/show/index'; -document.addEventListener('DOMContentLoaded', () => { - initCreateCluster(document, gon); - initIntegrationForm(); -}); +initCreateCluster(document, gon); +initIntegrationForm(); diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js index 744be65bfbe..3b71517f017 100644 --- a/app/assets/javascripts/pages/groups/clusters/index/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index/index.js @@ -1,8 +1,6 @@ import PersistentUserCallout from '~/persistent_user_callout'; import initClustersListApp from '~/clusters_list'; -document.addEventListener('DOMContentLoaded', () => { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); - initClustersListApp(); -}); +const callout = document.querySelector('.gcp-signup-offer'); +PersistentUserCallout.factory(callout); +initClustersListApp(); diff --git a/app/assets/javascripts/pages/groups/clusters/new/index.js b/app/assets/javascripts/pages/groups/clusters/new/index.js index 876bab0b339..de9ded87ef3 100644 --- a/app/assets/javascripts/pages/groups/clusters/new/index.js +++ b/app/assets/javascripts/pages/groups/clusters/new/index.js @@ -1,5 +1,3 @@ import initNewCluster from '~/clusters/new_cluster'; -document.addEventListener('DOMContentLoaded', () => { - initNewCluster(); -}); +initNewCluster(); diff --git a/app/assets/javascripts/pages/groups/clusters/show/index.js b/app/assets/javascripts/pages/groups/clusters/show/index.js index ccf631b2c53..5d202a8824f 100644 --- a/app/assets/javascripts/pages/groups/clusters/show/index.js +++ b/app/assets/javascripts/pages/groups/clusters/show/index.js @@ -1,7 +1,5 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; -document.addEventListener('DOMContentLoaded', () => { - new ClustersBundle(); // eslint-disable-line no-new - initClusterHealth(); -}); +new ClustersBundle(); // eslint-disable-line no-new +initClusterHealth(); diff --git a/app/assets/javascripts/pages/groups/dependency_proxies/index.js b/app/assets/javascripts/pages/groups/dependency_proxies/index.js new file mode 100644 index 00000000000..77c885d3858 --- /dev/null +++ b/app/assets/javascripts/pages/groups/dependency_proxies/index.js @@ -0,0 +1,13 @@ +import $ from 'jquery'; +import initDependencyProxy from '~/dependency_proxy'; + +initDependencyProxy(); + +const form = document.querySelector('form.edit_dependency_proxy_group_setting'); +const toggleInput = $('input.js-project-feature-toggle-input'); + +if (form && toggleInput) { + toggleInput.on('trigger-change', () => { + form.submit(); + }); +} diff --git a/app/assets/javascripts/pages/groups/details/index.js b/app/assets/javascripts/pages/groups/details/index.js index 3bcaa0f0232..0417134f2a7 100644 --- a/app/assets/javascripts/pages/groups/details/index.js +++ b/app/assets/javascripts/pages/groups/details/index.js @@ -1,5 +1,3 @@ import initGroupDetails from '../shared/group_details'; -document.addEventListener('DOMContentLoaded', () => { - initGroupDetails('details'); -}); +initGroupDetails('details'); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index dc647f5d3cb..009a3eee526 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -21,35 +21,36 @@ function mountRemoveMemberModal() { }); } -document.addEventListener('DOMContentLoaded', () => { - groupsSelect(); - memberExpirationDate(); - memberExpirationDate('.js-access-expiration-date-groups'); - mountRemoveMemberModal(); +const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; +initGroupMembersApp( + document.querySelector('.js-group-members-list'), + SHARED_FIELDS.concat(['source', 'granted']), + { tr: { 'data-qa-selector': 'member_row' } }, + memberRequestFormatter, +); +initGroupMembersApp( + document.querySelector('.js-group-linked-list'), + SHARED_FIELDS.concat('granted'), + { table: { 'data-qa-selector': 'groups_list' }, tr: { 'data-qa-selector': 'group_row' } }, + groupLinkRequestFormatter, +); +initGroupMembersApp( + document.querySelector('.js-group-invited-members-list'), + SHARED_FIELDS.concat('invited'), + {}, + memberRequestFormatter, +); +initGroupMembersApp( + document.querySelector('.js-group-access-requests-list'), + SHARED_FIELDS.concat('requested'), + {}, + memberRequestFormatter, +); - const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; +groupsSelect(); +memberExpirationDate(); +memberExpirationDate('.js-access-expiration-date-groups'); +mountRemoveMemberModal(); - initGroupMembersApp( - document.querySelector('.js-group-members-list'), - SHARED_FIELDS.concat(['source', 'granted']), - memberRequestFormatter, - ); - initGroupMembersApp( - document.querySelector('.js-group-linked-list'), - SHARED_FIELDS.concat('granted'), - groupLinkRequestFormatter, - ); - initGroupMembersApp( - document.querySelector('.js-group-invited-members-list'), - SHARED_FIELDS.concat('invited'), - memberRequestFormatter, - ); - initGroupMembersApp( - document.querySelector('.js-group-access-requests-list'), - SHARED_FIELDS.concat('requested'), - memberRequestFormatter, - ); - - new Members(); // eslint-disable-line no-new - new UsersSelect(); // eslint-disable-line no-new -}); +new Members(); // eslint-disable-line no-new +new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js index 83d6ac9fd14..2e8308fe084 100644 --- a/app/assets/javascripts/pages/groups/labels/edit/index.js +++ b/app/assets/javascripts/pages/groups/labels/edit/index.js @@ -1,3 +1,4 @@ import Labels from 'ee_else_ce/labels'; -document.addEventListener('DOMContentLoaded', () => new Labels()); +// eslint-disable-next-line no-new +new Labels(); diff --git a/app/assets/javascripts/pages/groups/labels/index/index.js b/app/assets/javascripts/pages/groups/labels/index/index.js index 6e45de2a724..87d522d7654 100644 --- a/app/assets/javascripts/pages/groups/labels/index/index.js +++ b/app/assets/javascripts/pages/groups/labels/index/index.js @@ -1,3 +1,3 @@ import initLabels from '~/init_labels'; -document.addEventListener('DOMContentLoaded', initLabels); +initLabels(); diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js index 83d6ac9fd14..2e8308fe084 100644 --- a/app/assets/javascripts/pages/groups/labels/new/index.js +++ b/app/assets/javascripts/pages/groups/labels/new/index.js @@ -1,3 +1,4 @@ import Labels from 'ee_else_ce/labels'; -document.addEventListener('DOMContentLoaded', () => new Labels()); +// eslint-disable-next-line no-new +new Labels(); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 71c67ac74ed..2832cbed5ac 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -7,15 +7,13 @@ import { FILTERED_SEARCH } from '~/pages/constants'; const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_'; -document.addEventListener('DOMContentLoaded', () => { - addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); - issuableInitBulkUpdateSidebar.init(ISSUABLE_BULK_UPDATE_PREFIX); +addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys); +issuableInitBulkUpdateSidebar.init(ISSUABLE_BULK_UPDATE_PREFIX); - initFilteredSearch({ - page: FILTERED_SEARCH.MERGE_REQUESTS, - isGroupDecendent: true, - useDefaultState: true, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - }); - projectSelect(); +initFilteredSearch({ + page: FILTERED_SEARCH.MERGE_REQUESTS, + isGroupDecendent: true, + useDefaultState: true, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); +projectSelect(); diff --git a/app/assets/javascripts/pages/groups/milestones/edit/index.js b/app/assets/javascripts/pages/groups/milestones/edit/index.js index ddd10fe5062..af0264c7992 100644 --- a/app/assets/javascripts/pages/groups/milestones/edit/index.js +++ b/app/assets/javascripts/pages/groups/milestones/edit/index.js @@ -1,3 +1,3 @@ import initForm from '../../../../shared/milestones/form'; -document.addEventListener('DOMContentLoaded', () => initForm(false)); +initForm(false); diff --git a/app/assets/javascripts/pages/groups/milestones/new/index.js b/app/assets/javascripts/pages/groups/milestones/new/index.js index ddd10fe5062..af0264c7992 100644 --- a/app/assets/javascripts/pages/groups/milestones/new/index.js +++ b/app/assets/javascripts/pages/groups/milestones/new/index.js @@ -1,3 +1,3 @@ import initForm from '../../../../shared/milestones/form'; -document.addEventListener('DOMContentLoaded', () => initForm(false)); +initForm(false); diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 640e64b5d3e..7021473b380 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -4,13 +4,11 @@ import Group from '~/group'; import GroupPathValidator from './group_path_validator'; import initFilePickers from '~/file_pickers'; -document.addEventListener('DOMContentLoaded', () => { - const parentId = $('#group_parent_id'); - if (!parentId.val()) { - new GroupPathValidator(); // eslint-disable-line no-new - } - BindInOut.initAll(); - initFilePickers(); +const parentId = $('#group_parent_id'); +if (!parentId.val()) { + new GroupPathValidator(); // eslint-disable-line no-new +} +BindInOut.initAll(); +initFilePickers(); - return new Group(); -}); +new Group(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js index 4836900aa28..1c4a10fd653 100644 --- a/app/assets/javascripts/pages/groups/packages/index/index.js +++ b/app/assets/javascripts/pages/groups/packages/index/index.js @@ -1,7 +1,5 @@ import initPackageList from '~/packages/list/packages_list_app_bundle'; -document.addEventListener('DOMContentLoaded', () => { - if (document.getElementById('js-vue-packages-list')) { - initPackageList(); - } -}); +if (document.getElementById('js-vue-packages-list')) { + initPackageList(); +} diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index 67eb09da5e0..3456048d718 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -1,35 +1,21 @@ import initSettingsPanels from '~/settings_panels'; -import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import initVariableList from '~/ci_variable_list'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; +import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; -document.addEventListener('DOMContentLoaded', () => { - // Initialize expandable settings panels - initSettingsPanels(); +// Initialize expandable settings panels +initSettingsPanels(); - initFilteredSearch({ - page: FILTERED_SEARCH.ADMIN_RUNNERS, - filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys, - anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR, - useDefaultState: false, - }); - - if (gon.features.newVariablesUi) { - initVariableList(); - } else { - const variableListEl = document.querySelector('.js-ci-variable-list-section'); - // eslint-disable-next-line no-new - new AjaxVariableList({ - container: variableListEl, - saveButton: variableListEl.querySelector('.js-ci-variables-save-button'), - errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), - saveEndpoint: variableListEl.dataset.saveEndpoint, - maskableRegex: variableListEl.dataset.maskableRegex, - }); - } - - initSharedRunnersForm(); +initFilteredSearch({ + page: FILTERED_SEARCH.ADMIN_RUNNERS, + filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys, + anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR, + useDefaultState: false, }); + +initSharedRunnersForm(); +initVariableList(); +initInstallRunner(); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 8546b1f759f..8d956c694c0 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -2,7 +2,6 @@ import { getPagePath, getDashPath } from '~/lib/utils/common_utils'; import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants'; -import NewGroupChild from '~/groups/new_group_child'; import notificationsDropdown from '~/notifications_dropdown'; import NotificationsForm from '~/notifications_form'; import ProjectsList from '~/projects_list'; @@ -11,7 +10,6 @@ import GroupTabs from './group_tabs'; import initInviteMembersBanner from '~/groups/init_invite_members_banner'; export default function initGroupDetails(actionName = 'show') { - const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED]; const dashPath = getDashPath(); let action = loadableActions.includes(dashPath) ? dashPath : getPagePath(1); @@ -25,8 +23,5 @@ export default function initGroupDetails(actionName = 'show') { notificationsDropdown(); new ProjectsList(); - if (newGroupChildWrapper) { - new NewGroupChild(newGroupChildWrapper); - } initInviteMembersBanner(); } diff --git a/app/assets/javascripts/pages/profiles/preferences/show/index.js b/app/assets/javascripts/pages/profiles/preferences/show/index.js new file mode 100644 index 00000000000..d489ed80f46 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/preferences/show/index.js @@ -0,0 +1,3 @@ +import initProfilePreferences from '~/profile/preferences/profile_preferences_bundle'; + +document.addEventListener('DOMContentLoaded', initProfilePreferences); diff --git a/app/assets/javascripts/pages/projects/alert_management/details/index.js b/app/assets/javascripts/pages/projects/alert_management/details/index.js index 0124795e1af..a20f6713c9d 100644 --- a/app/assets/javascripts/pages/projects/alert_management/details/index.js +++ b/app/assets/javascripts/pages/projects/alert_management/details/index.js @@ -1,5 +1,3 @@ import AlertDetails from '~/alert_management/details'; -document.addEventListener('DOMContentLoaded', () => { - AlertDetails('#js-alert_details'); -}); +AlertDetails('#js-alert_details'); diff --git a/app/assets/javascripts/pages/projects/alert_management/index/index.js b/app/assets/javascripts/pages/projects/alert_management/index/index.js index 1e98bcfd2eb..ed352f0ad7a 100644 --- a/app/assets/javascripts/pages/projects/alert_management/index/index.js +++ b/app/assets/javascripts/pages/projects/alert_management/index/index.js @@ -1,5 +1,3 @@ import AlertManagementList from '~/alert_management/list'; -document.addEventListener('DOMContentLoaded', () => { - AlertManagementList(); -}); +AlertManagementList(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index f2e8cb38ef5..1879e263ce7 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -7,7 +7,6 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import '~/sourcegraph/load'; import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue'; import { parseBoolean } from '~/lib/utils/common_utils'; -import { isExperimentEnabled } from '~/lib/utils/experimentation'; const createGitlabCiYmlVisualization = (containerId = '#js-blob-toggle-graph-preview') => { const el = document.querySelector(containerId); @@ -74,7 +73,7 @@ document.addEventListener('DOMContentLoaded', () => { ); } - if (isExperimentEnabled('suggestPipeline')) { + if (gon.features?.suggestPipeline) { const successPipelineEl = document.querySelector('.js-success-pipeline-modal'); if (successPipelineEl) { diff --git a/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js b/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js deleted file mode 100644 index df635522e94..00000000000 --- a/app/assets/javascripts/pages/projects/ci/lints/ci_lint_editor.js +++ /dev/null @@ -1,32 +0,0 @@ -import EditorLite from '~/editor/editor_lite'; - -export default class CILintEditor { - constructor() { - this.clearYml = document.querySelector('.clear-yml'); - this.clearYml.addEventListener('click', this.clear.bind(this)); - - return this.initEditorLite(); - } - - clear() { - this.editor.setValue(''); - } - - initEditorLite() { - const editorEl = document.getElementById('editor'); - const fileContentEl = document.getElementById('content'); - const form = document.querySelector('.js-ci-lint-form'); - - const rootEditor = new EditorLite(); - - this.editor = rootEditor.createInstance({ - el: editorEl, - blobPath: '.gitlab-ci.yml', - blobContent: editorEl.innerText, - }); - - form.addEventListener('submit', () => { - fileContentEl.value = this.editor.getValue(); - }); - } -} diff --git a/app/assets/javascripts/pages/projects/ci/lints/new/index.js b/app/assets/javascripts/pages/projects/ci/lints/new/index.js deleted file mode 100644 index 957801320c9..00000000000 --- a/app/assets/javascripts/pages/projects/ci/lints/new/index.js +++ /dev/null @@ -1,17 +0,0 @@ -import createFlash from '~/flash'; -import { __ } from '~/locale'; - -const ERROR = __('An error occurred while rendering the linter'); - -document.addEventListener('DOMContentLoaded', () => { - if (gon?.features?.ciLintVue) { - import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index') - .then(module => module.default()) - .catch(() => createFlash(ERROR)); - } else { - import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor') - // eslint-disable-next-line new-cap - .then(module => new module.default()) - .catch(() => createFlash(ERROR)); - } -}); diff --git a/app/assets/javascripts/pages/projects/ci/lints/show/index.js b/app/assets/javascripts/pages/projects/ci/lints/show/index.js index 957801320c9..6e1cdf557b5 100644 --- a/app/assets/javascripts/pages/projects/ci/lints/show/index.js +++ b/app/assets/javascripts/pages/projects/ci/lints/show/index.js @@ -1,17 +1,3 @@ -import createFlash from '~/flash'; -import { __ } from '~/locale'; +import initCiLint from '~/ci_lint'; -const ERROR = __('An error occurred while rendering the linter'); - -document.addEventListener('DOMContentLoaded', () => { - if (gon?.features?.ciLintVue) { - import(/* webpackChunkName: 'ciLintIndex' */ '~/ci_lint/index') - .then(module => module.default()) - .catch(() => createFlash(ERROR)); - } else { - import(/* webpackChunkName: 'ciLintEditor' */ '../ci_lint_editor') - // eslint-disable-next-line new-cap - .then(module => new module.default()) - .catch(() => createFlash(ERROR)); - } -}); +initCiLint(); diff --git a/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js new file mode 100644 index 00000000000..67d32648ce8 --- /dev/null +++ b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js @@ -0,0 +1,3 @@ +import { initPipelineEditor } from '~/pipeline_editor'; + +initPipelineEditor(); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 32fb35f97e3..e0bd49bf6ef 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -40,7 +40,7 @@ document.addEventListener('DOMContentLoaded', () => { new Diff(); }) .catch(() => { - flash(__('An error occurred while retrieving diff files')); + flash({ message: __('An error occurred while retrieving diff files') }); }); } else { new Diff(); diff --git a/app/assets/javascripts/pages/projects/error_tracking/details/index.js b/app/assets/javascripts/pages/projects/error_tracking/details/index.js index 25d1c744e1b..a750b3bac87 100644 --- a/app/assets/javascripts/pages/projects/error_tracking/details/index.js +++ b/app/assets/javascripts/pages/projects/error_tracking/details/index.js @@ -1,5 +1,3 @@ import ErrorTrackingDetails from '~/error_tracking/details'; -document.addEventListener('DOMContentLoaded', () => { - ErrorTrackingDetails(); -}); +ErrorTrackingDetails(); diff --git a/app/assets/javascripts/pages/projects/error_tracking/index/index.js b/app/assets/javascripts/pages/projects/error_tracking/index/index.js index ead81cd5d2d..fda0a35de9c 100644 --- a/app/assets/javascripts/pages/projects/error_tracking/index/index.js +++ b/app/assets/javascripts/pages/projects/error_tracking/index/index.js @@ -1,5 +1,3 @@ import ErrorTrackingList from '~/error_tracking/list'; -document.addEventListener('DOMContentLoaded', () => { - ErrorTrackingList(); -}); +ErrorTrackingList(); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 74abd1f67a5..6cf36463bda 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -5,6 +5,8 @@ import { __ } from '~/locale'; import CodeCoverage from '../components/code_coverage.vue'; import SeriesDataMixin from './series_data_mixin'; +const seriesDataToBarData = raw => Object.entries(raw).map(([name, data]) => ({ name, data })); + document.addEventListener('DOMContentLoaded', () => { waitForCSSLoaded(() => { const languagesContainer = document.getElementById('js-languages-chart'); @@ -41,13 +43,13 @@ document.addEventListener('DOMContentLoaded', () => { }, computed: { seriesData() { - return { full: this.chartData.map(d => [d.label, d.value]) }; + return [{ name: 'full', data: this.chartData.map(d => [d.label, d.value]) }]; }, }, render(h) { return h(GlColumnChart, { props: { - data: this.seriesData, + bars: this.seriesData, xAxisTitle: __('Used programming language'), yAxisTitle: __('Percentage'), xAxisType: 'category', @@ -86,7 +88,7 @@ document.addEventListener('DOMContentLoaded', () => { render(h) { return h(GlColumnChart, { props: { - data: this.seriesData, + bars: seriesDataToBarData(this.seriesData), xAxisTitle: __('Day of month'), yAxisTitle: __('No. of commits'), xAxisType: 'category', @@ -113,13 +115,13 @@ document.addEventListener('DOMContentLoaded', () => { acc.push([key, weekDays[key]]); return acc; }, []); - return { full: data }; + return [{ name: 'full', data }]; }, }, render(h) { return h(GlColumnChart, { props: { - data: this.seriesData, + bars: this.seriesData, xAxisTitle: __('Weekday'), yAxisTitle: __('No. of commits'), xAxisType: 'category', @@ -143,7 +145,7 @@ document.addEventListener('DOMContentLoaded', () => { render(h) { return h(GlColumnChart, { props: { - data: this.seriesData, + bars: seriesDataToBarData(this.seriesData), xAxisTitle: __('Hour (UTC)'), yAxisTitle: __('No. of commits'), xAxisType: 'category', diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue index a9079f91f50..6dd50958fa4 100644 --- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -153,10 +153,10 @@ export default { :option="chartOptions" :format-tooltip-text="formatTooltipText" > - <template v-if="canShowData" #tooltipTitle> + <template v-if="canShowData" #tooltip-title> {{ tooltipTitle }} </template> - <template v-if="canShowData" #tooltipContent> + <template v-if="canShowData" #tooltip-content> <gl-sprintf :message="__('Code Coverage: %{coveragePercentage}%{percentSymbol}')"> <template #coveragePercentage> {{ coveragePercentage }} diff --git a/app/assets/javascripts/pages/projects/incidents/index/index.js b/app/assets/javascripts/pages/projects/incidents/index/index.js index c37ae862a85..bbae605b31f 100644 --- a/app/assets/javascripts/pages/projects/incidents/index/index.js +++ b/app/assets/javascripts/pages/projects/incidents/index/index.js @@ -1,5 +1,3 @@ import IncidentsList from '~/incidents/list'; -document.addEventListener('DOMContentLoaded', () => { - IncidentsList(); -}); +IncidentsList(); diff --git a/app/assets/javascripts/pages/projects/incidents/show/index.js b/app/assets/javascripts/pages/projects/incidents/show/index.js index 3324cfc0335..5b3f03cd57e 100644 --- a/app/assets/javascripts/pages/projects/incidents/show/index.js +++ b/app/assets/javascripts/pages/projects/incidents/show/index.js @@ -2,10 +2,8 @@ import initSidebarBundle from '~/sidebar/sidebar_bundle'; import initRelatedIssues from '~/related_issues'; import initShow from '../../issues/show'; -document.addEventListener('DOMContentLoaded', () => { - initShow(); - if (!gon.features?.vueIssuableSidebar) { - initSidebarBundle(); - } - initRelatedIssues(); -}); +initShow(); +if (!gon.features?.vueIssuableSidebar) { + initSidebarBundle(); +} +initRelatedIssues(); diff --git a/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js deleted file mode 100644 index 534614349bf..00000000000 --- a/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import initIssuablesList from '~/issues_list'; - -document.addEventListener('DOMContentLoaded', () => { - initIssuablesList(); -}); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index a58b5d3f37c..4b15e435f60 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -5,7 +5,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; import { store } from '~/notes/stores'; -import initIssueApp from '~/issue_show/issue'; +import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue'; import initIncidentApp from '~/issue_show/incident'; import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; @@ -24,13 +24,14 @@ export default function() { initIncidentApp(issuableData); break; case IssuableType.Issue: - initIssueApp(issuableData); + initIssuableApp(issuableData, store); break; default: break; } initIssuableHeaderWarning(store); + initIssueHeaderActions(store); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue index f58e4909a08..7b5e0f70b7b 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -1,15 +1,21 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlModal } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; -import { s__, sprintf } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; import eventHub from '../event_hub'; export default { + primaryProps: { + text: s__('Labels|Promote Label'), + attributes: [{ variant: 'warning' }, { category: 'primary' }], + }, + cancelProps: { + text: __('Cancel'), + }, components: { - GlModal: DeprecatedModal2, + GlModal, GlSprintf, }, props: { @@ -72,12 +78,12 @@ export default { </script> <template> <gl-modal - id="promote-label-modal" - :footer-primary-button-text="s__('Labels|Promote Label')" - footer-primary-button-variant="warning" - @submit="onSubmit" + modal-id="promote-label-modal" + :action-primary="$options.primaryProps" + :action-cancel="$options.cancelProps" + @primary="onSubmit" > - <div slot="title" class="modal-title-with-label"> + <div slot="modal-title" class="modal-title-with-label"> <gl-sprintf :message=" s__( diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js index 36cf485f33d..ee129011f9a 100644 --- a/app/assets/javascripts/pages/projects/labels/index/index.js +++ b/app/assets/javascripts/pages/projects/labels/index/index.js @@ -27,71 +27,55 @@ const initLabelIndex = () => { eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished); }; - const onDeleteButtonClick = event => { - const button = event.currentTarget; - const modalProps = { - labelTitle: button.dataset.labelTitle, - labelColor: button.dataset.labelColor, - labelTextColor: button.dataset.labelTextColor, - url: button.dataset.url, - groupName: button.dataset.groupName, - }; - eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); - eventHub.$emit('promoteLabelModal.props', modalProps); - }; - const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button'); - promoteLabelButtons.forEach(button => { - button.addEventListener('click', onDeleteButtonClick); - }); - eventHub.$once('promoteLabelModal.mounted', () => { - promoteLabelButtons.forEach(button => { - button.removeAttribute('disabled'); - }); - }); + return new Vue({ + el: '#js-promote-label-modal', + data() { + return { + modalProps: { + labelTitle: '', + labelColor: '', + labelTextColor: '', + url: '', + groupName: '', + }, + }; + }, + mounted() { + eventHub.$on('promoteLabelModal.props', this.setModalProps); + eventHub.$emit('promoteLabelModal.mounted'); - const promoteLabelModal = document.getElementById('promote-label-modal'); - let promoteLabelModalComponent; + promoteLabelButtons.forEach(button => { + button.removeAttribute('disabled'); + button.addEventListener('click', () => { + this.$root.$emit('bv::show::modal', 'promote-label-modal'); + eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); - if (promoteLabelModal) { - promoteLabelModalComponent = new Vue({ - el: promoteLabelModal, - components: { - PromoteLabelModal, - }, - data() { - return { - modalProps: { - labelTitle: '', - labelColor: '', - labelTextColor: '', - url: '', - groupName: '', - }, - }; - }, - mounted() { - eventHub.$on('promoteLabelModal.props', this.setModalProps); - eventHub.$emit('promoteLabelModal.mounted'); - }, - beforeDestroy() { - eventHub.$off('promoteLabelModal.props', this.setModalProps); - }, - methods: { - setModalProps(modalProps) { - this.modalProps = modalProps; - }, - }, - render(createElement) { - return createElement('promote-label-modal', { - props: this.modalProps, + this.setModalProps({ + labelTitle: button.dataset.labelTitle, + labelColor: button.dataset.labelColor, + labelTextColor: button.dataset.labelTextColor, + url: button.dataset.url, + groupName: button.dataset.groupName, + }); }); + }); + }, + beforeDestroy() { + eventHub.$off('promoteLabelModal.props', this.setModalProps); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; }, - }); - } - - return promoteLabelModalComponent; + }, + render(createElement) { + return createElement(PromoteLabelModal, { + props: this.modalProps, + }); + }, + }); }; document.addEventListener('DOMContentLoaded', initLabelIndex); diff --git a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js index d3028aec313..606439866ea 100644 --- a/app/assets/javascripts/pages/projects/metrics_dashboard/index.js +++ b/app/assets/javascripts/pages/projects/metrics_dashboard/index.js @@ -1,3 +1,3 @@ import monitoringApp from '~/monitoring/monitoring_app'; -document.addEventListener('DOMContentLoaded', monitoringApp); +monitoringApp(); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js index 6197dc8a9db..90d2df50d5a 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/index/index.js @@ -1,16 +1,24 @@ import Vue from 'vue'; import PipelineSchedulesCallout from '../shared/components/pipeline_schedules_callout.vue'; -document.addEventListener( - 'DOMContentLoaded', - () => - new Vue({ - el: '#pipeline-schedules-callout', - components: { - 'pipeline-schedules-callout': PipelineSchedulesCallout, - }, - render(createElement) { - return createElement('pipeline-schedules-callout'); - }, - }), -); +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('pipeline-schedules-callout'); + + if (!el) { + return; + } + + const { docsUrl, illustrationUrl } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(PipelineSchedulesCallout); + }, + provide: { + docsUrl, + illustrationUrl, + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue index a138a3a3425..8ee9d481466 100644 --- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue +++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue @@ -1,7 +1,7 @@ <script> import Vue from 'vue'; import Cookies from 'js-cookie'; -import { GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import Translate from '../../../../../vue_shared/translate'; import { parseBoolean } from '~/lib/utils/common_utils'; @@ -12,12 +12,11 @@ const cookieKey = 'pipeline_schedules_callout_dismissed'; export default { name: 'PipelineSchedulesCallout', components: { - GlIcon, + GlButton, }, + inject: ['docsUrl', 'illustrationUrl'], data() { return { - docsUrl: document.getElementById('pipeline-schedules-callout').dataset.docsUrl, - imageUrl: document.getElementById('pipeline-schedules-callout').dataset.imageUrl, calloutDismissed: parseBoolean(Cookies.get(cookieKey)), }; }, @@ -31,12 +30,16 @@ export default { </script> <template> <div v-if="!calloutDismissed" class="pipeline-schedules-user-callout user-callout"> - <div class="bordered-box landing content-block"> - <button id="dismiss-callout-btn" class="btn btn-default close" @click="dismissCallout"> - <gl-icon name="close" aria-hidden="true" /> - </button> - <div class="svg-container"> - <img :src="imageUrl" /> + <div class="bordered-box landing content-block" data-testid="innerContent"> + <gl-button + category="tertiary" + icon="close" + :aria-label="__('Dismiss')" + class="gl-absolute gl-top-2 gl-right-2" + @click="dismissCallout" + /> + <div class="svg-content"> + <img :src="illustrationUrl" /> </div> <div class="user-callout-copy"> <h4>{{ __('Scheduling Pipelines') }}</h4> diff --git a/app/assets/javascripts/pages/projects/pipelines/show/index.js b/app/assets/javascripts/pages/projects/pipelines/show/index.js index 7a57e417b41..d3f46b7e025 100644 --- a/app/assets/javascripts/pages/projects/pipelines/show/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/show/index.js @@ -1,7 +1,5 @@ import initPipelineDetails from '~/pipelines/pipeline_details_bundle'; import initPipelines from '../init_pipelines'; -document.addEventListener('DOMContentLoaded', () => { - initPipelines(); - initPipelineDetails(); -}); +initPipelines(); +initPipelineDetails(); diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 2f27814a692..5317093c4cf 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -57,7 +57,7 @@ export default class Project { $('.project-refs-select').on('change', function() { return $(this) .parents('form') - .submit(); + .trigger('submit'); }); } @@ -156,11 +156,32 @@ export default class Project { }, clicked(options) { const { e } = options; - if (!shouldVisit) { - e.preventDefault(); + e.preventDefault(); + + // Since this page does not reload when changing directories in a repo + // the rendered links do not have the path to the current directory. + // This updates the path based on the current url and then opens + // the the url with the updated path parameter. + if (shouldVisit) { + const selectedUrl = new URL(e.target.href); + const loc = window.location.href; + + if (loc.includes('/-/')) { + const refs = this.fullData.Branches.concat(this.fullData.Tags); + const currentRef = refs.find(ref => loc.indexOf(ref) > -1); + if (currentRef) { + const targetPath = loc.split(currentRef)[1].slice(1); + selectedUrl.searchParams.set('path', targetPath); + } + } + + // Open in new window if "meta" key is pressed + if (e.metaKey) { + window.open(selectedUrl.href, '_blank'); + } else { + window.location.href = selectedUrl.href; + } } - /* The actual process is removed since `link.href` in `RenderRow` contains the full target. - * It makes the visitable link can be visited when opening on a new tab of browser */ }, }); }); diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 40816420eef..5d4c1595342 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -1,48 +1,35 @@ import initSettingsPanels from '~/settings_panels'; import SecretValues from '~/behaviors/secret_values'; -import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; import initVariableList from '~/ci_variable_list'; import initDeployFreeze from '~/deploy_freeze'; import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers'; +import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; -document.addEventListener('DOMContentLoaded', () => { - // Initialize expandable settings panels - initSettingsPanels(); +// Initialize expandable settings panels +initSettingsPanels(); - const runnerToken = document.querySelector('.js-secret-runner-token'); - if (runnerToken) { - const runnerTokenSecretValue = new SecretValues({ - container: runnerToken, - }); - runnerTokenSecretValue.init(); - } - - if (gon.features.newVariablesUi) { - initVariableList(); - } else { - const variableListEl = document.querySelector('.js-ci-variable-list-section'); - // eslint-disable-next-line no-new - new AjaxVariableList({ - container: variableListEl, - saveButton: variableListEl.querySelector('.js-ci-variables-save-button'), - errorBox: variableListEl.querySelector('.js-ci-variable-error-box'), - saveEndpoint: variableListEl.dataset.saveEndpoint, - maskableRegex: variableListEl.dataset.maskableRegex, - }); - } - - // hide extra auto devops settings based checkbox state - const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings'); - const instanceDefaultBadge = document.querySelector('.js-instance-default-badge'); - document.querySelector('.js-toggle-extra-settings').addEventListener('click', event => { - const { target } = event; - if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none'; - autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked); +const runnerToken = document.querySelector('.js-secret-runner-token'); +if (runnerToken) { + const runnerTokenSecretValue = new SecretValues({ + container: runnerToken, }); + runnerTokenSecretValue.init(); +} - registrySettingsApp(); - initDeployFreeze(); +initVariableList(); - initSettingsPipelinesTriggers(); +// hide extra auto devops settings based checkbox state +const autoDevOpsExtraSettings = document.querySelector('.js-extra-settings'); +const instanceDefaultBadge = document.querySelector('.js-instance-default-badge'); +document.querySelector('.js-toggle-extra-settings').addEventListener('click', event => { + const { target } = event; + if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none'; + autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked); }); + +registrySettingsApp(); +initDeployFreeze(); + +initSettingsPipelinesTriggers(); +initInstallRunner(); diff --git a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js index f2cf2eb9b28..bf9ccdbf9a8 100644 --- a/app/assets/javascripts/pages/projects/settings/integrations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/integrations/show/index.js @@ -1,6 +1,4 @@ import PersistentUserCallout from '~/persistent_user_callout'; -document.addEventListener('DOMContentLoaded', () => { - const callout = document.querySelector('.js-webhooks-moved-alert'); - PersistentUserCallout.factory(callout); -}); +const callout = document.querySelector('.js-webhooks-moved-alert'); +PersistentUserCallout.factory(callout); diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 1b9ec44ed4a..153ccffd472 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -5,13 +5,11 @@ import mountGrafanaIntegration from '~/grafana_integration'; import initSettingsPanels from '~/settings_panels'; import initIncidentsSettings from '~/incidents_settings'; -document.addEventListener('DOMContentLoaded', () => { - initIncidentsSettings(); - mountErrorTrackingForm(); - mountOperationSettings(); - mountGrafanaIntegration(); - if (!IS_EE) { - initSettingsPanels(); - } - mountAlertsSettings(document.querySelector('.js-alerts-settings')); -}); +initIncidentsSettings(); +mountErrorTrackingForm(); +mountOperationSettings(); +mountGrafanaIntegration(); +if (!IS_EE) { + initSettingsPanels(); +} +mountAlertsSettings(document.querySelector('.js-alerts-settings')); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index bcf82e264d1..e50add3b0a4 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -68,6 +68,11 @@ export default { required: false, default: false, }, + requirementsAvailable: { + type: Boolean, + required: false, + default: false, + }, visibilityHelpPath: { type: String, required: false, @@ -132,6 +137,7 @@ export default { snippetsAccessLevel: featureAccessLevel.EVERYONE, pagesAccessLevel: featureAccessLevel.EVERYONE, metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, + requirementsAccessLevel: featureAccessLevel.EVERYONE, containerRegistryEnabled: true, lfsEnabled: true, requestAccessEnabled: true, @@ -234,6 +240,10 @@ export default { featureAccessLevel.PROJECT_MEMBERS, this.metricsDashboardAccessLevel, ); + this.requirementsAccessLevel = Math.min( + featureAccessLevel.PROJECT_MEMBERS, + this.requirementsAccessLevel, + ); if (this.pagesAccessLevel === featureAccessLevel.EVERYONE) { // When from Internal->Private narrow access for only members this.pagesAccessLevel = featureAccessLevel.PROJECT_MEMBERS; @@ -257,6 +267,9 @@ export default { this.pagesAccessLevel = featureAccessLevel.EVERYONE; if (this.metricsDashboardAccessLevel === featureAccessLevel.PROJECT_MEMBERS) this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE; + if (this.requirementsAccessLevel === featureAccessLevel.PROJECT_MEMBERS) + this.requirementsAccessLevel = featureAccessLevel.EVERYONE; + this.highlightChanges(); } }, @@ -482,6 +495,18 @@ export default { </project-setting-row> </div> <project-setting-row + v-if="requirementsAvailable" + ref="requirements-settings" + :label="s__('ProjectSettings|Requirements')" + :help-text="s__('ProjectSettings|Requirements management system for this project')" + > + <project-feature-setting + v-model="requirementsAccessLevel" + :options="featureAccessLevelOptions" + name="project[project_feature_attributes][requirements_access_level]" + /> + </project-setting-row> + <project-setting-row ref="wiki-settings" :label="s__('ProjectSettings|Wiki')" :help-text="s__('ProjectSettings|Pages for project documentation')" diff --git a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js index f69ca6e27b3..ae0936417ad 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js @@ -2,6 +2,7 @@ export default { data() { return { packagesEnabled: false, + requirementsEnabled: false, }; }, watch: { diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index dd8141d34c7..413b2d01621 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -10,6 +10,8 @@ import leaveByUrl from '~/namespaces/leave_by_url'; import Star from '../../../star'; import notificationsDropdown from '../../../notifications_dropdown'; import { showLearnGitLabProjectPopover } from '~/onboarding_issues'; +import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; +import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; initReadMore(); new Star(); // eslint-disable-line no-new @@ -42,3 +44,6 @@ showLearnGitLabProjectPopover(); notificationsDropdown(); new ShortcutsNavigation(); // eslint-disable-line no-new + +initInviteMembersTrigger(); +initInviteMembersModal(); diff --git a/app/assets/javascripts/pages/projects/terraform/index/index.js b/app/assets/javascripts/pages/projects/terraform/index/index.js new file mode 100644 index 00000000000..6f9f820f8e1 --- /dev/null +++ b/app/assets/javascripts/pages/projects/terraform/index/index.js @@ -0,0 +1,3 @@ +import loadTerraformVues from '~/terraform'; + +loadTerraformVues(); diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js index 721219874cf..88f2f30aad9 100644 --- a/app/assets/javascripts/pages/search/show/index.js +++ b/app/assets/javascripts/pages/search/show/index.js @@ -1,7 +1,7 @@ import Search from './search'; -import initSearchApp from '~/search'; +import { initSearchApp } from '~/search'; document.addEventListener('DOMContentLoaded', () => { initSearchApp(); - return new Search(); + return new Search(); // Deprecated Dropdown (Projects) }); diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js index 2cd333f26e1..03675f1ce66 100644 --- a/app/assets/javascripts/pages/search/show/search.js +++ b/app/assets/javascripts/pages/search/show/search.js @@ -1,52 +1,26 @@ import $ from 'jquery'; +import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import { deprecatedCreateFlash as Flash } from '~/flash'; import Api from '~/api'; import { __ } from '~/locale'; import Project from '~/pages/projects/project'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { visitUrl, queryToObject } from '~/lib/utils/url_utility'; import refreshCounts from './refresh_counts'; -import setHighlightClass from './highlight_blob_search_result'; export default class Search { constructor() { - setHighlightClass(); - const $groupDropdown = $('.js-search-group-dropdown'); + setHighlightClass(); // Code Highlighting const $projectDropdown = $('.js-search-project-dropdown'); this.searchInput = '.js-search-input'; this.searchClear = '.js-search-clear'; - this.groupId = $groupDropdown.data('groupId'); + const query = queryToObject(window.location.search); + this.groupId = query?.group_id; this.eventListeners(); refreshCounts(); - initDeprecatedJQueryDropdown($groupDropdown, { - selectable: true, - filterable: true, - filterRemote: true, - fieldName: 'group_id', - search: { - fields: ['full_name'], - }, - data(term, callback) { - return Api.groups(term, {}, data => { - data.unshift({ - full_name: __('Any'), - }); - data.splice(1, 0, { type: 'divider' }); - return callback(data); - }); - }, - id(obj) { - return obj.id; - }, - text(obj) { - return obj.full_name; - }, - clicked: () => Search.submitSearch(), - }); - initDeprecatedJQueryDropdown($projectDropdown, { selectable: true, filterable: true, diff --git a/app/assets/javascripts/pages/shared/mount_runner_instructions.js b/app/assets/javascripts/pages/shared/mount_runner_instructions.js new file mode 100644 index 00000000000..b7662155339 --- /dev/null +++ b/app/assets/javascripts/pages/shared/mount_runner_instructions.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import InstallRunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue'; + +Vue.use(VueApollo); + +export function initInstallRunner(componentId = 'js-install-runner') { + const installRunnerEl = document.getElementById(componentId); + const { projectPath, groupPath } = installRunnerEl?.dataset; + + if (installRunnerEl) { + const defaultClient = createDefaultClient(); + + const apolloProvider = new VueApollo({ + defaultClient, + }); + + // eslint-disable-next-line no-new + new Vue({ + el: installRunnerEl, + apolloProvider, + provide: { + projectPath, + groupPath, + }, + render(createElement) { + return createElement(InstallRunnerInstructions); + }, + }); + } +} diff --git a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue index 653aad3d2f5..3792dad376b 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/delete_wiki_modal.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { escape } from 'lodash'; -import { s__, sprintf } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; export default { components: { @@ -29,12 +29,6 @@ export default { }, }, computed: { - modalId() { - return 'delete-wiki-modal'; - }, - message() { - return s__('WikiPageConfirmDelete|Are you sure you want to delete this page?'); - }, title() { return sprintf( s__('WikiPageConfirmDelete|Delete page %{pageTitle}?'), @@ -44,6 +38,21 @@ export default { false, ); }, + primaryProps() { + return { + text: this.$options.i18n.deletePageText, + attributes: { + variant: 'danger', + 'data-qa-selector': 'confirm_deletion_button', + 'data-testid': 'confirm_deletion_button', + }, + }; + }, + cancelProps() { + return { + text: this.$options.i18n.cancelButtonText, + }; + }, }, methods: { onSubmit() { @@ -51,30 +60,36 @@ export default { this.$refs.form.submit(); }, }, + i18n: { + deletePageText: s__('WikiPageConfirmDelete|Delete page'), + modalBody: s__('WikiPageConfirmDelete|Are you sure you want to delete this page?'), + cancelButtonText: __('Cancel'), + }, + modal: { + modalId: 'delete-wiki-modal', + }, }; </script> <template> <div class="d-inline-block"> <gl-button - v-gl-modal="modalId" - category="primary" + v-gl-modal="$options.modal.modalId" + category="secondary" variant="danger" data-qa-selector="delete_button" > - {{ __('Delete') }} + {{ $options.i18n.deletePageText }} </gl-button> <gl-modal :title="title" - :action-primary="{ - text: s__('WikiPageConfirmDelete|Delete page'), - attributes: { variant: 'danger', 'data-qa-selector': 'confirm_deletion_button' }, - }" - :modal-id="modalId" - title-tag="h4" + :action-primary="primaryProps" + :action-cancel="cancelProps" + :modal-id="$options.modal.modalId" + size="sm" @ok="onSubmit" > - {{ message }} + {{ $options.i18n.modalBody }} <form ref="form" :action="deleteWikiUrl" method="post" class="js-requires-input"> <input ref="method" type="hidden" name="_method" value="delete" /> <input :value="csrfToken" type="hidden" name="authenticity_token" /> diff --git a/app/assets/javascripts/pages/shared/wikis/wikis.js b/app/assets/javascripts/pages/shared/wikis/wikis.js index ab948fd106f..fe9caba351e 100644 --- a/app/assets/javascripts/pages/shared/wikis/wikis.js +++ b/app/assets/javascripts/pages/shared/wikis/wikis.js @@ -1,6 +1,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { s__, sprintf } from '~/locale'; import Tracking from '~/tracking'; +import showToast from '~/vue_shared/plugins/global_toast'; const MARKDOWN_LINK_TEXT = { markdown: '[Link Title](page-slug)', @@ -63,6 +64,7 @@ export default class Wikis { } Wikis.trackPageView(); + Wikis.showToasts(); } handleWikiTitleChange(e) { @@ -116,4 +118,9 @@ export default class Wikis { }, }); } + + static showToasts() { + const toasts = document.querySelectorAll('.js-toast-message'); + toasts.forEach(toast => showToast(toast.dataset.message)); + } } diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index eb0a5efe75c..54666af540e 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -125,9 +125,6 @@ export default class ActivityCalendar { this.renderMonths(); this.renderDayTitles(); this.renderKey(); - - // Init tooltips - $(`${container} .js-tooltip`).tooltip({ html: true }); } // Add extra padding for the last month label if it is also the last column @@ -191,7 +188,8 @@ export default class ActivityCalendar { stamp.count !== 0 ? this.color(Math.min(stamp.count, 40)) : '#ededed', ) .attr('title', stamp => formatTooltipText(stamp)) - .attr('class', 'user-contrib-cell js-tooltip') + .attr('class', 'user-contrib-cell has-tooltip') + .attr('data-html', true) .attr('data-container', 'body') .on('click', this.clickDay); } @@ -279,9 +277,10 @@ export default class ActivityCalendar { .attr('x', (color, i) => this.daySizeWithSpace * i) .attr('y', 0) .attr('fill', color => color) - .attr('class', 'js-tooltip') + .attr('class', 'has-tooltip') .attr('title', (color, i) => keyValues[i]) - .attr('data-container', 'body'); + .attr('data-container', 'body') + .attr('data-html', true); } initColor() { diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index cfc6dc61f9f..8adbc2a8168 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -4,11 +4,6 @@ import UserCallout from '~/user_callout'; import UserTabs from './user_tabs'; function initUserProfile(action) { - // place profile avatars to top - $('.profile-groups-avatars').tooltip({ - placement: 'top', - }); - // eslint-disable-next-line no-new new UserTabs({ parentEl: '.user-profile', action }); diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 9d66c784750..2485853afc7 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -5,7 +5,6 @@ import Activities from '~/activities'; import { localTimeAgo } from '~/lib/utils/datetime_utility'; import AjaxCache from '~/lib/utils/ajax_cache'; import { __ } from '~/locale'; -import { deprecatedCreateFlash as flash } from '~/flash'; import ActivityCalendar from './activity_calendar'; import UserOverviewBlock from './user_overview_block'; @@ -63,9 +62,9 @@ import UserOverviewBlock from './user_overview_block'; */ const CALENDAR_TEMPLATE = ` - <div class="clearfix calendar"> + <div class="calendar"> <div class="js-contrib-calendar"></div> - <div class="calendar-hint bottom-right"></div> + <div class="calendar-hint"></div> </div> `; @@ -214,7 +213,17 @@ export default class UserTabs { AjaxCache.retrieve(calendarPath) .then(data => UserTabs.renderActivityCalendar(data, $calendarWrap)) - .catch(() => flash(__('There was an error loading users activity calendar.'))); + .catch(() => { + const cWrap = $calendarWrap[0]; + cWrap.querySelector('.spinner').classList.add('invisible'); + cWrap.querySelector('.user-calendar-error').classList.remove('invisible'); + cWrap.querySelector('.user-calendar-error .js-retry-load').addEventListener('click', e => { + e.preventDefault(); + cWrap.querySelector('.user-calendar-error').classList.add('invisible'); + cWrap.querySelector('.spinner').classList.remove('invisible'); + this.loadActivityCalendar(); + }); + }); } static renderActivityCalendar(data, $calendarWrap) { diff --git a/app/assets/javascripts/performance_constants.js b/app/assets/javascripts/performance/constants.js index 6b6b6f1da40..816eb9b3a66 100644 --- a/app/assets/javascripts/performance_constants.js +++ b/app/assets/javascripts/performance/constants.js @@ -29,3 +29,17 @@ export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished'; 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'; + +// +// MR Diffs namespace + +// Marks +export const MR_DIFFS_MARK_FILE_TREE_START = 'mr-diffs-mark-file-tree-start'; +export const MR_DIFFS_MARK_FILE_TREE_END = 'mr-diffs-mark-file-tree-end'; +export const MR_DIFFS_MARK_DIFF_FILES_START = 'mr-diffs-mark-diff-files-start'; +export const MR_DIFFS_MARK_FIRST_DIFF_FILE_SHOWN = 'mr-diffs-mark-first-diff-file-shown'; +export const MR_DIFFS_MARK_DIFF_FILES_END = 'mr-diffs-mark-diff-files-end'; + +// Measures +export const MR_DIFFS_MEASURE_FILE_TREE_DONE = 'mr-diffs-measure-file-tree-done'; +export const MR_DIFFS_MEASURE_DIFF_FILES_DONE = 'mr-diffs-measure-diff-files-done'; diff --git a/app/assets/javascripts/performance_utils.js b/app/assets/javascripts/performance/utils.js index 1c87ee2086e..1c87ee2086e 100644 --- a/app/assets/javascripts/performance_utils.js +++ b/app/assets/javascripts/performance/utils.js diff --git a/app/assets/javascripts/performance/vue_performance_plugin.js b/app/assets/javascripts/performance/vue_performance_plugin.js new file mode 100644 index 00000000000..7329b83b1d1 --- /dev/null +++ b/app/assets/javascripts/performance/vue_performance_plugin.js @@ -0,0 +1,53 @@ +const ComponentPerformancePlugin = { + install(Vue, options) { + Vue.mixin({ + beforeCreate() { + /** Make sure the component you want to measure has `name` option defined + * and it matches the one you pass as the plugin option. Example: + * + * my_component.vue: + * + * ``` + * export default { + * name: 'MyComponent' + * ... + * } + * ``` + * + * index.js (where you initialize your Vue app containing <my-component>): + * + * ``` + * Vue.use(PerformancePlugin, { + * components: [ + * 'MyComponent', + * ] + * }); + * ``` + */ + const componentName = this.$options.name; + if (options?.components?.indexOf(componentName) !== -1) { + const tagName = `<${componentName}>`; + if (!performance.getEntriesByName(`${tagName}-start`).length) { + performance.mark(`${tagName}-start`); + } + } + }, + mounted() { + const componentName = this.$options.name; + if (options?.components?.indexOf(componentName) !== -1) { + this.$nextTick(() => { + window.requestAnimationFrame(() => { + const tagName = `<${componentName}>`; + if (!performance.getEntriesByName(`${tagName}-end`).length) { + performance.mark(`${tagName}-end`); + performance.measure(`${tagName}`, `${tagName}-start`); + } + }); + }); + } + }, + }); + }, +}; + +export default ComponentPerformancePlugin; diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue index 9f05ee5c7c2..90e14d8325f 100644 --- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue +++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue @@ -1,15 +1,17 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlButton, GlIcon, GlModal, GlModalDirective } from '@gitlab/ui'; import RequestWarning from './request_warning.vue'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; - export default { components: { RequestWarning, - GlModal: DeprecatedModal2, + GlButton, + GlModal, GlIcon, }, + directives: { + 'gl-modal': GlModalDirective, + }, props: { currentRequest: { type: Object, @@ -35,7 +37,15 @@ export default { required: true, }, }, + data() { + return { + openedBacktraces: [], + }; + }, computed: { + modalId() { + return `modal-peek-${this.metric}-details`; + }, metricDetails() { return this.currentRequest.details[this.metric]; }, @@ -58,29 +68,35 @@ export default { return ''; }, }, + methods: { + toggleBacktrace(toggledIndex) { + const toggledOpenedIndex = this.openedBacktraces.indexOf(toggledIndex); + + if (toggledOpenedIndex === -1) { + this.openedBacktraces = [...this.openedBacktraces, toggledIndex]; + } else { + this.openedBacktraces = this.openedBacktraces.filter( + openedIndex => openedIndex !== toggledIndex, + ); + } + }, + itemHasOpenedBacktrace(toggledIndex) { + return this.openedBacktraces.find(openedIndex => openedIndex === toggledIndex) >= 0; + }, + }, }; </script> <template> <div v-if="currentRequest.details && metricDetails" :id="`peek-view-${metric}`" - class="view" + class="gl-display-flex gl-align-items-center view" data-qa-selector="detailed_metric_content" > - <button - :data-target="`#modal-peek-${metric}-details`" - class="btn-blank btn-link bold" - type="button" - data-toggle="modal" - > + <gl-button v-gl-modal="modalId" class="gl-mr-2" type="button" variant="link"> {{ metricDetailsLabel }} - </button> - <gl-modal - :id="`modal-peek-${metric}-details`" - :header-title-text="header" - modal-size="xl" - class="performance-bar-modal" - > + </gl-button> + <gl-modal :modal-id="modalId" :title="header" size="lg" modal-class="gl-mt-7" scrollable> <table class="table"> <template v-if="detailsList.length"> <tr v-for="(item, index) in detailsList" :key="index"> @@ -90,7 +106,7 @@ export default { }}</span> </td> <td> - <div class="js-toggle-container"> + <div> <div v-for="(key, keyIndex) in keys" :key="key" @@ -98,16 +114,18 @@ export default { :class="{ 'mb-3 bold': keyIndex == 0 }" > {{ item[key] }} - <button + <gl-button v-if="keyIndex == 0 && item.backtrace" - class="text-expander js-toggle-button" + class="gl-ml-3" + data-testid="backtrace-expand-btn" type="button" :aria-label="__('Toggle backtrace')" + @click="toggleBacktrace(index)" > <gl-icon :size="12" name="ellipsis_h" /> - </button> + </gl-button> </div> - <pre v-if="item.backtrace" class="backtrace-row js-toggle-content mt-2">{{ + <pre v-if="itemHasOpenedBacktrace(index)" class="backtrace-row mt-2">{{ item.backtrace }}</pre> </div> diff --git a/app/assets/javascripts/performance_bar/performance_bar_log.js b/app/assets/javascripts/performance_bar/performance_bar_log.js index 55b4d626e56..3ba7ff1c221 100644 --- a/app/assets/javascripts/performance_bar/performance_bar_log.js +++ b/app/assets/javascripts/performance_bar/performance_bar_log.js @@ -1,6 +1,6 @@ /* eslint-disable no-console */ import { getCLS, getFID, getLCP } from 'web-vitals'; -import { PERFORMANCE_TYPE_MARK, PERFORMANCE_TYPE_MEASURE } from '~/performance_constants'; +import { PERFORMANCE_TYPE_MARK, PERFORMANCE_TYPE_MEASURE } from '~/performance/constants'; const initVitalsLog = () => { const reportVital = data => { diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index ef4d5338046..8c5f45e9d34 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -6,6 +6,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-admin-licensed-user-count-threshold', '.js-buy-pipeline-minutes-notification-callout', '.js-token-expiry-callout', + '.js-registration-enabled-callout', ]; const initCallouts = () => { diff --git a/app/assets/javascripts/pipeline_editor/components/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/text_editor.vue new file mode 100644 index 00000000000..a925077c906 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/text_editor.vue @@ -0,0 +1,26 @@ +<script> +import EditorLite from '~/vue_shared/components/editor_lite.vue'; + +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')" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql new file mode 100644 index 00000000000..9f1b5b13088 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/blob_content.graphql @@ -0,0 +1,5 @@ +query getBlobContent($projectPath: ID!, $path: String, $ref: String!) { + blobContent(projectPath: $projectPath, path: $path, ref: $ref) @client { + rawData + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js new file mode 100644 index 00000000000..7b8c70ac93e --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -0,0 +1,16 @@ +import Api from '~/api'; + +export const resolvers = { + Query: { + blobContent(_, { projectPath, path, ref }) { + return { + __typename: 'BlobContent', + rawData: Api.getRawFile(projectPath, path, { ref }).then(({ data }) => { + return data; + }), + }; + }, + }, +}; + +export default resolvers; diff --git a/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql new file mode 100644 index 00000000000..f4f65262158 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/typedefs.graphql @@ -0,0 +1,7 @@ +type BlobContent { + rawData: String! +} + +extend type Query { + blobContent: BlobContent +} diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js new file mode 100644 index 00000000000..ccd7b74064f --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; + +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import typeDefs from './graphql/typedefs.graphql'; +import { resolvers } from './graphql/resolvers'; + +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; + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers, { typeDefs }), + }); + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(PipelineEditorApp, { + props: { + projectPath, + defaultBranch, + ciConfigPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue new file mode 100644 index 00000000000..50b946af456 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -0,0 +1,108 @@ +<script> +import { GlLoadingIcon, GlAlert, GlTabs, GlTab } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; + +import TextEditor from './components/text_editor.vue'; +import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; + +import getBlobContent from './graphql/queries/blob_content.graphql'; + +export default { + components: { + GlLoadingIcon, + GlAlert, + GlTabs, + GlTab, + TextEditor, + PipelineGraph, + }, + props: { + projectPath: { + type: String, + required: true, + }, + defaultBranch: { + type: String, + required: false, + default: null, + }, + ciConfigPath: { + type: String, + required: true, + }, + }, + data() { + return { + error: null, + content: '', + editorIsReady: false, + }; + }, + apollo: { + content: { + query: getBlobContent, + variables() { + return { + projectPath: this.projectPath, + path: this.ciConfigPath, + ref: this.defaultBranch, + }; + }, + update(data) { + return data?.blobContent?.rawData; + }, + error(error) { + this.error = error; + }, + }, + }, + computed: { + loading() { + 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 }); + }, + pipelineData() { + // Note data will loaded as part of https://gitlab.com/gitlab-org/gitlab/-/issues/263141 + return {}; + }, + }, + i18n: { + unknownError: __('Unknown Error'), + errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'), + tabEdit: s__('Pipelines|Write pipeline configuration'), + tabGraph: s__('Pipelines|Visualize'), + }, +}; +</script> + +<template> + <div class="gl-mt-4"> + <gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert> + <div class="gl-mt-4"> + <gl-loading-icon v-if="loading" size="lg" /> + <div v-else class="file-editor"> + <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" /> + </gl-tab> + + <gl-tab :title="$options.i18n.tabGraph"> + <pipeline-graph :pipeline-data="pipelineData" /> + </gl-tab> + </gl-tabs> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue index 20067f6646f..6552665100a 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -14,6 +14,7 @@ import { GlDropdownItem, GlSearchBoxByType, GlSprintf, + GlLoadingIcon, } from '@gitlab/ui'; import { s__, __, n__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; @@ -45,6 +46,7 @@ export default { GlDropdownItem, GlSearchBoxByType, GlSprintf, + GlLoadingIcon, }, props: { pipelinesPath: { @@ -96,6 +98,7 @@ export default { warnings: [], totalWarnings: 0, isWarningDismissed: false, + isLoading: false, }; }, computed: { @@ -209,6 +212,8 @@ export default { fetchConfigVariables(refValue) { if (gon?.features?.newPipelineFormPrefilledVars) { + this.isLoading = true; + return axios .get(this.configVariablesPath, { params: { @@ -226,6 +231,8 @@ export default { } }); + this.isLoading = false; + return { params, descriptions }; }); } @@ -324,7 +331,9 @@ export default { > </gl-form-group> - <gl-form-group :label="s__('Pipeline|Variables')"> + <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> + + <gl-form-group v-else :label="s__('Pipeline|Variables')"> <div v-for="(variable, index) in variables" :key="variable.uniqueId" diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 6267b63328c..85171263f08 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { __ } from '~/locale'; import { fetchPolicies } from '~/lib/graphql'; @@ -17,11 +17,15 @@ export default { DagAnnotations, DagGraph, GlAlert, - GlSprintf, - GlEmptyState, GlButton, + GlEmptyState, + GlLink, + GlSprintf, }, inject: { + aboutDagDocPath: { + default: null, + }, dagDocPath: { default: null, }, @@ -89,14 +93,14 @@ export default { [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, emptyStateTexts: { - title: __('Start using Directed Acyclic Graphs (DAG)'), + title: __('Speed up your pipelines with Needs relationships'), firstDescription: __( - "This pipeline does not use the %{codeStart}needs%{codeEnd} keyword and can't be represented as a directed acyclic graph.", + 'Using the %{codeStart}needs%{codeEnd} keyword makes jobs run before their stage is reached. Jobs run as soon as their %{codeStart}needs%{codeEnd} relationships are met, which speeds up your pipelines.', ), secondDescription: __( - 'Using %{codeStart}needs%{codeEnd} allows jobs to run before their stage is reached, as soon as their individual dependencies are met, which speeds up your pipelines.', + "If you add %{codeStart}needs%{codeEnd} to jobs in your pipeline you'll be able to view the %{codeStart}needs%{codeEnd} relationships between jobs in this tab as a %{linkStart}Directed Acyclic Graph (DAG)%{linkEnd}.", ), - button: __('Learn more about job dependencies'), + button: __('Learn more about Needs relationships'), }, computed: { failure() { @@ -222,6 +226,9 @@ export default { <template #code="{ content }"> <code>{{ content }}</code> </template> + <template #link="{ content }"> + <gl-link :href="aboutDagDocPath">{{ content }}</gl-link> + </template> </gl-sprintf> </p> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js new file mode 100644 index 00000000000..ba1922b6dae --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/constants.js @@ -0,0 +1,3 @@ +export const DOWNSTREAM = 'downstream'; +export const MAIN = 'main'; +export const UPSTREAM = 'upstream'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 0f5a8cb8fbf..16ce279a591 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -5,6 +5,7 @@ import StageColumnComponent from './stage_column_component.vue'; import GraphWidthMixin from '../../mixins/graph_width_mixin'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; +import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; export default { name: 'PipelineGraph', @@ -35,11 +36,11 @@ export default { type: { type: String, required: false, - default: 'main', + default: MAIN, }, }, - upstream: 'upstream', - downstream: 'downstream', + upstream: UPSTREAM, + downstream: DOWNSTREAM, data() { return { downstreamMarginTop: null, @@ -54,41 +55,41 @@ export default { graph() { return this.pipeline.details?.stages; }, - hasTriggeredBy() { + hasUpstream() { return ( this.type !== this.$options.downstream && - this.triggeredByPipelines && + this.upstreamPipelines && this.pipeline.triggered_by !== null ); }, - triggeredByPipelines() { + upstreamPipelines() { return this.pipeline.triggered_by; }, - hasTriggered() { + hasDownstream() { return ( this.type !== this.$options.upstream && - this.triggeredPipelines && + this.downstreamPipelines && this.pipeline.triggered.length > 0 ); }, - triggeredPipelines() { + downstreamPipelines() { return this.pipeline.triggered; }, - expandedTriggeredBy() { + expandedUpstream() { return ( this.pipeline.triggered_by && Array.isArray(this.pipeline.triggered_by) && this.pipeline.triggered_by.find(el => el.isExpanded) ); }, - expandedTriggered() { + expandedDownstream() { return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded); }, pipelineTypeUpstream() { - return this.type !== this.$options.downstream && this.expandedTriggeredBy; + return this.type !== this.$options.downstream && this.expandedUpstream; }, pipelineTypeDownstream() { - return this.type !== this.$options.upstream && this.expandedTriggered; + return this.type !== this.$options.upstream && this.expandedDownstream; }, pipelineProjectId() { return this.pipeline.project.id; @@ -142,11 +143,11 @@ export default { * and we want to reset the pipeline store. Triggering the reset without * this condition would mean not allowing downstreams of downstreams to expand */ - if (this.expandedTriggered?.id !== pipeline.id) { - this.$emit('onResetTriggered', this.pipeline, pipeline); + if (this.expandedDownstream?.id !== pipeline.id) { + this.$emit('onResetDownstream', this.pipeline, pipeline); } - this.$emit('onClickTriggered', pipeline); + this.$emit('onClickDownstreamPipeline', pipeline); }, calculateMarginTop(downstreamNode, pixelDiff) { return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`; @@ -154,8 +155,8 @@ export default { hasOnlyOneJob(stage) { return stage.groups.length === 1; }, - hasUpstream(index) { - return index === 0 && this.hasTriggeredBy; + hasUpstreamColumn(index) { + return index === 0 && this.hasUpstream; }, setJob(jobName) { this.jobName = jobName; @@ -192,30 +193,30 @@ export default { <pipeline-graph v-if="pipelineTypeUpstream" - type="upstream" + :type="$options.upstream" class="d-inline-block upstream-pipeline" - :class="`js-upstream-pipeline-${expandedTriggeredBy.id}`" + :class="`js-upstream-pipeline-${expandedUpstream.id}`" :is-loading="false" - :pipeline="expandedTriggeredBy" + :pipeline="expandedUpstream" :is-linked-pipeline="true" :mediator="mediator" - @onClickTriggeredBy="clickTriggeredByPipeline" + @onClickUpstreamPipeline="clickUpstreamPipeline" @refreshPipelineGraph="requestRefreshPipelineGraph" /> <linked-pipelines-column - v-if="hasTriggeredBy" - :linked-pipelines="triggeredByPipelines" + v-if="hasUpstream" + :type="$options.upstream" + :linked-pipelines="upstreamPipelines" :column-title="__('Upstream')" :project-id="pipelineProjectId" - graph-position="left" - @linkedPipelineClick="$emit('onClickTriggeredBy', $event)" + @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)" /> <ul v-if="!isLoading" :class="{ - 'inline js-has-linked-pipelines': hasTriggered || hasTriggeredBy, + 'inline js-has-linked-pipelines': hasDownstream || hasUpstream, }" class="stage-column-list align-top" > @@ -223,7 +224,7 @@ export default { v-for="(stage, index) in graph" :key="stage.name" :class="{ - 'has-upstream gl-ml-11': hasUpstream(index), + 'has-upstream gl-ml-11': hasUpstreamColumn(index), 'has-only-one-job': hasOnlyOneJob(stage), 'gl-mr-26': shouldAddRightMargin(index), }" @@ -231,7 +232,7 @@ export default { :groups="stage.groups" :stage-connector-class="stageConnectorClass(index, stage)" :is-first-column="isFirstColumn(index)" - :has-triggered-by="hasTriggeredBy" + :has-upstream="hasUpstream" :action="stage.status.action" :job-hovered="jobName" :pipeline-expanded="pipelineExpanded" @@ -240,11 +241,11 @@ export default { </ul> <linked-pipelines-column - v-if="hasTriggered" - :linked-pipelines="triggeredPipelines" + v-if="hasDownstream" + :type="$options.downstream" + :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" :project-id="pipelineProjectId" - graph-position="right" @linkedPipelineClick="handleClickedDownstream" @downstreamHovered="setJob" @pipelineExpandToggle="setPipelineExpanded" @@ -252,15 +253,15 @@ export default { <pipeline-graph v-if="pipelineTypeDownstream" - type="downstream" + :type="$options.downstream" class="d-inline-block" - :class="`js-downstream-pipeline-${expandedTriggered.id}`" + :class="`js-downstream-pipeline-${expandedDownstream.id}`" :is-loading="false" - :pipeline="expandedTriggered" + :pipeline="expandedDownstream" :is-linked-pipeline="true" :style="{ 'margin-top': downstreamMarginTop }" :mediator="mediator" - @onClickTriggered="clickTriggeredPipeline" + @onClickDownstreamPipeline="clickDownstreamPipeline" @refreshPipelineGraph="requestRefreshPipelineGraph" /> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 7aee2573ce1..4ed0aae0d1e 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -119,6 +119,9 @@ export default { }, }, methods: { + hideTooltips() { + this.$root.$emit('bv::hide::tooltip'); + }, pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); }, @@ -129,24 +132,26 @@ export default { <div class="ci-job-component" data-qa-selector="job_item_container"> <gl-link v-if="status.has_details" - v-gl-tooltip="{ boundary, placement: 'bottom' }" + v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" :href="status.details_path" :title="tooltipText" :class="jobClasses" class="js-pipeline-graph-job-link qa-job-link menu-item" data-testid="job-with-link" - @click.stop + @click.stop="hideTooltips" + @mouseout="hideTooltips" > <job-name-component :name="job.name" :status="job.status" /> </gl-link> <div v-else - v-gl-tooltip="{ boundary, placement: 'bottom' }" + v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" :title="tooltipText" :class="jobClasses" class="js-job-component-tooltip non-details-job-component" data-testid="job-without-link" + @mouseout="hideTooltips" > <job-name-component :name="job.name" :status="job.status" /> </div> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index e359fc787c5..11f06a25984 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -2,6 +2,7 @@ import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; import { __, sprintf } from '~/locale'; +import { UPSTREAM, DOWNSTREAM } from './constants'; export default { directives: { @@ -14,6 +15,10 @@ export default { GlLoadingIcon, }, props: { + columnTitle: { + type: String, + required: true, + }, pipeline: { type: Object, required: true, @@ -22,7 +27,7 @@ export default { type: Number, required: true, }, - columnTitle: { + type: { type: String, required: true, }, @@ -50,12 +55,10 @@ export default { return this.childPipeline ? __('child-pipeline') : this.pipeline.project.name; }, parentPipeline() { - // Refactor string match when BE returns Upstream/Downstream indicators - return this.projectId === this.pipeline.project.id && this.columnTitle === __('Upstream'); + return this.isUpstream && this.isSameProject; }, childPipeline() { - // Refactor string match when BE returns Upstream/Downstream indicators - return this.projectId === this.pipeline.project.id && this.isDownstream; + return this.isDownstream && this.isSameProject; }, label() { if (this.parentPipeline) { @@ -66,7 +69,13 @@ export default { return __('Multi-project'); }, isDownstream() { - return this.columnTitle === __('Downstream'); + return this.type === DOWNSTREAM; + }, + isUpstream() { + return this.type === UPSTREAM; + }, + isSameProject() { + return this.projectId === this.pipeline.project.id; }, sourceJobInfo() { return this.isDownstream @@ -74,13 +83,13 @@ export default { : ''; }, expandedIcon() { - if (this.parentPipeline) { + if (this.isUpstream) { return this.expanded ? 'angle-right' : 'angle-left'; } return this.expanded ? 'angle-left' : 'angle-right'; }, expandButtonPosition() { - return this.parentPipeline ? 'gl-left-0 gl-border-r-1!' : 'gl-right-0 gl-border-l-1!'; + return this.isUpstream ? 'gl-left-0 gl-border-r-1!' : 'gl-right-0 gl-border-l-1!'; }, }, methods: { @@ -116,7 +125,7 @@ export default { > <div class="gl-relative gl-bg-white gl-p-3 gl-border-solid gl-border-gray-100 gl-border-1" - :class="{ 'gl-pl-9': parentPipeline }" + :class="{ 'gl-pl-9': isUpstream }" > <div class="gl-display-flex"> <ci-status diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 3ad28d88345..2ca33e6d33e 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -1,6 +1,6 @@ <script> import LinkedPipeline from './linked_pipeline.vue'; -import { __ } from '~/locale'; +import { UPSTREAM } from './constants'; export default { components: { @@ -15,7 +15,7 @@ export default { type: Array, required: true, }, - graphPosition: { + type: { type: String, required: true, }, @@ -32,9 +32,12 @@ export default { }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, + graphPosition() { + return this.isUpstream ? 'left' : 'right'; + }, // Refactor string match when BE returns Upstream/Downstream indicators isUpstream() { - return this.columnTitle === __('Upstream'); + return this.type === UPSTREAM; }, }, methods: { @@ -45,6 +48,11 @@ export default { this.$emit('downstreamHovered', jobName); }, onPipelineExpandToggle(jobName, expanded) { + // Highlighting only applies to downstream pipelines + if (this.isUpstream) { + return; + } + this.$emit('pipelineExpandToggle', jobName, expanded); }, }, @@ -66,6 +74,7 @@ export default { :pipeline="pipeline" :column-title="columnTitle" :project-id="projectId" + :type="type" @pipelineClicked="onPipelineClick($event, pipeline, index)" @downstreamHovered="onDownstreamHovered" @pipelineExpandToggle="onPipelineExpandToggle" diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index b26f28fa6af..741609c908a 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,16 +1,22 @@ <script> import { GlAlert, GlButton, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; import ciHeader from '~/vue_shared/components/header_ci_component.vue'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql'; +import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql'; +import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; +import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql'; import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants'; const DELETE_MODAL_ID = 'pipeline-delete-modal'; +const POLL_INTERVAL = 10000; export default { name: 'PipelineHeaderSection', + pipelineCancel: 'pipelineCancel', + pipelineRetry: 'pipelineRetry', + finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'], components: { ciHeader, GlAlert, @@ -28,7 +34,7 @@ export default { [DEFAULT]: __('An unknown error occurred.'), }, inject: { - // Receive `cancel`, `delete`, `fullProject` and `retry` + // Receive `fullProject` and `pipelinesPath` paths: { default: {}, }, @@ -52,7 +58,7 @@ export default { error() { this.reportFailure(LOAD_FAILURE); }, - pollInterval: 10000, + pollInterval: POLL_INTERVAL, watchLoading(isLoading) { if (!isLoading) { // To ensure apollo has updated the cache, @@ -90,6 +96,9 @@ export default { status() { return this.pipeline?.status; }, + isFinished() { + return this.$options.finishedStatuses.includes(this.status); + }, shouldRenderContent() { return !this.isLoadingInitialQuery && this.hasPipelineData; }, @@ -118,35 +127,72 @@ export default { } }, }, + watch: { + isFinished(finished) { + if (finished) { + this.$apollo.queries.pipeline.stopPolling(); + } + }, + }, methods: { reportFailure(errorType) { this.failureType = errorType; }, - async postAction(path) { + async postPipelineAction(name, mutation) { try { - await axios.post(path); - this.$apollo.queries.pipeline.refetch(); + const { + data: { + [name]: { errors }, + }, + } = await this.$apollo.mutate({ + mutation, + variables: { id: this.pipeline.id }, + }); + + if (errors.length > 0) { + this.reportFailure(POST_FAILURE); + } else { + await this.$apollo.queries.pipeline.refetch(); + if (!this.isFinished) { + this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL); + } + } } catch { this.reportFailure(POST_FAILURE); } }, - async cancelPipeline() { + cancelPipeline() { this.isCanceling = true; - this.postAction(this.paths.cancel); + this.postPipelineAction(this.$options.pipelineCancel, cancelPipelineMutation); }, - async retryPipeline() { + retryPipeline() { this.isRetrying = true; - this.postAction(this.paths.retry); + this.postPipelineAction(this.$options.pipelineRetry, retryPipelineMutation); }, async deletePipeline() { this.isDeleting = true; this.$apollo.queries.pipeline.stopPolling(); try { - const { request } = await axios.delete(this.paths.delete); - redirectTo(setUrlFragment(request.responseURL, 'delete_success')); + const { + data: { + pipelineDestroy: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: deletePipelineMutation, + variables: { + id: this.pipeline.id, + }, + }); + + if (errors.length > 0) { + this.reportFailure(DELETE_FAILURE); + this.isDeleting = false; + } else { + redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); + } } catch { - this.$apollo.queries.pipeline.startPolling(); + this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL); this.reportFailure(DELETE_FAILURE); this.isDeleting = false; } diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue index 8eec0110865..a0c35f54c0e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue @@ -57,7 +57,7 @@ export default { <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> <div :id="jobId" - class="pipeline-job-pill gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" + class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" :class="jobPillClasses" @mouseover="onMouseEnter" @mouseleave="onMouseLeave" diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue index 3a2b8a20bae..11ad2f2a3b6 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -97,15 +97,20 @@ export default { this.reportFailure(DRAW_FAILURE); } }, - getStageBackgroundClass(index) { + getStageBackgroundClasses(index) { const { length } = this.pipelineData.stages; - + // It's possible for a graph to have only one stage, in which + // case we concatenate both the left and right rounding classes if (length === 1) { - return 'stage-rounded'; - } else if (index === 0) { - return 'stage-left-rounded'; - } else if (index === length - 1) { - return 'stage-right-rounded'; + return 'gl-rounded-bottom-left-6 gl-rounded-top-left-6 gl-rounded-bottom-right-6 gl-rounded-top-right-6'; + } + + if (index === 0) { + return 'gl-rounded-bottom-left-6 gl-rounded-top-left-6'; + } + + if (index === length - 1) { + return 'gl-rounded-bottom-right-6 gl-rounded-top-right-6'; } return ''; @@ -162,7 +167,11 @@ export default { {{ failure.text }} </gl-alert> <gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false"> - {{ __('No content to show') }} + {{ + __( + 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', + ) + }} </gl-alert> <div v-else @@ -190,7 +199,8 @@ export default { > <div class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5" - :class="getStageBackgroundClass(index)" + :class="getStageBackgroundClasses(index)" + data-testid="stage-background" > <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" /> </div> diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue index 7b2458db725..df48426f24e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/stage_pill.vue @@ -26,7 +26,7 @@ export default { <template> <tooltip-on-truncate :title="stageName" truncate-target="child" placement="top"> <div - class="gl-px-5 gl-py-2 gl-text-white gl-text-center gl-text-truncate gl-rounded-pill pipeline-stage-pill" + class="gl-px-5 gl-py-2 gl-text-white gl-text-center gl-text-truncate gl-rounded-pill gl-w-20" :class="emptyClass" > {{ stageName }} diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index adba86d384b..9ee427d01fd 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -13,7 +13,6 @@ import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; import { validateParams } from '../../utils'; import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../../constants'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -23,7 +22,7 @@ export default { PipelinesFilteredSearch, GlIcon, }, - mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()], + mixins: [pipelinesMixin, CIPaginationMixin], props: { store: { type: Object, @@ -209,9 +208,6 @@ export default { }, ]; }, - canFilterPipelines() { - return this.glFeatures.filterPipelinesSearch; - }, validatedParams() { return validateParams(this.params); }, @@ -306,7 +302,6 @@ export default { </div> <pipelines-filtered-search - v-if="canFilterPipelines" :project-id="projectId" :params="validatedParams" @filterPipelines="filterPipelines" @@ -342,7 +337,7 @@ export default { :message="emptyTabMessage" /> - <div v-else-if="stateToRender === $options.stateMap.tableList" class="table-holder"> + <div v-else-if="stateToRender === $options.stateMap.tableList"> <pipelines-table-component :pipelines="state.pipelines" :pipeline-schedule-url="pipelineScheduleUrl" 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 97595e5d2ce..e52afe08336 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue @@ -87,7 +87,7 @@ export default { :aria-label="__('Run manual or delayed jobs')" > <gl-icon name="play" class="icon-play" /> - <i class="fa fa-caret-down" aria-hidden="true"></i> + <gl-icon name="chevron-down" /> <gl-loading-icon v-if="isLoading" /> </button> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 4a3d134685e..55c71e299be 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue @@ -29,7 +29,7 @@ export default { :aria-label="__('Artifacts')" > <gl-icon name="download" /> - <i class="fa fa-caret-down" aria-hidden="true"></i> + <gl-icon name="chevron-down" /> </button> <ul class="dropdown-menu dropdown-menu-right"> <li v-for="(artifact, i) in artifacts" :key="i"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue index 4045f450104..581ea5fbb35 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue @@ -168,7 +168,7 @@ export default { aria-expanded="false" @click="onClickStage" > - <span :aria-label="stage.title" aria-hidden="true" class="no-pointer-events"> + <span :aria-label="stage.title" aria-hidden="true" class="gl-pointer-events-none"> <gl-icon :name="borderlessIcon" /> </span> </button> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index dd09247337c..1d117cfe34a 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -1,6 +1,5 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import '~/lib/utils/datetime_utility'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue new file mode 100644 index 00000000000..504cf138d07 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_case_details.vue @@ -0,0 +1,67 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { __ } from '~/locale'; +import CodeBlock from '~/vue_shared/components/code_block.vue'; + +export default { + name: 'TestCaseDetails', + components: { + CodeBlock, + GlModal, + }, + props: { + modalId: { + type: String, + required: true, + }, + testCase: { + type: Object, + required: true, + validator: ({ classname, formattedTime, name }) => + Boolean(classname) && Boolean(formattedTime) && Boolean(name), + }, + }, + text: { + name: __('Name'), + duration: __('Execution time'), + trace: __('System output'), + }, + modalCloseButton: { + text: __('Close'), + attributes: [{ variant: 'info' }], + }, +}; +</script> + +<template> + <gl-modal + :modal-id="modalId" + :title="testCase.classname" + :action-primary="$options.modalCloseButton" + > + <div class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.name }}</strong> + <div class="col-sm-9" data-testid="test-case-name"> + {{ testCase.name }} + </div> + </div> + + <div class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.duration }}</strong> + <div class="col-sm-9" data-testid="test-case-duration"> + {{ testCase.formattedTime }} + </div> + </div> + + <div + v-if="testCase.system_output" + class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3" + data-testid="test-case-trace" + > + <strong class="gl-text-right col-sm-3">{{ $options.text.trace }}</strong> + <div class="col-sm-9"> + <code-block :code="testCase.system_output" /> + </div> + </div> + </gl-modal> +</template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index c3398e90895..a56dcf48d92 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -61,7 +61,7 @@ export default { <div v-else-if="!isLoading && showTests" ref="container" - class="tests-detail position-relative" + class="position-relative" data-testid="tests-detail" > <transition @@ -69,13 +69,13 @@ export default { @before-enter="beforeEnterTransition" @after-leave="afterLeaveTransition" > - <div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element"> + <div v-if="showSuite" key="detail" class="w-100 slide-enter-to-element"> <test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" /> <test-suite-table /> </div> - <div v-else key="summary" class="w-100 position-absolute slide-enter-from-element"> + <div v-else key="summary" class="w-100 slide-enter-from-element"> <test-summary :report="testReports" /> <test-summary-table @row-click="summaryTableRowClick" /> 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 2b92ffc3f26..7afbb59cbd6 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,7 +1,8 @@ <script> import { mapGetters } from 'vuex'; -import { GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui'; +import { GlModalDirective, GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; +import TestCaseDetails from './test_case_details.vue'; export default { name: 'TestsSuiteTable', @@ -9,9 +10,11 @@ export default { GlIcon, GlFriendlyWrap, GlButton, + TestCaseDetails, }, directives: { GlTooltip: GlTooltipDirective, + GlModalDirective, }, props: { heading: { @@ -43,7 +46,7 @@ export default { <div role="rowheader" class="table-section section-20"> {{ __('Suite') }} </div> - <div role="rowheader" class="table-section section-20"> + <div role="rowheader" class="table-section section-40"> {{ __('Name') }} </div> <div role="rowheader" class="table-section section-10"> @@ -52,12 +55,12 @@ export default { <div role="rowheader" class="table-section section-10 text-center"> {{ __('Status') }} </div> - <div role="rowheader" class="table-section flex-grow-1"> - {{ __('Trace'), }} - </div> - <div role="rowheader" class="table-section section-10 text-right"> + <div role="rowheader" class="table-section section-10"> {{ __('Duration') }} </div> + <div role="rowheader" class="table-section section-10"> + {{ __('Details'), }} + </div> </div> <div @@ -72,7 +75,7 @@ export default { </div> </div> - <div class="table-section section-20 section-wrap"> + <div class="table-section section-40 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break"> <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.name" /> @@ -107,24 +110,24 @@ export default { </div> </div> - <div class="table-section flex-grow-1"> - <div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div> - <div class="table-mobile-content"> - <pre - v-if="testCase.system_output" - class="build-trace build-trace-rounded text-left" - ><code class="bash p-0">{{testCase.system_output}}</code></pre> - </div> - </div> - <div class="table-section section-10 section-wrap"> <div role="rowheader" class="table-mobile-header"> {{ __('Duration') }} </div> - <div class="table-mobile-content text-right pr-sm-1"> + <div class="table-mobile-content pr-sm-1"> {{ testCase.formattedTime }} </div> </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Details'), }}</div> + <div class="table-mobile-content"> + <gl-button v-gl-modal-directive="`test-case-details-${index}`">{{ + __('View details') + }}</gl-button> + <test-case-details :modal-id="`test-case-details-${index}`" :test-case="testCase" /> + </div> + </div> </div> </div> diff --git a/app/assets/javascripts/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql b/app/assets/javascripts/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql new file mode 100644 index 00000000000..9afb474cb17 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql @@ -0,0 +1,5 @@ +mutation cancelPipeline($id: CiPipelineID!) { + pipelineCancel(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql b/app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql new file mode 100644 index 00000000000..a52cecafcaf --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/mutations/delete_pipeline.mutation.graphql @@ -0,0 +1,5 @@ +mutation deletePipeline($id: CiPipelineID!) { + pipelineDestroy(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/pipelines/graphql/mutations/retry_pipeline.mutation.graphql b/app/assets/javascripts/pipelines/graphql/mutations/retry_pipeline.mutation.graphql new file mode 100644 index 00000000000..2b3b0822653 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/mutations/retry_pipeline.mutation.graphql @@ -0,0 +1,5 @@ +mutation retryPipeline($id: CiPipelineID!) { + pipelineRetry(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js index 886a8a78448..bd1b1664a1e 100644 --- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js @@ -41,13 +41,13 @@ export default { this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() }); } }, - resetTriggeredPipelines(parentPipeline, pipeline) { + resetDownstreamPipelines(parentPipeline, pipeline) { this.mediator.store.resetTriggeredPipelines(parentPipeline, pipeline); }, - clickTriggeredByPipeline(pipeline) { + clickUpstreamPipeline(pipeline) { this.clickPipeline(pipeline, 'openPipeline', 'closePipeline'); }, - clickTriggeredPipeline(pipeline) { + clickDownstreamPipeline(pipeline) { this.clickPipeline(pipeline, 'openPipeline', 'closePipeline'); }, requestRefreshPipelineGraph() { diff --git a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js index 3f3007ba11a..578ff498358 100644 --- a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js @@ -1,6 +1,6 @@ export default { props: { - hasTriggeredBy: { + hasUpstream: { type: Boolean, required: false, default: false, @@ -8,7 +8,7 @@ export default { }, methods: { buildConnnectorClass(index) { - return index === 0 && (!this.isFirstColumn || this.hasTriggeredBy) ? 'left-connector' : ''; + return index === 0 && (!this.isFirstColumn || this.hasUpstream) ? 'left-connector' : ''; }, }, }; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 67aec12655a..29dec2309a7 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -6,12 +6,10 @@ import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import pipelineGraph from './components/graph/graph_component.vue'; import createDagApp from './pipeline_details_dag'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; -import PipelinesMediator from './pipeline_details_mediator'; import legacyPipelineHeader from './components/legacy_header_component.vue'; import eventHub from './event_hub'; import TestReports from './components/test_reports/test_reports.vue'; import createTestReportsStore from './stores/test_reports'; -import { createPipelineHeaderApp } from './pipeline_details_header'; Vue.use(Translate); @@ -22,7 +20,7 @@ const SELECTORS = { PIPELINE_TESTS: '#js-pipeline-tests-detail', }; -const createPipelinesDetailApp = mediator => { +const createLegacyPipelinesDetailApp = mediator => { if (!document.querySelector(SELECTORS.PIPELINE_GRAPH)) { return; } @@ -47,10 +45,10 @@ const createPipelinesDetailApp = mediator => { }, on: { refreshPipelineGraph: this.requestRefreshPipelineGraph, - onResetTriggered: (parentPipeline, pipeline) => - this.resetTriggeredPipelines(parentPipeline, pipeline), - onClickTriggeredBy: pipeline => this.clickTriggeredByPipeline(pipeline), - onClickTriggered: pipeline => this.clickTriggeredPipeline(pipeline), + onResetDownstream: (parentPipeline, pipeline) => + this.resetDownstreamPipelines(parentPipeline, pipeline), + onClickUpstreamPipeline: pipeline => this.clickUpstreamPipeline(pipeline), + onClickDownstreamPipeline: pipeline => this.clickDownstreamPipeline(pipeline), }, }); }, @@ -127,18 +125,48 @@ const createTestDetails = () => { }); }; -export default () => { +export default async function() { + createTestDetails(); + createDagApp(); + const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); - const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); - mediator.fetchPipeline(); + let mediator; + + if (!gon.features.graphqlPipelineHeader || !gon.features.graphqlPipelineDetails) { + try { + const { default: PipelinesMediator } = await import( + /* webpackChunkName: 'PipelinesMediator' */ './pipeline_details_mediator' + ); + mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); + mediator.fetchPipeline(); + } catch { + Flash(__('An error occurred while loading the pipeline.')); + } + } - createPipelinesDetailApp(mediator); + if (gon.features.graphqlPipelineDetails) { + try { + const { createPipelinesDetailApp } = await import( + /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' + ); + createPipelinesDetailApp(); + } catch { + Flash(__('An error occurred while loading the pipeline.')); + } + } else { + createLegacyPipelinesDetailApp(mediator); + } if (gon.features.graphqlPipelineHeader) { - createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER); + try { + const { createPipelineHeaderApp } = await import( + /* webpackChunkName: 'createPipelineHeaderApp' */ './pipeline_details_header' + ); + createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER); + } catch { + Flash(__('An error occurred while loading a section of this page.')); + } } else { createLegacyPipelineHeaderApp(mediator); } - createTestDetails(); - createDagApp(); -}; +} diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js index dc03b457265..d37c72a4f2a 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_dag.js +++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js @@ -10,12 +10,19 @@ const apolloProvider = new VueApollo({ }); const createDagApp = () => { - if (!window.gon?.features?.dagPipelineTab) { + const el = document.querySelector('#js-pipeline-dag-vue'); + + if (!window.gon?.features?.dagPipelineTab || !el) { return; } - const el = document.querySelector('#js-pipeline-dag-vue'); - const { pipelineProjectPath, pipelineIid, emptySvgPath, dagDocPath } = el?.dataset; + const { + aboutDagDocPath, + dagDocPath, + emptySvgPath, + pipelineProjectPath, + pipelineIid, + } = el?.dataset; // eslint-disable-next-line no-new new Vue({ @@ -25,10 +32,11 @@ const createDagApp = () => { }, apolloProvider, provide: { + aboutDagDocPath, + dagDocPath, + emptySvgPath, pipelineProjectPath, pipelineIid, - emptySvgPath, - dagDocPath, }, render(createElement) { return createElement('dag', {}); diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js new file mode 100644 index 00000000000..880855cf21d --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js @@ -0,0 +1,7 @@ +const createPipelinesDetailApp = () => { + // Placeholder. See: https://gitlab.com/gitlab-org/gitlab/-/issues/223262 + // eslint-disable-next-line no-useless-return + return; +}; + +export { createPipelinesDetailApp }; diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js index 27fe9ba3f19..744a8272709 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -16,7 +16,7 @@ export const createPipelineHeaderApp = elSelector => { return; } - const { cancelPath, deletePath, fullPath, pipelineId, pipelineIid, retryPath } = el?.dataset; + const { fullPath, pipelineId, pipelineIid, pipelinesPath } = el?.dataset; // eslint-disable-next-line no-new new Vue({ el, @@ -26,10 +26,8 @@ export const createPipelineHeaderApp = elSelector => { apolloProvider, provide: { paths: { - cancel: cancelPath, - delete: deletePath, fullProject: fullPath, - retry: retryPath, + pipelinesPath, }, pipelineId, pipelineIid, diff --git a/app/assets/javascripts/popovers/components/popovers.vue b/app/assets/javascripts/popovers/components/popovers.vue new file mode 100644 index 00000000000..3bb6d284264 --- /dev/null +++ b/app/assets/javascripts/popovers/components/popovers.vue @@ -0,0 +1,92 @@ +<script> +// We can't use v-safe-html here as the popover's title or content might contains SVGs that would +// be stripped by the directive's sanitizer. Instead, we fallback on v-html and we use GitLab's +// dompurify config that lets SVGs be rendered properly. +// Context: https://gitlab.com/gitlab-org/gitlab/-/issues/247207 +/* eslint-disable vue/no-v-html */ +import { GlPopover } from '@gitlab/ui'; +import { sanitize } from '~/lib/dompurify'; + +const newPopover = element => { + const { content, html, placement, title, triggers = 'focus' } = element.dataset; + + return { + target: element, + content, + html, + placement, + title, + triggers, + }; +}; + +export default { + components: { + GlPopover, + }, + data() { + return { + popovers: [], + }; + }, + created() { + this.observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + mutation.removedNodes.forEach(this.dispose); + }); + }); + }, + beforeDestroy() { + this.observer.disconnect(); + }, + methods: { + addPopovers(elements) { + const newPopovers = elements.reduce((acc, element) => { + if (this.popoverExists(element)) { + return acc; + } + const popover = newPopover(element); + this.observe(popover); + return [...acc, popover]; + }, []); + + this.popovers.push(...newPopovers); + }, + observe(popover) { + this.observer.observe(popover.target.parentElement, { + childList: true, + }); + }, + dispose(target) { + if (!target) { + this.popovers = []; + } else { + const index = this.popovers.findIndex(popover => popover.target === target); + + if (index > -1) { + this.popovers.splice(index, 1); + } + } + }, + popoverExists(element) { + return this.popovers.some(popover => popover.target === element); + }, + getSafeHtml(html) { + return sanitize(html); + }, + }, +}; +</script> + +<template> + <div> + <gl-popover v-for="(popover, index) in popovers" :key="index" v-bind="popover"> + <template #title> + <span v-if="popover.html" v-html="getSafeHtml(popover.title)"></span> + <span v-else>{{ popover.title }}</span> + </template> + <span v-if="popover.html" v-html="getSafeHtml(popover.content)"></span> + <span v-else>{{ popover.content }}</span> + </gl-popover> + </div> +</template> diff --git a/app/assets/javascripts/popovers/index.js b/app/assets/javascripts/popovers/index.js new file mode 100644 index 00000000000..bfb61f02a3a --- /dev/null +++ b/app/assets/javascripts/popovers/index.js @@ -0,0 +1,51 @@ +import Vue from 'vue'; +import { toArray } from 'lodash'; +import PopoversComponent from './components/popovers.vue'; + +let app; + +const APP_ELEMENT_ID = 'gl-popovers-app'; + +const getPopoversApp = () => { + if (!app) { + const container = document.createElement('div'); + container.setAttribute('id', APP_ELEMENT_ID); + document.body.appendChild(container); + + const Popovers = Vue.extend(PopoversComponent); + app = new Popovers(); + app.$mount(`#${APP_ELEMENT_ID}`); + } + + return app; +}; + +const isPopover = (node, selector) => node.matches && node.matches(selector); + +const handlePopoverEvent = (rootTarget, e, selector) => { + for (let { target } = e; target && target !== rootTarget; target = target.parentNode) { + if (isPopover(target, selector)) { + getPopoversApp().addPopovers([target]); + break; + } + } +}; + +export const initPopovers = () => { + ['mouseenter', 'focus', 'click'].forEach(event => { + document.addEventListener( + event, + e => handlePopoverEvent(document, e, '[data-toggle="popover"]'), + true, + ); + }); + + return getPopoversApp(); +}; + +export const dispose = elements => toArray(elements).forEach(getPopoversApp().dispose); + +export const destroy = () => { + getPopoversApp().$destroy(); + app = null; +}; diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index 200e5ba255f..5feac7485ad 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -1,17 +1,19 @@ <script> -/* eslint-disable vue/no-v-html */ import { escape } from 'lodash'; -import { GlButton } from '@gitlab/ui'; +import { GlSafeHtmlDirective as SafeHtml, GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; import { deprecatedCreateFlash as Flash } from '~/flash'; export default { components: { - GlModal: DeprecatedModal2, + GlModal, GlButton, }, + directives: { + GlModalDirective, + SafeHtml, + }, props: { actionUrl: { type: String, @@ -54,6 +56,21 @@ Please update your Git repository remotes as soon as possible.`), false, ); }, + primaryProps() { + return { + text: s__('Update username'), + attributes: [ + { variant: 'warning' }, + { category: 'primary' }, + { disabled: this.isRequestPending }, + ], + }; + }, + cancelProps() { + return { + text: s__('Cancel'), + }; + }, }, methods: { onConfirm() { @@ -103,22 +120,21 @@ Please update your Git repository remotes as soon as possible.`), <p class="form-text text-muted">{{ path }}</p> </div> <gl-button - :data-target="`#${$options.modalId}`" + v-gl-modal-directive="$options.modalId" :disabled="isRequestPending || newUsername === username" category="primary" variant="warning" - data-toggle="modal" + data-testid="username-change-confirmation-modal" + >{{ $options.buttonText }}</gl-button > - {{ $options.buttonText }} - </gl-button> <gl-modal - :id="$options.modalId" - :header-title-text="s__('Profiles|Change username') + '?'" - :footer-primary-button-text="$options.buttonText" - footer-primary-button-variant="warning" - @submit="onConfirm" + :modal-id="$options.modalId" + :title="s__('Profiles|Change username') + '?'" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="onConfirm" > - <span v-html="modalText"></span> + <span v-safe-html="modalText"></span> </gl-modal> </div> </template> diff --git a/app/assets/javascripts/profile/preferences/components/integration_view.vue b/app/assets/javascripts/profile/preferences/components/integration_view.vue new file mode 100644 index 00000000000..c2952629a5d --- /dev/null +++ b/app/assets/javascripts/profile/preferences/components/integration_view.vue @@ -0,0 +1,81 @@ +<script> +import { GlFormText, GlIcon, GlLink } from '@gitlab/ui'; +import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue'; + +export default { + name: 'IntegrationView', + components: { + GlFormText, + GlIcon, + GlLink, + IntegrationHelpText, + }, + inject: ['userFields'], + props: { + helpLink: { + type: String, + required: true, + }, + message: { + type: String, + required: true, + }, + messageUrl: { + type: String, + required: true, + }, + config: { + type: Object, + required: true, + }, + }, + data() { + return { + isEnabled: this.userFields[this.config.formName], + }; + }, + computed: { + formName() { + return `user[${this.config.formName}]`; + }, + formId() { + return `user_${this.config.formName}`; + }, + }, +}; +</script> + +<template> + <div> + <label class="label-bold"> + {{ config.title }} + </label> + <gl-link class="has-tooltip" title="More information" :href="helpLink"> + <gl-icon name="question-o" class="vertical-align-middle" /> + </gl-link> + <div class="form-group form-check" data-testid="profile-preferences-integration-form-group"> + <!-- Necessary for Rails to receive the value when not checked --> + <input + :name="formName" + type="hidden" + value="0" + data-testid="profile-preferences-integration-hidden-field" + /> + <input + :id="formId" + v-model="isEnabled" + type="checkbox" + class="form-check-input" + :name="formName" + value="1" + data-testid="profile-preferences-integration-checkbox" + /> + <label class="form-check-label" :for="formId"> + {{ config.label }} + </label> + <gl-form-text tag="div"> + <integration-help-text :message="message" :message-url="messageUrl" /> + </gl-form-text> + </div> + </div> +</template> diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue new file mode 100644 index 00000000000..8b2006b7c5b --- /dev/null +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -0,0 +1,56 @@ +<script> +import { s__ } from '~/locale'; +import IntegrationView from './integration_view.vue'; + +const INTEGRATION_VIEW_CONFIGS = { + sourcegraph: { + title: s__('ProfilePreferences|Sourcegraph'), + label: s__('ProfilePreferences|Enable integrated code intelligence on code views'), + formName: 'sourcegraph_enabled', + }, + gitpod: { + title: s__('ProfilePreferences|Gitpod'), + label: s__('ProfilePreferences|Enable Gitpod integration'), + formName: 'gitpod_enabled', + }, +}; + +export default { + name: 'ProfilePreferences', + components: { + IntegrationView, + }, + inject: { + integrationViews: { + default: [], + }, + }, + integrationViewConfigs: INTEGRATION_VIEW_CONFIGS, +}; +</script> + +<template> + <div class="row gl-mt-3 js-preferences-form"> + <div v-if="integrationViews.length" class="col-sm-12"> + <hr data-testid="profile-preferences-integrations-rule" /> + </div> + <div v-if="integrationViews.length" class="col-lg-4 profile-settings-sidebar"> + <h4 class="gl-mt-0" data-testid="profile-preferences-integrations-heading"> + {{ s__('ProfilePreferences|Integrations') }} + </h4> + <p> + {{ s__('ProfilePreferences|Customize integrations with third party services.') }} + </p> + </div> + <div v-if="integrationViews.length" class="col-lg-8"> + <integration-view + v-for="view in integrationViews" + :key="view.name" + :help-link="view.help_link" + :message="view.message" + :message-url="view.message_url" + :config="$options.integrationViewConfigs[view.name]" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js new file mode 100644 index 00000000000..bcca3140717 --- /dev/null +++ b/app/assets/javascripts/profile/preferences/profile_preferences_bundle.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import ProfilePreferences from './components/profile_preferences.vue'; + +export default () => { + const el = document.querySelector('#js-profile-preferences-app'); + const shouldParse = ['integrationViews', 'userFields']; + + const provide = Object.keys(el.dataset).reduce((memo, key) => { + let value = el.dataset[key]; + if (shouldParse.includes(key)) { + value = JSON.parse(value); + } + + return { ...memo, [key]: value }; + }, {}); + + return new Vue({ + el, + name: 'ProfilePreferencesApp', + provide, + render: createElement => createElement(ProfilePreferences), + }); +}; diff --git a/app/assets/javascripts/projects/commit_box/info/index.js b/app/assets/javascripts/projects/commit_box/info/index.js index 352ac39f3c4..254d178f013 100644 --- a/app/assets/javascripts/projects/commit_box/info/index.js +++ b/app/assets/javascripts/projects/commit_box/info/index.js @@ -1,4 +1,5 @@ import { loadBranches } from './load_branches'; +import { initDetailsButton } from './init_details_button'; import { fetchCommitMergeRequests } from '~/commit_merge_requests'; import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; @@ -15,4 +16,6 @@ export const initCommitBoxInfo = (containerSelector = '.js-commit-box-info') => new MiniPipelineGraph({ container: '.js-commit-pipeline-graph', }).bindEvents(); + + initDetailsButton(); }; diff --git a/app/assets/javascripts/projects/commit_box/info/init_details_button.js b/app/assets/javascripts/projects/commit_box/info/init_details_button.js new file mode 100644 index 00000000000..032fbf5316a --- /dev/null +++ b/app/assets/javascripts/projects/commit_box/info/init_details_button.js @@ -0,0 +1,11 @@ +import $ from 'jquery'; + +export const initDetailsButton = () => { + $('body').on('click', '.js-details-expand', function expand(e) { + e.preventDefault(); + $(this) + .next('.js-details-content') + .removeClass('hide'); + $(this).hide(); + }); +}; diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue index 2f3ff92d7ae..5429d51dae0 100644 --- a/app/assets/javascripts/projects/components/project_delete_button.vue +++ b/app/assets/javascripts/projects/components/project_delete_button.vue @@ -25,7 +25,7 @@ export default { 'Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc.', ), modalBody: __( - "This action cannot be undone. You will lose the project's repository and all content: issues, merge requests, etc.", + "This action cannot be undone. You will lose this project's repository and all content: issues, merge requests, etc.", ), }, }; diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index 0777dddfc19..c6e2b2e1140 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -45,9 +45,12 @@ export default { }, data() { return { - timesChartTransformedData: { - full: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values), - }, + timesChartTransformedData: [ + { + name: 'full', + data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values), + }, + ], }; }, computed: { @@ -128,7 +131,7 @@ export default { <gl-column-chart :height="$options.chartContainerHeight" :option="$options.timesChartOptions" - :data="timesChartTransformedData" + :bars="timesChartTransformedData" :y-axis-title="__('Minutes')" :x-axis-title="__('Commit')" x-axis-type="category" diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue index cd9e464c5ac..aa59717ddcd 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue @@ -1,4 +1,7 @@ <script> +import { formatTime } from '~/lib/utils/datetime_utility'; +import { s__, n__ } from '~/locale'; + export default { props: { counts: { @@ -6,25 +9,44 @@ export default { required: true, }, }, + computed: { + totalDuration() { + return formatTime(this.counts.totalDuration); + }, + statistics() { + return [ + { + title: s__('PipelineCharts|Total:'), + value: n__('1 pipeline', '%d pipelines', this.counts.total), + }, + { + title: s__('PipelineCharts|Successful:'), + value: n__('1 pipeline', '%d pipelines', this.counts.success), + }, + { + title: s__('PipelineCharts|Failed:'), + value: n__('1 pipeline', '%d pipelines', this.counts.failed), + }, + { + title: s__('PipelineCharts|Success ratio:'), + value: `${this.counts.successRatio}%`, + }, + { + title: s__('PipelineCharts|Total duration:'), + value: this.totalDuration, + }, + ]; + }, + }, }; </script> <template> <ul> - <li> - <span>{{ s__('PipelineCharts|Total:') }}</span> - <strong>{{ n__('1 pipeline', '%d pipelines', counts.total) }}</strong> - </li> - <li> - <span>{{ s__('PipelineCharts|Successful:') }}</span> - <strong>{{ n__('1 pipeline', '%d pipelines', counts.success) }}</strong> - </li> - <li> - <span>{{ s__('PipelineCharts|Failed:') }}</span> - <strong>{{ n__('1 pipeline', '%d pipelines', counts.failed) }}</strong> - </li> - <li> - <span>{{ s__('PipelineCharts|Success ratio:') }}</span> - <strong>{{ counts.successRatio }}%</strong> - </li> + <template v-for="({ title, value }, index) in statistics"> + <li :key="index"> + <span>{{ title }}</span> + <strong>{{ value }}</strong> + </li> + </template> </ul> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index 4ae2b729200..eef1bc2d28b 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -7,6 +7,7 @@ export default () => { countsFailed, countsSuccess, countsTotal, + countsTotalDuration, successRatio, timesChartLabels, timesChartValues, @@ -41,6 +42,7 @@ export default () => { success: countsSuccess, total: countsTotal, successRatio, + totalDuration: countsTotalDuration, }, timesChartData: { labels: JSON.parse(timesChartLabels), diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 4bfed6d489d..df7d9b56aed 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -20,7 +20,12 @@ export default { type: String, required: true, }, - initialIncomingEmail: { + incomingEmail: { + type: String, + required: false, + default: '', + }, + customEmail: { type: String, required: false, default: '', @@ -50,23 +55,18 @@ export default { data() { return { isEnabled: this.initialIsEnabled, - incomingEmail: this.initialIncomingEmail, isTemplateSaving: false, isAlertShowing: false, alertVariant: 'danger', alertMessage: '', + updatedCustomEmail: this.customEmail, }; }, created() { eventHub.$on('serviceDeskEnabledCheckboxToggled', this.onEnableToggled); eventHub.$on('serviceDeskTemplateSave', this.onSaveTemplate); - this.service = new ServiceDeskService(this.endpoint); - - if (this.isEnabled && !this.incomingEmail) { - this.fetchIncomingEmail(); - } }, beforeDestroy() { @@ -75,22 +75,6 @@ export default { }, methods: { - fetchIncomingEmail() { - this.service - .fetchIncomingEmail() - .then(({ data }) => { - const email = data.service_desk_address; - if (!email) { - throw new Error(__("Response didn't include `service_desk_address`")); - } - - this.incomingEmail = email; - }) - .catch(() => - this.showAlert(__('An error occurred while fetching the Service Desk address.')), - ); - }, - onEnableToggled(isChecked) { this.isEnabled = isChecked; this.incomingEmail = ''; @@ -119,7 +103,7 @@ export default { this.service .updateTemplate({ selectedTemplate, outgoingName, projectKey }, this.isEnabled) .then(({ data }) => { - this.incomingEmail = data?.service_desk_address; + this.updatedCustomEmail = data?.service_desk_address; this.showAlert(__('Changes were successfully made.'), 'success'); }) .catch(err => { @@ -155,6 +139,7 @@ export default { <service-desk-setting :is-enabled="isEnabled" :incoming-email="incomingEmail" + :custom-email="updatedCustomEmail" :initial-selected-template="selectedTemplate" :initial-outgoing-name="outgoingName" :initial-project-key="projectKey" diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index e18cfefc3ca..5d120fd0b3f 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -26,6 +26,11 @@ export default { required: false, default: '', }, + customEmail: { + type: String, + required: false, + default: '', + }, initialSelectedTemplate: { type: String, required: false, @@ -57,7 +62,6 @@ export default { selectedTemplate: this.initialSelectedTemplate, outgoingName: this.initialOutgoingName || __('GitLab Support Bot'), projectKey: this.initialProjectKey, - baseEmail: this.incomingEmail.replace(this.initialProjectKey, ''), }; }, computed: { @@ -67,6 +71,12 @@ export default { hasProjectKeySupport() { return Boolean(this.glFeatures.serviceDeskCustomAddress); }, + email() { + return this.customEmail || this.incomingEmail; + }, + hasCustomEmail() { + return this.customEmail && this.customEmail !== this.incomingEmail; + }, }, methods: { onCheckboxToggle(isChecked) { @@ -101,30 +111,31 @@ export default { <strong id="incoming-email-describer" class="d-block mb-1"> {{ __('Forward external support email address to') }} </strong> - <template v-if="incomingEmail"> + <template v-if="email"> <div class="input-group"> <input ref="service-desk-incoming-email" type="text" - class="form-control incoming-email" + class="form-control" + data-testid="incoming-email" :placeholder="__('Incoming email')" :aria-label="__('Incoming email')" aria-describedby="incoming-email-describer" - :value="incomingEmail" + :value="email" disabled="true" /> <div class="input-group-append"> <clipboard-button :title="__('Copy')" - :text="incomingEmail" + :text="email" css-class="input-group-text qa-clipboard-button" /> </div> </div> - <span v-if="projectKey" class="form-text text-muted"> + <span v-if="hasCustomEmail" class="form-text text-muted"> <gl-sprintf :message="__('Emails sent to %{email} will still be supported')"> <template #email> - <code>{{ baseEmail }}</code> + <code>{{ incomingEmail }}</code> </template> </gl-sprintf> </span> diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index 15c077de72e..c73163788ef 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -17,6 +17,7 @@ export default () => { initialIsEnabled: parseBoolean(dataset.enabled), endpoint: dataset.endpoint, incomingEmail: dataset.incomingEmail, + customEmail: dataset.customEmail, selectedTemplate: dataset.selectedTemplate, outgoingName: dataset.outgoingName, projectKey: dataset.projectKey, @@ -28,7 +29,8 @@ export default () => { props: { initialIsEnabled: this.initialIsEnabled, endpoint: this.endpoint, - initialIncomingEmail: this.incomingEmail, + incomingEmail: this.incomingEmail, + customEmail: this.customEmail, selectedTemplate: this.selectedTemplate, outgoingName: this.outgoingName, projectKey: this.projectKey, diff --git a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js index d707763c64e..b68c5bb876f 100644 --- a/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js +++ b/app/assets/javascripts/projects/settings_service_desk/services/service_desk_service.js @@ -5,10 +5,6 @@ class ServiceDeskService { this.endpoint = endpoint; } - fetchIncomingEmail() { - return axios.get(this.endpoint); - } - toggleServiceDesk(enable) { return axios.put(this.endpoint, { service_desk_enabled: enable }); } diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js index 7fc1b18bf71..bb9689f09a1 100644 --- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js +++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js @@ -18,29 +18,32 @@ export default class PrometheusMetrics { this.$monitoredMetricsList = this.$monitoredMetricsPanel.find('.js-metrics-list'); this.$missingEnvVarPanel = this.$wrapper.find('.js-panel-missing-env-vars'); - this.$panelToggle = this.$missingEnvVarPanel.find('.js-panel-toggle'); + this.$panelToggleRight = this.$missingEnvVarPanel.find('.js-panel-toggle-right'); + this.$panelToggleDown = this.$missingEnvVarPanel.find('.js-panel-toggle-down'); this.$missingEnvVarMetricCount = this.$missingEnvVarPanel.find('.js-env-var-count'); this.$missingEnvVarMetricsList = this.$missingEnvVarPanel.find('.js-missing-var-metrics-list'); this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('activeMetrics'); this.helpMetricsPath = this.$monitoredMetricsPanel.data('metrics-help-path'); - this.$panelToggle.on('click', e => this.handlePanelToggle(e)); + this.$panelToggleRight.on('click', e => this.handlePanelToggle(e)); + this.$panelToggleDown.on('click', e => this.handlePanelToggle(e)); } init() { this.loadActiveMetrics(); } - /* eslint-disable class-methods-use-this */ handlePanelToggle(e) { const $toggleBtn = $(e.currentTarget); const $currentPanelBody = $toggleBtn.closest('.card').find('.card-body'); $currentPanelBody.toggleClass('hidden'); - if ($toggleBtn.hasClass('fa-caret-down')) { - $toggleBtn.removeClass('fa-caret-down').addClass('fa-caret-right'); - } else { - $toggleBtn.removeClass('fa-caret-right').addClass('fa-caret-down'); + if ($toggleBtn.hasClass('js-panel-toggle-right')) { + $toggleBtn.addClass('hidden'); + this.$panelToggleDown.removeClass('hidden'); + } else if ($toggleBtn.hasClass('js-panel-toggle-down')) { + $toggleBtn.addClass('hidden'); + this.$panelToggleRight.removeClass('hidden'); } } diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue index 328026d0953..2844b4ffde3 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue @@ -14,9 +14,9 @@ export default { required: false, default: () => [], }, - isDesktop: { + isMobile: { type: Boolean, - default: false, + default: true, required: false, }, }, @@ -34,7 +34,7 @@ export default { return this.tags.some(tag => this.selectedItems[tag.name]); }, showMultiDeleteButton() { - return this.tags.some(tag => tag.destroy_path) && this.isDesktop; + return this.tags.some(tag => tag.destroy_path) && !this.isMobile; }, }, methods: { @@ -68,7 +68,7 @@ export default { :tag="tag" :first="index === 0" :selected="selectedItems[tag.name]" - :is-desktop="isDesktop" + :is-mobile="isMobile" @select="updateSelectedItems(tag.name)" @delete="$emit('delete', { [tag.name]: true })" /> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue index 0f6297ca406..2edeac1144f 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -40,9 +40,9 @@ export default { type: Object, required: true, }, - isDesktop: { + isMobile: { type: Boolean, - default: false, + default: true, required: false, }, selected: { @@ -69,7 +69,7 @@ export default { return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : ''; }, mobileClasses() { - return this.isDesktop ? '' : 'mw-s'; + return this.isMobile ? 'mw-s' : ''; }, shortDigest() { // remove sha256: from the string, and show only the first 7 char diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue index 85d87dab042..ba55822f0ca 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedDropdown } from '@gitlab/ui'; +import { GlDropdown } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import Tracking from '~/tracking'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; @@ -17,7 +17,7 @@ const trackingLabel = 'quickstart_dropdown'; export default { components: { - GlDeprecatedDropdown, + GlDropdown, CodeInstruction, }, mixins: [Tracking.mixin({ label: trackingLabel })], @@ -37,15 +37,14 @@ export default { }; </script> <template> - <gl-deprecated-dropdown + <gl-dropdown :text="$options.i18n.QUICK_START" - variant="primary" - size="sm" + variant="info" right @shown="track('click_dropdown')" > <!-- This li is used as a container since gl-dropdown produces a root ul, this mimics the functionality exposed by b-dropdown-form --> - <li role="presentation" class="px-2 py-1 dropdown-menu-large"> + <li role="presentation" class="px-2 py-1"> <code-instruction :label="$options.i18n.LOGIN_COMMAND_LABEL" :instruction="dockerLoginCommand" @@ -71,5 +70,5 @@ export default { :tracking-label="$options.trackingLabel" /> </li> - </gl-deprecated-dropdown> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index cfd787b3f52..b0a7c4824bd 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -37,15 +37,6 @@ export default { ROW_SCHEDULED_FOR_DELETION, }, computed: { - encodedItem() { - const params = JSON.stringify({ - name: this.item.path, - tags_path: this.item.tags_path, - id: this.item.id, - cleanup_policy_started_at: this.item.cleanup_policy_started_at, - }); - return window.btoa(params); - }, disabledDelete() { return !this.item.destroy_path || this.item.deleting; }, @@ -81,8 +72,8 @@ export default { <template #left-primary> <router-link class="gl-text-body gl-font-weight-bold" - data-testid="detailsLink" - :to="{ name: 'details', params: { id: encodedItem } }" + data-testid="details-link" + :to="{ name: 'details', params: { id: item.id } }" > {{ item.path }} </router-link> diff --git a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue index 146d1434b18..666d8b042da 100644 --- a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue +++ b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue @@ -30,7 +30,7 @@ export default { return { tagName, className, - text: this.$route.meta.nameGenerator(this.$route), + text: this.$route.meta.nameGenerator(this.$store.state), path: { to: this.$route.name }, }; }, @@ -48,7 +48,7 @@ export default { ></li> <li v-if="!isRootRoute"> <router-link ref="rootRouteLink" :to="rootRoute.path"> - {{ rootRoute.meta.nameGenerator(rootRoute) }} + {{ rootRoute.meta.nameGenerator($store.state) }} </router-link> <component :is="divider.tagName" v-safe-html="divider.innerHTML" :class="divider.classList" /> </li> diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index 1dc5882d415..306e6903a4f 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -15,6 +15,10 @@ export const DELETE_TAGS_SUCCESS_MESSAGE = s__( 'ContainerRegistry|Tags successfully marked for deletion.', ); +export const FETCH_IMAGE_DETAILS_ERROR_MESSAGE = s__( + 'ContainerRegistry|Something went wrong while fetching the image details.', +); + export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags'); export const DIGEST_LABEL = s__('ContainerRegistry|Digest: %{imageId}'); export const CREATED_AT_LABEL = s__('ContainerRegistry|Published %{timeInfo}'); diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index d2fb695dbfa..a60ef5c4982 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -11,7 +11,6 @@ import TagsList from '../components/details_page/tags_list.vue'; import TagsLoader from '../components/details_page/tags_loader.vue'; import EmptyTagsState from '../components/details_page/empty_tags_state.vue'; -import { decodeAndParse } from '../utils'; import { ALERT_SUCCESS_TAG, ALERT_DANGER_TAG, @@ -37,18 +36,15 @@ export default { data() { return { itemsToBeDeleted: [], - isDesktop: true, + isMobile: false, deleteAlertType: null, dismissPartialCleanupWarning: false, }; }, computed: { - ...mapState(['tagsPagination', 'isLoading', 'config', 'tags']), - queryParameters() { - return decodeAndParse(this.$route.params.id); - }, + ...mapState(['tagsPagination', 'isLoading', 'config', 'tags', 'imageDetails']), showPartialCleanupWarning() { - return this.queryParameters.cleanup_policy_started_at && !this.dismissPartialCleanupWarning; + return this.imageDetails?.cleanup_policy_started_at && !this.dismissPartialCleanupWarning; }, tracking() { return { @@ -61,15 +57,20 @@ export default { return this.tagsPagination.page; }, set(page) { - this.requestTagsList({ pagination: { page }, params: this.$route.params.id }); + this.requestTagsList({ page }); }, }, }, mounted() { - this.requestTagsList({ params: this.$route.params.id }); + this.requestImageDetailsAndTagsList(this.$route.params.id); }, methods: { - ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']), + ...mapActions([ + 'requestTagsList', + 'requestDeleteTag', + 'requestDeleteTags', + 'requestImageDetailsAndTagsList', + ]), deleteTags(toBeDeleted) { this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]); this.track('click_button'); @@ -78,7 +79,7 @@ export default { handleSingleDelete() { const [itemToDelete] = this.itemsToBeDeleted; this.itemsToBeDeleted = []; - return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id }) + return this.requestDeleteTag({ tag: itemToDelete }) .then(() => { this.deleteAlertType = ALERT_SUCCESS_TAG; }) @@ -92,7 +93,6 @@ export default { return this.requestDeleteTags({ ids: itemsToBeDeleted.map(x => x.name), - params: this.$route.params.id, }) .then(() => { this.deleteAlertType = ALERT_SUCCESS_TAGS; @@ -110,7 +110,7 @@ export default { } }, handleResize() { - this.isDesktop = GlBreakpointInstance.isDesktop(); + this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs'; }, }, }; @@ -132,12 +132,12 @@ export default { @dismiss="dismissPartialCleanupWarning = true" /> - <details-header :image-name="queryParameters.name" /> + <details-header :image-name="imageDetails.name" /> <tags-loader v-if="isLoading" /> <template v-else> <empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" /> - <tags-list v-else :tags="tags" :is-desktop="isDesktop" @delete="deleteTags" /> + <tags-list v-else :tags="tags" :is-mobile="isMobile" @delete="deleteTags" /> </template> <gl-pagination diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js index f570987023b..dcf1c77329d 100644 --- a/app/assets/javascripts/registry/explorer/router.js +++ b/app/assets/javascripts/registry/explorer/router.js @@ -2,7 +2,6 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import List from './pages/list.vue'; import Details from './pages/details.vue'; -import { decodeAndParse } from './utils'; import { CONTAINER_REGISTRY_TITLE } from './constants/index'; Vue.use(VueRouter); @@ -26,7 +25,7 @@ export default function createRouter(base) { path: '/:id', component: Details, meta: { - nameGenerator: route => decodeAndParse(route.params.id).name, + nameGenerator: ({ imageDetails }) => imageDetails?.name, }, }, ], diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 9125f573aa4..c1883095097 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -1,13 +1,15 @@ import axios from '~/lib/utils/axios_utils'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; +import Api from '~/api'; import * as types from './mutation_types'; import { FETCH_IMAGES_LIST_ERROR_MESSAGE, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, FETCH_TAGS_LIST_ERROR_MESSAGE, + FETCH_IMAGE_DETAILS_ERROR_MESSAGE, } from '../constants/index'; -import { decodeAndParse } from '../utils'; +import { pathGenerator } from '../utils'; export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); export const setShowGarbageCollectionTip = ({ commit }, data) => @@ -36,55 +38,68 @@ export const requestImagesList = ( dispatch('receiveImagesListSuccess', { data, headers }); }) .catch(() => { - createFlash(FETCH_IMAGES_LIST_ERROR_MESSAGE); + createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); }) .finally(() => { commit(types.SET_MAIN_LOADING, false); }); }; -export const requestTagsList = ({ commit, dispatch }, { pagination = {}, params }) => { +export const requestTagsList = ({ commit, dispatch, state: { imageDetails } }, pagination = {}) => { commit(types.SET_MAIN_LOADING, true); - const { tags_path } = decodeAndParse(params); + const tagsPath = pathGenerator(imageDetails); const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; return axios - .get(tags_path, { params: { page, per_page: perPage } }) + .get(tagsPath, { params: { page, per_page: perPage } }) .then(({ data, headers }) => { dispatch('receiveTagsListSuccess', { data, headers }); }) .catch(() => { - createFlash(FETCH_TAGS_LIST_ERROR_MESSAGE); + createFlash({ message: FETCH_TAGS_LIST_ERROR_MESSAGE }); }) .finally(() => { commit(types.SET_MAIN_LOADING, false); }); }; -export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) => { +export const requestImageDetailsAndTagsList = ({ dispatch, commit }, id) => { + commit(types.SET_MAIN_LOADING, true); + return Api.containerRegistryDetails(id) + .then(({ data }) => { + commit(types.SET_IMAGE_DETAILS, data); + dispatch('requestTagsList'); + }) + .catch(() => { + createFlash({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE }); + commit(types.SET_MAIN_LOADING, false); + }); +}; + +export const requestDeleteTag = ({ commit, dispatch, state }, { tag }) => { commit(types.SET_MAIN_LOADING, true); return axios .delete(tag.destroy_path) .then(() => { dispatch('setShowGarbageCollectionTip', true); - return dispatch('requestTagsList', { pagination: state.tagsPagination, params }); + + return dispatch('requestTagsList', state.tagsPagination); }) .finally(() => { commit(types.SET_MAIN_LOADING, false); }); }; -export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) => { +export const requestDeleteTags = ({ commit, dispatch, state }, { ids }) => { commit(types.SET_MAIN_LOADING, true); - const { tags_path } = decodeAndParse(params); - const url = tags_path.replace('?format=json', '/bulk_destroy'); + const tagsPath = pathGenerator(state.imageDetails, '/bulk_destroy'); return axios - .delete(url, { params: { ids } }) + .delete(tagsPath, { params: { ids } }) .then(() => { dispatch('setShowGarbageCollectionTip', true); - return dispatch('requestTagsList', { pagination: state.tagsPagination, params }); + return dispatch('requestTagsList', state.tagsPagination); }) .finally(() => { commit(types.SET_MAIN_LOADING, false); diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js index f32cdf90783..5dd0cec52eb 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js +++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js @@ -7,3 +7,4 @@ export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION'; export const SET_TAGS_LIST_SUCCESS = 'SET_TAGS_LIST_SUCCESS'; export const SET_SHOW_GARBAGE_COLLECTION_TIP = 'SET_SHOW_GARBAGE_COLLECTION_TIP'; +export const SET_IMAGE_DETAILS = 'SET_IMAGE_DETAILS'; diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js index 706f6489287..5bdb431ad2e 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ b/app/assets/javascripts/registry/explorer/stores/mutations.js @@ -47,4 +47,8 @@ export default { const normalizedHeaders = normalizeHeaders(headers); state.tagsPagination = parseIntPagination(normalizedHeaders); }, + + [types.SET_IMAGE_DETAILS](state, details) { + state.imageDetails = details; + }, }; diff --git a/app/assets/javascripts/registry/explorer/stores/state.js b/app/assets/javascripts/registry/explorer/stores/state.js index 694006aac81..66ee56eb47b 100644 --- a/app/assets/javascripts/registry/explorer/stores/state.js +++ b/app/assets/javascripts/registry/explorer/stores/state.js @@ -3,6 +3,7 @@ export default () => ({ showGarbageCollectionTip: false, config: {}, images: [], + imageDetails: {}, tags: [], pagination: {}, tagsPagination: {}, diff --git a/app/assets/javascripts/registry/explorer/utils.js b/app/assets/javascripts/registry/explorer/utils.js index 44262a6cbb6..2c89d508c31 100644 --- a/app/assets/javascripts/registry/explorer/utils.js +++ b/app/assets/javascripts/registry/explorer/utils.js @@ -1 +1,16 @@ -export const decodeAndParse = param => JSON.parse(window.atob(param)); +export const pathGenerator = (imageDetails, ending = '?format=json') => { + // this method is a temporary workaround, to be removed with graphql implementation + // https://gitlab.com/gitlab-org/gitlab/-/issues/276432 + + const splitPath = imageDetails.path.split('/').reverse(); + const splitName = imageDetails.name ? imageDetails.name.split('/').reverse() : []; + const basePath = splitPath + .reduce((acc, curr, index) => { + if (splitPath[index] !== splitName[index]) { + acc.unshift(curr); + } + return acc; + }, []) + .join('/'); + return `/${basePath}/registry/repository/${imageDetails.id}/tags${ending}`; +}; diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index a9b35d4e29f..fe4aee6806e 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -117,8 +117,9 @@ export default { const errorMessage = data?.updateContainerExpirationPolicy?.errors[0]; if (errorMessage) { this.$toast.show(errorMessage, { type: 'error' }); + } else { + this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }); } - this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }); }) .catch(error => { this.setApiErrors(error); diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js index 735d72972e6..d1e3d93938b 100644 --- a/app/assets/javascripts/registry/shared/constants.js +++ b/app/assets/javascripts/registry/shared/constants.js @@ -32,7 +32,7 @@ export const KEEP_N_LABEL = s__('ContainerRegistry|Number of tags to retain:'); export const NAME_REGEX_LABEL = s__( 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}', ); -export const NAME_REGEX_PLACEHOLDER = '.*'; +export const NAME_REGEX_PLACEHOLDER = ''; export const NAME_REGEX_DESCRIPTION = s__( 'ContainerRegistry|Wildcards such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}', ); diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue index bbbdf2cdb49..7f12c10f6a1 100644 --- a/app/assets/javascripts/related_issues/components/issue_token.vue +++ b/app/assets/javascripts/related_issues/components/issue_token.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; @@ -8,6 +8,9 @@ export default { components: { GlIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, mixins: [relatedIssuableMixin], props: { isCondensed: { @@ -52,7 +55,7 @@ export default { <component :is="computedLinkElementType" ref="link" - v-tooltip + v-gl-tooltip :class="{ 'issue-token-link': isCondensed, 'issuable-main-info': !isCondensed, @@ -84,7 +87,7 @@ export default { > <gl-icon v-if="hasState" - v-tooltip + v-gl-tooltip :class="iconClass" :name="iconName" :size="12" @@ -98,7 +101,7 @@ export default { <button v-if="canRemove" ref="removeButton" - v-tooltip + v-gl-tooltip :class="{ 'issue-token-remove-button': isCondensed, 'btn btn-default': !isCondensed, diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue index f7a79c62716..c913745a8e1 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -138,7 +138,7 @@ export default { href="#related-issues" aria-hidden="true" /> - <slot name="headerText">{{ __('Linked issues') }}</slot> + <slot name="header-text">{{ __('Linked issues') }}</slot> <gl-link v-if="hasHelpPath" :href="helpPath" @@ -167,7 +167,7 @@ export default { /> </div> </h3> - <slot name="headerActions"></slot> + <slot name="header-actions"></slot> </div> <div class="linked-issues-card-body bg-gray-light" diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue index a75fe4397bb..8021d390d95 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -3,13 +3,9 @@ import { GlLoadingIcon } from '@gitlab/ui'; import Sortable from 'sortablejs'; import sortableConfig from 'ee_else_ce/sortable/sortable_config'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; export default { name: 'RelatedIssuesList', - directives: { - tooltip, - }, components: { GlLoadingIcon, RelatedIssuableItem, @@ -101,7 +97,11 @@ export default { class="related-issues-token-body bordered-box bg-white" :class="{ 'sortable-container': canReorder }" > - <div v-if="isFetching" class="related-issues-loading-icon qa-related-issues-loading-icon"> + <div + v-if="isFetching" + class="related-issues-loading-icon" + data-qa-selector="related_issues_loading_placeholder" + > <gl-loading-icon ref="loadingIcon" label="Fetching linked issues" class="gl-mt-2" /> </div> <ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list"> @@ -136,7 +136,7 @@ export default { :is-locked="issue.lockIssueRemoval" :locked-message="issue.lockedMessage" event-namespace="relatedIssue" - class="qa-related-issuable-item" + data-qa-selector="related_issuable_content" @relatedIssueRemoveRequest="$emit('relatedIssueRemoveRequest', $event)" /> </li> diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 1a07e0ed762..8d1bc44cba0 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -6,7 +6,7 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; import AssetLinksForm from './asset_links_form.vue'; -import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; +import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; import TagField from './tag_field.vue'; export default { @@ -29,11 +29,12 @@ export default { 'markdownDocsPath', 'markdownPreviewPath', 'releasesPagePath', - 'updateReleaseApiDocsPath', 'release', 'newMilestonePath', 'manageMilestonesPath', 'projectId', + 'groupId', + 'groupMilestonesAvailable', ]), ...mapGetters('detail', ['isValid', 'isExistingRelease']), showForm() { @@ -141,6 +142,8 @@ export default { <milestone-combobox v-model="releaseMilestones" :project-id="projectId" + :group-id="groupId" + :group-milestones-available="groupMilestonesAvailable" :extra-links="milestoneComboboxExtraLinks" /> </div> diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index 422d8bf630d..5064b7dd6ad 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -6,6 +6,7 @@ import { __ } from '~/locale'; import ReleaseBlock from './release_block.vue'; import ReleasesPagination from './releases_pagination.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; +import ReleasesSort from './releases_sort.vue'; export default { name: 'ReleasesApp', @@ -16,6 +17,7 @@ export default { ReleaseBlock, ReleasesPagination, ReleaseSkeletonLoader, + ReleasesSort, }, computed: { ...mapState('list', [ @@ -62,16 +64,20 @@ export default { </script> <template> <div class="flex flex-column mt-2"> - <gl-button - v-if="newReleasePath" - :href="newReleasePath" - :aria-describedby="shouldRenderEmptyState && 'releases-description'" - category="primary" - variant="success" - class="align-self-end mb-2 js-new-release-btn" - > - {{ __('New release') }} - </gl-button> + <div class="gl-align-self-end gl-mb-3"> + <releases-sort class="gl-mr-2" @sort:changed="fetchReleases" /> + + <gl-button + v-if="newReleasePath" + :href="newReleasePath" + :aria-describedby="shouldRenderEmptyState && 'releases-description'" + category="primary" + variant="success" + class="js-new-release-btn" + > + {{ __('New release') }} + </gl-button> + </div> <release-skeleton-loader v-if="isLoading" class="js-loading" /> diff --git a/app/assets/javascripts/releases/components/issuable_stats.vue b/app/assets/javascripts/releases/components/issuable_stats.vue new file mode 100644 index 00000000000..d005d8e10dd --- /dev/null +++ b/app/assets/javascripts/releases/components/issuable_stats.vue @@ -0,0 +1,97 @@ +<script> +import { GlLink, GlBadge, GlSprintf } from '@gitlab/ui'; + +export default { + name: 'IssuableStats', + components: { + GlLink, + GlBadge, + GlSprintf, + }, + props: { + label: { + type: String, + required: true, + }, + total: { + type: Number, + required: true, + }, + closed: { + type: Number, + required: true, + }, + merged: { + type: Number, + required: false, + default: null, + }, + openedPath: { + type: String, + required: false, + default: '', + }, + closedPath: { + type: String, + required: false, + default: '', + }, + mergedPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + opened() { + return this.total - (this.closed + (this.merged || 0)); + }, + showMerged() { + return this.merged != null; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-flex-direction-column gl-flex-shrink-0 gl-mr-6 gl-mb-5"> + <span class="gl-mb-2"> + {{ label }} + <gl-badge variant="muted" size="sm">{{ total }}</gl-badge> + </span> + <div class="gl-display-flex"> + <span class="gl-white-space-pre-wrap" data-testid="open-stat"> + <gl-sprintf :message="__('Open: %{open}')"> + <template #open> + <gl-link v-if="openedPath" :href="openedPath">{{ opened }}</gl-link> + <template v-else>{{ opened }}</template> + </template> + </gl-sprintf> + </span> + + <template v-if="showMerged"> + <span class="gl-mx-2">•</span> + + <span class="gl-white-space-pre-wrap" data-testid="merged-stat"> + <gl-sprintf :message="__('Merged: %{merged}')"> + <template #merged> + <gl-link v-if="mergedPath" :href="mergedPath">{{ merged }}</gl-link> + <template v-else>{{ merged }}</template> + </template> + </gl-sprintf> + </span> + </template> + + <span class="gl-mx-2">•</span> + + <span class="gl-white-space-pre-wrap" data-testid="closed-stat"> + <gl-sprintf :message="__('Closed: %{closed}')"> + <template #closed> + <gl-link v-if="closedPath" :href="closedPath">{{ closed }}</gl-link> + <template v-else>{{ closed }}</template> + </template> + </gl-sprintf> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index e9163a52792..b89e5f2df3f 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -87,9 +87,14 @@ export default { <release-block-header :release="release" /> <div class="card-body"> <div v-if="shouldRenderMilestoneInfo"> + <!-- TODO: Switch open* links to opened* once fields have been updated in GraphQL --> <release-block-milestone-info :milestones="milestones" - :open-issues-path="release._links.issuesUrl" + :opened-issues-path="release._links.openedIssuesUrl" + :closed-issues-path="release._links.closedIssuesUrl" + :opened-merge-requests-path="release._links.openedMergeRequestsUrl" + :merged-merge-requests-path="release._links.mergedMergeRequestsUrl" + :closed-merge-requests-path="release._links.closedMergeRequestsUrl" /> <hr class="mb-3 mt-0" /> </div> diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue index deff673cc17..daa9c3480f4 100644 --- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue +++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue @@ -1,24 +1,16 @@ <script> -import { - GlProgressBar, - GlLink, - GlBadge, - GlButton, - GlTooltipDirective, - GlSprintf, -} from '@gitlab/ui'; -import { sum } from 'lodash'; +import { GlProgressBar, GlLink, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { __, n__, sprintf } from '~/locale'; import { MAX_MILESTONES_TO_DISPLAY } from '../constants'; +import IssuableStats from './issuable_stats.vue'; export default { name: 'ReleaseBlockMilestoneInfo', components: { GlProgressBar, GlLink, - GlBadge, GlButton, - GlSprintf, + IssuableStats, }, directives: { GlTooltip: GlTooltipDirective, @@ -28,7 +20,7 @@ export default { type: Array, required: true, }, - openIssuesPath: { + openedIssuesPath: { type: String, required: false, default: '', @@ -38,6 +30,21 @@ export default { required: false, default: '', }, + openedMergeRequestsPath: { + type: String, + required: false, + default: '', + }, + mergedMergeRequestsPath: { + type: String, + required: false, + default: '', + }, + closedMergeRequestsPath: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -52,30 +59,49 @@ export default { }); }, percentComplete() { - const percent = Math.round((this.closedIssuesCount / this.totalIssuesCount) * 100); + const percent = Math.round((this.issueCounts.closed / this.issueCounts.total) * 100); return Number.isNaN(percent) ? 0 : percent; }, - allIssueStats() { - return this.milestones.map(m => m.issueStats || {}); - }, - totalIssuesCount() { - return sum(this.allIssueStats.map(stats => stats.total || 0)); - }, - closedIssuesCount() { - return sum(this.allIssueStats.map(stats => stats.closed || 0)); - }, - openIssuesCount() { - return this.totalIssuesCount - this.closedIssuesCount; + issueCounts() { + return this.milestones + .map(m => m.issueStats || {}) + .reduce( + (acc, current) => { + acc.total += current.total || 0; + acc.closed += current.closed || 0; + + return acc; + }, + { + total: 0, + closed: 0, + }, + ); + }, + showMergeRequestStats() { + return this.milestones.some(m => m.mrStats); + }, + mergeRequestCounts() { + return this.milestones + .map(m => m.mrStats || {}) + .reduce( + (acc, current) => { + acc.total += current.total || 0; + acc.merged += current.merged || 0; + acc.closed += current.closed || 0; + + return acc; + }, + { + total: 0, + merged: 0, + closed: 0, + }, + ); }, milestoneLabelText() { return n__('Milestone', 'Milestones', this.milestones.length); }, - issueCountsText() { - return sprintf(__('Open: %{open} • Closed: %{closed}'), { - open: this.openIssuesCount, - closed: this.closedIssuesCount, - }); - }, milestonesToDisplay() { return this.showAllMilestones ? this.milestones @@ -106,20 +132,22 @@ export default { }; </script> <template> - <div class="release-block-milestone-info d-flex align-items-start flex-wrap"> + <div class="release-block-milestone-info gl-display-flex gl-flex-wrap"> <div v-gl-tooltip - class="milestone-progress-bar-container js-milestone-progress-bar-container d-flex flex-column align-items-start flex-shrink-1 mr-4 mb-3" + class="milestone-progress-bar-container js-milestone-progress-bar-container gl-display-flex gl-flex-direction-column gl-mr-6 gl-mb-5" :title="__('Closed issues')" > - <span class="mb-2">{{ percentCompleteText }}</span> - <span class="w-100"> - <gl-progress-bar :value="closedIssuesCount" :max="totalIssuesCount" variant="success" /> + <span class="gl-mb-3">{{ percentCompleteText }}</span> + <span class="gl-w-full"> + <gl-progress-bar :value="issueCounts.closed" :max="issueCounts.total" variant="success" /> </span> </div> - <div class="d-flex flex-column align-items-start mr-4 mb-3 js-milestone-list-container"> - <span class="mb-1">{{ milestoneLabelText }}</span> - <div class="d-flex flex-wrap align-items-end"> + <div + class="gl-display-flex gl-flex-direction-column gl-mr-6 gl-mb-5 js-milestone-list-container" + > + <span class="gl-mb-2">{{ milestoneLabelText }}</span> + <div class="gl-display-flex gl-flex-wrap gl-align-items-flex-end"> <template v-for="(milestone, index) in milestonesToDisplay"> <gl-link :key="milestone.id" @@ -141,32 +169,24 @@ export default { </template> </div> </div> - <div class="d-flex flex-column align-items-start flex-shrink-0 mr-4 mb-3 js-issues-container"> - <span class="mb-1"> - {{ __('Issues') }} - <gl-badge variant="muted" size="sm">{{ totalIssuesCount }}</gl-badge> - </span> - <div class="d-flex"> - <gl-link v-if="openIssuesPath" ref="openIssuesLink" :href="openIssuesPath"> - <gl-sprintf :message="__('Open: %{openIssuesCount}')"> - <template #openIssuesCount>{{ openIssuesCount }}</template> - </gl-sprintf> - </gl-link> - <span v-else ref="openIssuesText"> - {{ sprintf(__('Open: %{openIssuesCount}'), { openIssuesCount }) }} - </span> - - <span class="mx-1">•</span> - - <gl-link v-if="closedIssuesPath" ref="closedIssuesLink" :href="closedIssuesPath"> - <gl-sprintf :message="__('Closed: %{closedIssuesCount}')"> - <template #closedIssuesCount>{{ closedIssuesCount }}</template> - </gl-sprintf> - </gl-link> - <span v-else ref="closedIssuesText"> - {{ sprintf(__('Closed: %{closedIssuesCount}'), { closedIssuesCount }) }} - </span> - </div> - </div> + <issuable-stats + :label="__('Issues')" + :total="issueCounts.total" + :closed="issueCounts.closed" + :opened-path="openedIssuesPath" + :closed-path="closedIssuesPath" + data-testid="issue-stats" + /> + <issuable-stats + v-if="showMergeRequestStats" + :label="__('Merge Requests')" + :total="mergeRequestCounts.total" + :merged="mergeRequestCounts.merged" + :closed="mergeRequestCounts.closed" + :opened-path="openedMergeRequestsPath" + :merged-path="mergedMergeRequestsPath" + :closed-path="closedMergeRequestsPath" + data-testid="merge-request-stats" + /> </div> </template> diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue new file mode 100644 index 00000000000..50f6f3c19bd --- /dev/null +++ b/app/assets/javascripts/releases/components/releases_sort.vue @@ -0,0 +1,62 @@ +<script> +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { ASCENDING_ODER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants'; + +export default { + name: 'ReleasesSort', + components: { + GlSorting, + GlSortingItem, + }, + computed: { + ...mapState('list', { + orderBy: state => state.sorting.orderBy, + sort: state => state.sorting.sort, + }), + sortOptions() { + return SORT_OPTIONS; + }, + sortText() { + const option = this.sortOptions.find(s => s.orderBy === this.orderBy); + return option.label; + }, + isSortAscending() { + return this.sort === ASCENDING_ODER; + }, + }, + methods: { + ...mapActions('list', ['setSorting']), + onDirectionChange() { + const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER; + this.setSorting({ sort }); + this.$emit('sort:changed'); + }, + onSortItemClick(item) { + this.setSorting({ orderBy: item }); + this.$emit('sort:changed'); + }, + isActiveSortItem(item) { + return this.orderBy === item; + }, + }, +}; +</script> + +<template> + <gl-sorting + :text="sortText" + :is-ascending="isSortAscending" + data-testid="releases-sort" + @sortDirectionChange="onDirectionChange" + > + <gl-sorting-item + v-for="item in sortOptions" + :key="item.orderBy" + :active="isActiveSortItem(item.orderBy)" + @click="onSortItemClick(item.orderBy)" + > + {{ item.label }} + </gl-sorting-item> + </gl-sorting> +</template> diff --git a/app/assets/javascripts/releases/components/tag_field_existing.vue b/app/assets/javascripts/releases/components/tag_field_existing.vue index b84e713df26..046885fe2f6 100644 --- a/app/assets/javascripts/releases/components/tag_field_existing.vue +++ b/app/assets/javascripts/releases/components/tag_field_existing.vue @@ -1,14 +1,14 @@ <script> import { mapState } from 'vuex'; import { uniqueId } from 'lodash'; -import { GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; import FormFieldContainer from './form_field_container.vue'; export default { name: 'TagFieldExisting', - components: { GlFormGroup, GlFormInput, GlSprintf, GlLink, FormFieldContainer }, + components: { GlFormGroup, GlFormInput, FormFieldContainer }, computed: { - ...mapState('detail', ['release', 'updateReleaseApiDocsPath']), + ...mapState('detail', ['release']), inputId() { return uniqueId('tag-name-input-'); }, @@ -32,19 +32,7 @@ export default { </form-field-container> <template #description> <div :id="helpId" data-testid="tag-name-help"> - <gl-sprintf - :message=" - __( - 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}', - ) - " - > - <template #link="{ content }"> - <gl-link :href="updateReleaseApiDocsPath" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + {{ __("The tag name can't be changed for an existing release.") }} </div> </template> </gl-form-group> diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js index 953e7b4189c..8979aa1394d 100644 --- a/app/assets/javascripts/releases/constants.js +++ b/app/assets/javascripts/releases/constants.js @@ -1,3 +1,5 @@ +import { __ } from '~/locale'; + export const MAX_MILESTONES_TO_DISPLAY = 5; export const BACK_URL_PARAM = 'back_url'; @@ -12,3 +14,19 @@ export const ASSET_LINK_TYPE = Object.freeze({ export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER; export const PAGE_SIZE = 20; + +export const ASCENDING_ODER = 'asc'; +export const DESCENDING_ORDER = 'desc'; +export const RELEASED_AT = 'released_at'; +export const CREATED_AT = 'created_at'; + +export const SORT_OPTIONS = [ + { + orderBy: RELEASED_AT, + label: __('Released date'), + }, + { + orderBy: CREATED_AT, + label: __('Created date'), + }, +]; diff --git a/app/assets/javascripts/releases/queries/all_releases.query.graphql b/app/assets/javascripts/releases/queries/all_releases.query.graphql index c35306f163d..a07dabb9fd6 100644 --- a/app/assets/javascripts/releases/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/queries/all_releases.query.graphql @@ -1,8 +1,15 @@ #import "./release.fragment.graphql" -query allReleases($fullPath: ID!, $first: Int, $last: Int, $before: String, $after: String) { +query allReleases( + $fullPath: ID! + $first: Int + $last: Int + $before: String + $after: String + $sort: ReleaseSort +) { project(fullPath: $fullPath) { - releases(first: $first, last: $last, before: $before, after: $after) { + releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { nodes { ...Release } diff --git a/app/assets/javascripts/releases/queries/release.fragment.graphql b/app/assets/javascripts/releases/queries/release.fragment.graphql index 445ed616348..3a742db7d9e 100644 --- a/app/assets/javascripts/releases/queries/release.fragment.graphql +++ b/app/assets/javascripts/releases/queries/release.fragment.graphql @@ -4,6 +4,7 @@ fragment Release on Release { tagPath descriptionHtml releasedAt + createdAt upcomingRelease assets { count @@ -33,9 +34,12 @@ fragment Release on Release { } links { editUrl - issuesUrl - mergeRequestsUrl selfUrl + openedIssuesUrl + closedIssuesUrl + openedMergeRequestsUrl + mergedMergeRequestsUrl + closedMergeRequestsUrl } commit { sha diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index 782a5c46d6c..315d07ac664 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js @@ -1,9 +1,10 @@ export default ({ projectId, + groupId, + groupMilestonesAvailable = false, projectPath, markdownDocsPath, markdownPreviewPath, - updateReleaseApiDocsPath, releaseAssetsDocsPath, manageMilestonesPath, newMilestonePath, @@ -13,10 +14,11 @@ export default ({ defaultBranch = null, }) => ({ projectId, + groupId, + groupMilestonesAvailable: Boolean(groupMilestonesAvailable), projectPath, markdownDocsPath, markdownPreviewPath, - updateReleaseApiDocsPath, releaseAssetsDocsPath, manageMilestonesPath, newMilestonePath, diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js index 02e67415e63..a62f7c25464 100644 --- a/app/assets/javascripts/releases/stores/modules/list/actions.js +++ b/app/assets/javascripts/releases/stores/modules/list/actions.js @@ -42,6 +42,10 @@ export const fetchReleasesGraphQl = ( ) => { commit(types.REQUEST_RELEASES); + const { sort, orderBy } = state.sorting; + const orderByParam = orderBy === 'created_at' ? 'created' : orderBy; + const sortParams = `${orderByParam}_${sort}`.toUpperCase(); + let paginationParams; if (!before && !after) { paginationParams = { first: PAGE_SIZE }; @@ -60,6 +64,7 @@ export const fetchReleasesGraphQl = ( query: allReleasesQuery, variables: { fullPath: state.projectPath, + sort: sortParams, ...paginationParams, }, }) @@ -80,8 +85,10 @@ export const fetchReleasesGraphQl = ( export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => { commit(types.REQUEST_RELEASES); + const { sort, orderBy } = state.sorting; + api - .releases(state.projectId, { page }) + .releases(state.projectId, { page, sort, order_by: orderBy }) .then(({ data, headers }) => { const restPageInfo = parseIntPagination(normalizeHeaders(headers)); const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true }); @@ -98,3 +105,5 @@ export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); createFlash(__('An error occurred while fetching the releases. Please try again.')); }; + +export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data); diff --git a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js b/app/assets/javascripts/releases/stores/modules/list/mutation_types.js index a74bf15c515..669168efb88 100644 --- a/app/assets/javascripts/releases/stores/modules/list/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/list/mutation_types.js @@ -1,3 +1,4 @@ export const REQUEST_RELEASES = 'REQUEST_RELEASES'; export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS'; export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR'; +export const SET_SORTING = 'SET_SORTING'; diff --git a/app/assets/javascripts/releases/stores/modules/list/mutations.js b/app/assets/javascripts/releases/stores/modules/list/mutations.js index 296487cfee2..e1aaa2e2a19 100644 --- a/app/assets/javascripts/releases/stores/modules/list/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/list/mutations.js @@ -39,4 +39,8 @@ export default { state.restPageInfo = {}; state.graphQlPageInfo = {}; }, + + [types.SET_SORTING](state, sorting) { + state.sorting = { ...state.sorting, ...sorting }; + }, }; diff --git a/app/assets/javascripts/releases/stores/modules/list/state.js b/app/assets/javascripts/releases/stores/modules/list/state.js index 0bffaa0f9db..164a496d450 100644 --- a/app/assets/javascripts/releases/stores/modules/list/state.js +++ b/app/assets/javascripts/releases/stores/modules/list/state.js @@ -1,3 +1,5 @@ +import { DESCENDING_ORDER, RELEASED_AT } from '../../../constants'; + export default ({ projectId, projectPath, @@ -16,4 +18,8 @@ export default ({ releases: [], restPageInfo: {}, graphQlPageInfo: {}, + sorting: { + sort: DESCENDING_ORDER, + orderBy: RELEASED_AT, + }, }); diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js index 445c429fd96..464f0594b8d 100644 --- a/app/assets/javascripts/releases/util.js +++ b/app/assets/javascripts/releases/util.js @@ -15,7 +15,9 @@ import { export const releaseToApiJson = (release, createFrom = null) => { const name = release.name?.trim().length > 0 ? release.name.trim() : null; - const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : []; + // Milestones may be either a list of milestone objects OR just a list + // of milestone titles. The API requires only the titles be sent. + const milestones = (release.milestones || []).map(m => m.title || m); return convertObjectPropsToSnakeCase( { diff --git a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue index 0c758ee2b5c..d0a5615bb57 100644 --- a/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue +++ b/app/assets/javascripts/reports/codequality_report/components/codequality_issue_body.vue @@ -3,15 +3,21 @@ * Renders Code quality body text * Fixed: [name] in [link]:[line] */ +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import ReportLink from '~/reports/components/report_link.vue'; import { STATUS_SUCCESS } from '~/reports/constants'; +import { s__ } from '~/locale'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '../constants'; export default { name: 'CodequalityIssueBody', - components: { + GlIcon, ReportLink, }, + directives: { + tooltip: GlTooltipDirective, + }, props: { status: { type: String, @@ -23,20 +29,44 @@ export default { }, }, computed: { + issueName() { + return `${this.severityLabel} - ${this.issue.name}`; + }, isStatusSuccess() { return this.status === STATUS_SUCCESS; }, + severityClass() { + return SEVERITY_CLASSES[this.issue.severity] || SEVERITY_CLASSES.unknown; + }, + severityIcon() { + return SEVERITY_ICONS[this.issue.severity] || SEVERITY_ICONS.unknown; + }, + severityLabel() { + return this.$options.severityText[this.issue.severity] || this.$options.severityText.unknown; + }, + }, + severityText: { + info: s__('severity|Info'), + minor: s__('severity|Minor'), + major: s__('severity|Major'), + critical: s__('severity|Critical'), + blocker: s__('severity|Blocker'), + unknown: s__('severity|Unknown'), }, }; </script> <template> - <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> - <div class="report-block-list-issue-description-text"> - <template v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</template> + <div class="gl-display-flex gl-mt-2 gl-mb-2 gl-w-full"> + <span :class="severityClass" class="gl-mr-5" data-testid="codequality-severity-icon"> + <gl-icon v-tooltip="severityLabel" :name="severityIcon" :size="12" /> + </span> + <div class="gl-flex-fill-1"> + <div> + <strong v-if="isStatusSuccess">{{ s__('ciReport|Fixed:') }}</strong> + {{ issueName }} + </div> - {{ issue.name }} + <report-link v-if="issue.path" :issue="issue" /> </div> - - <report-link v-if="issue.path" :issue="issue" /> </div> </template> diff --git a/app/assets/javascripts/reports/codequality_report/constants.js b/app/assets/javascripts/reports/codequality_report/constants.js new file mode 100644 index 00000000000..502977e714c --- /dev/null +++ b/app/assets/javascripts/reports/codequality_report/constants.js @@ -0,0 +1,17 @@ +export const SEVERITY_CLASSES = { + info: 'text-primary-400', + minor: 'text-warning-200', + major: 'text-warning-400', + critical: 'text-danger-600', + blocker: 'text-danger-800', + unknown: 'text-secondary-400', +}; + +export const SEVERITY_ICONS = { + info: 'severity-info', + minor: 'severity-low', + major: 'severity-medium', + critical: 'severity-high', + blocker: 'severity-critical', + unknown: 'severity-unknown', +}; diff --git a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue index f3d5b1a80f8..5c8f31d7da0 100644 --- a/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue +++ b/app/assets/javascripts/reports/codequality_report/grouped_codequality_reports_app.vue @@ -78,6 +78,7 @@ export default { :has-issues="hasCodequalityIssues" :component="$options.componentNames.CodequalityIssueBody" :popover-options="codequalityPopover" + :show-report-section-status-icon="false" class="js-codequality-widget mr-widget-border-top mr-report" /> </template> diff --git a/app/assets/javascripts/reports/codequality_report/store/getters.js b/app/assets/javascripts/reports/codequality_report/store/getters.js index 5df58c7f85f..d7c31bcf459 100644 --- a/app/assets/javascripts/reports/codequality_report/store/getters.js +++ b/app/assets/javascripts/reports/codequality_report/store/getters.js @@ -1,5 +1,6 @@ import { LOADING, ERROR, SUCCESS } from '../../constants'; import { sprintf, __, s__, n__ } from '~/locale'; +import { spriteIcon } from '~/lib/utils/common_utils'; export const hasCodequalityIssues = state => Boolean(state.newIssues?.length || state.resolvedIssues?.length); @@ -48,7 +49,7 @@ export const codequalityPopover = state => { s__('ciReport|%{linkStartTag}Learn more about codequality reports %{linkEndTag}'), { linkStartTag: `<a href="${state.helpPath}" target="_blank" rel="noopener noreferrer">`, - linkEndTag: '<i class="fa fa-external-link" aria-hidden="true"></i></a>', + linkEndTag: `${spriteIcon('external-link', 's16')}</a>`, }, false, ), diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index 47f04019595..c13df60198b 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -1,5 +1,6 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; +import { once } from 'lodash'; import { GlButton } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import { componentNames } from './issue_body'; @@ -8,8 +9,14 @@ import SummaryRow from './summary_row.vue'; import IssuesList from './issues_list.vue'; import Modal from './modal.vue'; import createStore from '../store'; +import Tracking from '~/tracking'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { summaryTextBuilder, reportTextBuilder, statusIcon } from '../store/utils'; +import { + summaryTextBuilder, + reportTextBuilder, + statusIcon, + recentFailuresTextBuilder, +} from '../store/utils'; export default { name: 'GroupedTestReportsApp', @@ -21,7 +28,7 @@ export default { Modal, GlButton, }, - mixins: [glFeatureFlagsMixin()], + mixins: [glFeatureFlagsMixin(), Tracking.mixin()], props: { endpoint: { type: String, @@ -58,6 +65,11 @@ export default { showViewFullReport() { return this.pipelinePath.length; }, + handleToggleEvent() { + return once(() => { + this.track(this.$options.expandEvent); + }); + }, }, created() { this.setEndpoint(this.endpoint); @@ -79,6 +91,12 @@ export default { return reportTextBuilder(name, summary); }, + hasRecentFailures(summary) { + return this.glFeatures.testFailureHistory && summary?.recentlyFailed > 0; + }, + recentFailuresText(summary) { + return recentFailuresTextBuilder(summary); + }, getReportIcon(report) { return statusIcon(report.status); }, @@ -102,6 +120,7 @@ export default { return report.resolved_failures.concat(report.resolved_errors); }, }, + expandEvent: 'expand_test_report_widget', }; </script> <template> @@ -111,9 +130,11 @@ export default { :loading-text="groupedSummaryText" :error-text="groupedSummaryText" :has-issues="reports.length > 0" + :should-emit-toggle-event="true" class="mr-widget-section grouped-security-reports mr-report" + @toggleEvent="handleToggleEvent" > - <template v-if="showViewFullReport" #actionButtons> + <template v-if="showViewFullReport" #action-buttons> <gl-button :href="testTabURL" target="_blank" @@ -124,14 +145,22 @@ export default { {{ s__('ciReport|View full report') }} </gl-button> </template> + <template v-if="hasRecentFailures(summary)" #sub-heading> + {{ recentFailuresText(summary) }} + </template> <template #body> <div class="mr-widget-grouped-section report-block"> <template v-for="(report, i) in reports"> - <summary-row - :key="`summary-row-${i}`" - :summary="reportText(report)" - :status-icon="getReportIcon(report)" - /> + <summary-row :key="`summary-row-${i}`" :status-icon="getReportIcon(report)"> + <template #summary> + <div class="gl-display-inline-flex gl-flex-direction-column"> + <div>{{ reportText(report) }}</div> + <div v-if="hasRecentFailures(report.summary)"> + {{ recentFailuresText(report.summary) }} + </div> + </div> + </template> + </summary-row> <issues-list v-if="shouldRenderIssuesList(report)" :key="`issues-list-${i}`" diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 63af8a5a9ac..f245e2bfd2f 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -181,14 +181,15 @@ export default { <slot :name="slotName"></slot> <popover v-if="hasPopover" :options="popoverOptions" class="gl-ml-2" /> </div> - <slot name="subHeading"></slot> + <slot name="sub-heading"></slot> </div> - <slot name="actionButtons"></slot> + <slot name="action-buttons"></slot> <button v-if="isCollapsible" type="button" + data-testid="report-section-expand-button" class="js-collapse-btn btn float-right btn-sm align-self-center qa-expand-report-button" @click="toggleCollapsed" > diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue index 4e0631740d8..5e9a5b03543 100644 --- a/app/assets/javascripts/reports/components/test_issue_body.vue +++ b/app/assets/javascripts/reports/components/test_issue_body.vue @@ -1,8 +1,15 @@ <script> import { mapActions } from 'vuex'; +import { GlBadge } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'TestIssueBody', + components: { + GlBadge, + }, + mixins: [glFeatureFlagsMixin()], props: { issue: { type: Object, @@ -19,8 +26,20 @@ export default { default: false, }, }, + computed: { + showRecentFailures() { + return this.glFeatures.testFailureHistory && this.issue.recent_failures; + }, + }, methods: { ...mapActions(['openModal']), + recentFailuresText(count) { + return n__( + 'Failed %d time in the last 14 days', + 'Failed %d times in the last 14 days', + count, + ); + }, }, }; </script> @@ -32,7 +51,10 @@ export default { class="btn-link btn-blank text-left break-link vulnerability-name-button" @click="openModal({ issue })" > - <div v-if="isNew" class="badge badge-danger gl-mr-2">{{ s__('New') }}</div> + <gl-badge v-if="isNew" variant="danger" class="gl-mr-2">{{ s__('New') }}</gl-badge> + <gl-badge v-if="showRecentFailures" variant="warning" class="gl-mr-2"> + {{ recentFailuresText(issue.recent_failures) }} + </gl-badge> {{ issue.name }} </button> </div> diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js index 35ab72bf694..acaa98754b0 100644 --- a/app/assets/javascripts/reports/store/mutations.js +++ b/app/assets/javascripts/reports/store/mutations.js @@ -1,4 +1,5 @@ import * as types from './mutation_types'; +import { countRecentlyFailedTests } from './utils'; export default { [types.SET_ENDPOINT](state, endpoint) { @@ -16,9 +17,15 @@ export default { state.summary.resolved = response.summary.resolved; state.summary.failed = response.summary.failed; state.summary.errored = response.summary.errored; + state.summary.recentlyFailed = countRecentlyFailedTests(response.suites); state.status = response.status; state.reports = response.suites; + + state.reports.forEach((report, i) => { + if (!state.reports[i].summary) return; + state.reports[i].summary.recentlyFailed = countRecentlyFailedTests(report); + }); }, [types.RECEIVE_REPORTS_ERROR](state) { state.isLoading = false; @@ -30,6 +37,7 @@ export default { resolved: 0, failed: 0, errored: 0, + recentlyFailed: 0, }; state.status = null; }, diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js index 5d3d9ddda3b..fd6f4933cfa 100644 --- a/app/assets/javascripts/reports/store/utils.js +++ b/app/assets/javascripts/reports/store/utils.js @@ -48,6 +48,48 @@ export const reportTextBuilder = (name = '', results = {}) => { return sprintf(__('%{name} found %{resultsString}'), { name, resultsString }); }; +export const recentFailuresTextBuilder = (summary = {}) => { + const { failed, recentlyFailed } = summary; + if (!failed || !recentlyFailed) return ''; + + if (failed < 2) { + return sprintf( + s__( + 'Reports|%{recentlyFailed} out of %{failed} failed test has failed more than once in the last 14 days', + ), + { recentlyFailed, failed }, + ); + } + return sprintf( + n__( + s__( + 'Reports|%{recentlyFailed} out of %{failed} failed tests has failed more than once in the last 14 days', + ), + s__( + 'Reports|%{recentlyFailed} out of %{failed} failed tests have failed more than once in the last 14 days', + ), + recentlyFailed, + ), + { recentlyFailed, failed }, + ); +}; + +export const countRecentlyFailedTests = subject => { + // handle either a single report or an array of reports + const reports = !subject.length ? [subject] : subject; + + return reports + .map(report => { + return ( + [report.new_failures, report.existing_failures, report.resolved_failures] + // only count tests which have failed more than once + .map(failureArray => failureArray.filter(failure => failure.recent_failures > 1).length) + .reduce((total, count) => total + count, 0) + ); + }) + .reduce((total, count) => total + count, 0); +}; + export const statusIcon = status => { if (status === STATUS_FAILED) { return ICON_WARNING; diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 677cb265942..a1f1c77df2f 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -6,12 +6,12 @@ import { GlDropdownItem, GlIcon, } from '@gitlab/ui'; +import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import { __ } from '../../locale'; import getRefMixin from '../mixins/get_ref'; import projectShortPathQuery from '../queries/project_short_path.query.graphql'; import projectPathQuery from '../queries/project_path.query.graphql'; -import permissionsQuery from '../queries/permissions.query.graphql'; const ROW_TYPES = { header: 'header', diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 0e2bccfabdd..2626bace363 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -137,8 +137,8 @@ export default { :href="commit.author.webPath" class="commit-author-link js-user-link" > - {{ commit.author.name }} - </gl-link> + {{ commit.author.name }}</gl-link + > <template v-else> {{ commit.authorName }} </template> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 4e2c8332f37..c9c5aa37645 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -58,7 +58,7 @@ export default { </gl-link> </div> </div> - <div class="blob-viewer" data-qa-selector="blob_viewer_content"> + <div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about"> <gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" /> <div v-else-if="readme" ref="readme" v-html="readme.html"></div> </div> diff --git a/app/assets/javascripts/repository/components/tree_action_link.vue b/app/assets/javascripts/repository/components/tree_action_link.vue index 72764f3ccc9..c5ab150adaf 100644 --- a/app/assets/javascripts/repository/components/tree_action_link.vue +++ b/app/assets/javascripts/repository/components/tree_action_link.vue @@ -24,5 +24,5 @@ export default { </script> <template> - <gl-link :href="path" :class="cssClass" class="btn">{{ text }}</gl-link> + <gl-link :href="path" :class="cssClass" class="btn gl-button">{{ text }}</gl-link> </template> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 78b8baaa75e..b42f88631b5 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -1,9 +1,9 @@ <script> +import filesQuery from 'shared_queries/repository/files.query.graphql'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '../../locale'; import FileTable from './table/index.vue'; import getRefMixin from '../mixins/get_ref'; -import filesQuery from '../queries/files.query.graphql'; import projectPathQuery from '../queries/project_path.query.graphql'; import FilePreview from './preview/index.vue'; import { readmeFile } from '../utils/readme'; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index a62b2d96c54..f56b141fe5c 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import PathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { escapeFileUrl } from '../lib/utils/url_utility'; import createRouter from './router'; import App from './components/app.vue'; @@ -19,10 +18,6 @@ export default function setupVueRepositoryList() { const { dataset } = el; const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset; const router = createRouter(projectPath, escapedRef); - const pathRegex = /-\/tree\/[^/]+\/(.+$)/; - const matches = window.location.href.match(pathRegex); - - const currentRoutePath = matches ? matches[1] : ''; apolloProvider.clients.defaultClient.cache.writeData({ data: { @@ -48,28 +43,7 @@ export default function setupVueRepositoryList() { }, }); - if (window.gl.startup_graphql_calls) { - const query = window.gl.startup_graphql_calls.find( - call => call.operationName === 'pathLastCommit', - ); - query.fetchCall - .then(res => res.json()) - .then(res => { - apolloProvider.clients.defaultClient.writeQuery({ - query: PathLastCommitQuery, - data: res.data, - variables: { - projectPath, - ref, - path: currentRoutePath, - }, - }); - }) - .catch(() => {}) - .finally(() => initLastCommitApp()); - } else { - initLastCommitApp(); - } + initLastCommitApp(); router.afterEach(({ params: { path } }) => { setTitle(path, ref, fullName); diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js index cb1d7f3aac9..c1607866941 100644 --- a/app/assets/javascripts/repository/mixins/preload.js +++ b/app/assets/javascripts/repository/mixins/preload.js @@ -1,4 +1,4 @@ -import filesQuery from '../queries/files.query.graphql'; +import filesQuery from 'shared_queries/repository/files.query.graphql'; import getRefMixin from './get_ref'; import projectPathQuery from '../queries/project_path.query.graphql'; diff --git a/app/assets/javascripts/repository/queries/files.query.graphql b/app/assets/javascripts/repository/queries/files.query.graphql deleted file mode 100644 index 9e9f5303dd4..00000000000 --- a/app/assets/javascripts/repository/queries/files.query.graphql +++ /dev/null @@ -1,60 +0,0 @@ -#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" - -fragment TreeEntry on Entry { - id - sha - name - flatPath - type -} - -query getFiles( - $projectPath: ID! - $path: String - $ref: String! - $pageSize: Int! - $nextPageCursor: String -) { - project(fullPath: $projectPath) { - repository { - tree(path: $path, ref: $ref) { - trees(first: $pageSize, after: $nextPageCursor) { - edges { - node { - ...TreeEntry - webPath - } - } - pageInfo { - ...PageInfo - } - } - submodules(first: $pageSize, after: $nextPageCursor) { - edges { - node { - ...TreeEntry - webUrl - treeUrl - } - } - pageInfo { - ...PageInfo - } - } - blobs(first: $pageSize, after: $nextPageCursor) { - edges { - node { - ...TreeEntry - mode - webPath - lfsOid - } - } - pageInfo { - ...PageInfo - } - } - } - } - } -} diff --git a/app/assets/javascripts/repository/queries/permissions.query.graphql b/app/assets/javascripts/repository/queries/permissions.query.graphql deleted file mode 100644 index 092fa44e2d0..00000000000 --- a/app/assets/javascripts/repository/queries/permissions.query.graphql +++ /dev/null @@ -1,9 +0,0 @@ -query getPermissions($projectPath: ID!) { - project(fullPath: $projectPath) { - userPermissions { - pushCode - forkProject - createMergeRequestIn - } - } -} diff --git a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue b/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue deleted file mode 100644 index b6e2dd46358..00000000000 --- a/app/assets/javascripts/search/dropdown_filter/components/dropdown_filter.vue +++ /dev/null @@ -1,100 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; -import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; -import { sprintf, s__ } from '~/locale'; - -export default { - name: 'DropdownFilter', - components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - }, - props: { - filterData: { - type: Object, - required: true, - }, - }, - computed: { - ...mapState(['query']), - scope() { - return this.query.scope; - }, - supportedScopes() { - return Object.values(this.filterData.scopes); - }, - initialFilter() { - return this.query[this.filterData.filterParam]; - }, - filter() { - return this.initialFilter || this.filterData.filters.ANY.value; - }, - filtersArray() { - return this.filterData.filterByScope[this.scope]; - }, - selectedFilter: { - get() { - if (this.filtersArray.some(({ value }) => value === this.filter)) { - return this.filter; - } - - return this.filterData.filters.ANY.value; - }, - set(filter) { - visitUrl(setUrlParams({ [this.filterData.filterParam]: filter })); - }, - }, - selectedFilterText() { - const f = this.filtersArray.find(({ value }) => value === this.selectedFilter); - if (!f || f === this.filterData.filters.ANY) { - return sprintf(s__('Any %{header}'), { header: this.filterData.header }); - } - - return f.label; - }, - showDropdown() { - return this.supportedScopes.includes(this.scope); - }, - }, - methods: { - dropDownItemClass(filter) { - return { - 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2': - filter === this.filterData.filters.ANY, - }; - }, - isFilterSelected(filter) { - return filter === this.selectedFilter; - }, - handleFilterChange(filter) { - this.selectedFilter = filter; - }, - }, -}; -</script> - -<template> - <gl-dropdown - v-if="showDropdown" - :text="selectedFilterText" - class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4" - menu-class="gl-w-full! gl-pl-0" - > - <header class="gl-text-center gl-font-weight-bold gl-font-lg"> - {{ filterData.header }} - </header> - <gl-dropdown-divider /> - <gl-dropdown-item - v-for="f in filtersArray" - :key="f.value" - :is-check-item="true" - :is-checked="isFilterSelected(f.value)" - :class="dropDownItemClass(f)" - @click="handleFilterChange(f.value)" - > - {{ f.label }} - </gl-dropdown-item> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/search/dropdown_filter/index.js b/app/assets/javascripts/search/dropdown_filter/index.js deleted file mode 100644 index e5e0745d990..00000000000 --- a/app/assets/javascripts/search/dropdown_filter/index.js +++ /dev/null @@ -1,38 +0,0 @@ -import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; -import DropdownFilter from './components/dropdown_filter.vue'; -import stateFilterData from './constants/state_filter_data'; -import confidentialFilterData from './constants/confidential_filter_data'; - -Vue.use(Translate); - -const mountDropdownFilter = (store, { id, filterData }) => { - const el = document.getElementById(id); - - if (!el) return false; - - return new Vue({ - el, - store, - render(createElement) { - return createElement(DropdownFilter, { - props: { - filterData, - }, - }); - }, - }); -}; - -const dropdownFilters = [ - { - id: 'js-search-filter-by-state', - filterData: stateFilterData, - }, - { - id: 'js-search-filter-by-confidential', - filterData: confidentialFilterData, - }, -]; - -export default store => [...dropdownFilters].map(filter => mountDropdownFilter(store, filter)); diff --git a/app/assets/javascripts/search/group_filter/components/group_filter.vue b/app/assets/javascripts/search/group_filter/components/group_filter.vue new file mode 100644 index 00000000000..4b7963c5187 --- /dev/null +++ b/app/assets/javascripts/search/group_filter/components/group_filter.vue @@ -0,0 +1,124 @@ +<script> +import { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + GlIcon, + GlSkeletonLoader, + GlTooltipDirective, +} from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { isEmpty } from 'lodash'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import { ANY_GROUP, GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM } from '../constants'; + +export default { + name: 'GroupFilter', + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlLoadingIcon, + GlIcon, + GlSkeletonLoader, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + initialGroup: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + groupSearch: '', + }; + }, + computed: { + ...mapState(['groups', 'fetchingGroups']), + selectedGroup: { + get() { + return isEmpty(this.initialGroup) ? ANY_GROUP : this.initialGroup; + }, + set(group) { + visitUrl(setUrlParams({ [GROUP_QUERY_PARAM]: group.id, [PROJECT_QUERY_PARAM]: null })); + }, + }, + }, + methods: { + ...mapActions(['fetchGroups']), + isGroupSelected(group) { + return group.id === this.selectedGroup.id; + }, + handleGroupChange(group) { + this.selectedGroup = group; + }, + }, + ANY_GROUP, +}; +</script> + +<template> + <gl-dropdown + ref="groupFilter" + class="gl-w-full" + menu-class="gl-w-full!" + toggle-class="gl-text-truncate gl-reset-line-height!" + :header-text="__('Filter results by group')" + @show="fetchGroups(groupSearch)" + > + <template #button-content> + <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> + {{ selectedGroup.name }} + </span> + <gl-loading-icon v-if="fetchingGroups" inline class="mr-2" /> + <gl-icon + v-if="!isGroupSelected($options.ANY_GROUP)" + v-gl-tooltip + name="clear" + :title="__('Clear')" + class="gl-text-gray-200! gl-hover-text-blue-800!" + @click.stop="handleGroupChange($options.ANY_GROUP)" + /> + <gl-icon name="chevron-down" /> + </template> + <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white"> + <gl-search-box-by-type + v-model="groupSearch" + class="m-2" + :debounce="500" + @input="fetchGroups" + /> + <gl-dropdown-item + class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2" + :is-check-item="true" + :is-checked="isGroupSelected($options.ANY_GROUP)" + @click="handleGroupChange($options.ANY_GROUP)" + > + {{ $options.ANY_GROUP.name }} + </gl-dropdown-item> + </div> + <div v-if="!fetchingGroups"> + <gl-dropdown-item + v-for="group in groups" + :key="group.id" + :is-check-item="true" + :is-checked="isGroupSelected(group)" + @click="handleGroupChange(group)" + > + {{ group.full_name }} + </gl-dropdown-item> + </div> + <div v-if="fetchingGroups" class="mx-3 mt-2"> + <gl-skeleton-loader :height="100"> + <rect y="0" width="90%" height="20" rx="4" /> + <rect y="40" width="70%" height="20" rx="4" /> + <rect y="80" width="80%" height="20" rx="4" /> + </gl-skeleton-loader> + </div> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/search/group_filter/constants.js b/app/assets/javascripts/search/group_filter/constants.js new file mode 100644 index 00000000000..9bd92eaa130 --- /dev/null +++ b/app/assets/javascripts/search/group_filter/constants.js @@ -0,0 +1,10 @@ +import { __ } from '~/locale'; + +export const ANY_GROUP = Object.freeze({ + id: null, + name: __('Any'), +}); + +export const GROUP_QUERY_PARAM = 'group_id'; + +export const PROJECT_QUERY_PARAM = 'project_id'; diff --git a/app/assets/javascripts/search/group_filter/index.js b/app/assets/javascripts/search/group_filter/index.js new file mode 100644 index 00000000000..9b009bc0305 --- /dev/null +++ b/app/assets/javascripts/search/group_filter/index.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import GroupFilter from './components/group_filter.vue'; + +Vue.use(Translate); + +export default store => { + let initialGroup; + const el = document.getElementById('js-search-group-dropdown'); + + const { initialGroupData } = el.dataset; + + initialGroup = JSON.parse(initialGroupData); + initialGroup = convertObjectPropsToCamelCase(initialGroup, { deep: true }); + + return new Vue({ + el, + store, + render(createElement) { + return createElement(GroupFilter, { + props: { + initialGroup, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/search/show/highlight_blob_search_result.js b/app/assets/javascripts/search/highlight_blob_search_result.js index e17c87735b4..e17c87735b4 100644 --- a/app/assets/javascripts/pages/search/show/highlight_blob_search_result.js +++ b/app/assets/javascripts/search/highlight_blob_search_result.js diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js index 780d3ff0d25..781a564d077 100644 --- a/app/assets/javascripts/search/index.js +++ b/app/assets/javascripts/search/index.js @@ -1,9 +1,14 @@ import { queryToObject } from '~/lib/utils/url_utility'; import createStore from './store'; -import initDropdownFilters from './dropdown_filter'; +import { initSidebar } from './sidebar'; +import initGroupFilter from './group_filter'; -export default () => { - const store = createStore({ query: queryToObject(window.location.search) }); +export const initSearchApp = () => { + // Similar to url_utility.decodeUrlParameter + // Our query treats + as %20. This replaces the query + symbols with %20. + const sanitizedSearch = window.location.search.replace(/\+/g, '%20'); + const store = createStore({ query: queryToObject(sanitizedSearch) }); - initDropdownFilters(store); + initSidebar(store); + initGroupFilter(store); }; diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue new file mode 100644 index 00000000000..aa11b2025f2 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -0,0 +1,41 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlButton, GlLink } from '@gitlab/ui'; +import StatusFilter from './status_filter.vue'; +import ConfidentialityFilter from './confidentiality_filter.vue'; + +export default { + name: 'GlobalSearchSidebar', + components: { + GlButton, + GlLink, + StatusFilter, + ConfidentialityFilter, + }, + computed: { + ...mapState(['query']), + showReset() { + return this.query.state || this.query.confidential; + }, + }, + methods: { + ...mapActions(['applyQuery', 'resetQuery']), + }, +}; +</script> + +<template> + <form + class="gl-display-flex gl-flex-direction-column col-md-3 gl-mr-4 gl-mb-6 gl-mt-5" + @submit.prevent="applyQuery" + > + <status-filter /> + <confidentiality-filter /> + <div class="gl-display-flex gl-align-items-center gl-mt-3"> + <gl-button variant="success" type="submit">{{ __('Apply') }}</gl-button> + <gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{ + __('Reset filters') + }}</gl-link> + </div> + </form> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue new file mode 100644 index 00000000000..38dccb9675d --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/confidentiality_filter.vue @@ -0,0 +1,26 @@ +<script> +import { mapState } from 'vuex'; +import { confidentialFilterData } from '../constants/confidential_filter_data'; +import RadioFilter from './radio_filter.vue'; + +export default { + name: 'ConfidentialityFilter', + components: { + RadioFilter, + }, + computed: { + ...mapState(['query']), + showDropdown() { + return Object.values(confidentialFilterData.scopes).includes(this.query.scope); + }, + }, + confidentialFilterData, +}; +</script> + +<template> + <div v-if="showDropdown"> + <radio-filter :filter-data="$options.confidentialFilterData" /> + <hr class="gl-my-5 gl-border-gray-100" /> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/radio_filter.vue b/app/assets/javascripts/search/sidebar/components/radio_filter.vue new file mode 100644 index 00000000000..b27c4e26fb5 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/radio_filter.vue @@ -0,0 +1,68 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; + +export default { + name: 'RadioFilter', + components: { + GlFormRadioGroup, + GlFormRadio, + }, + props: { + filterData: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['query']), + ANY() { + return this.filterData.filters.ANY; + }, + scope() { + return this.query.scope; + }, + initialFilter() { + return this.query[this.filterData.filterParam]; + }, + filter() { + return this.initialFilter || this.ANY.value; + }, + filtersArray() { + return this.filterData.filterByScope[this.scope]; + }, + selectedFilter: { + get() { + if (this.filtersArray.some(({ value }) => value === this.filter)) { + return this.filter; + } + + return this.ANY.value; + }, + set(value) { + this.setQuery({ key: this.filterData.filterParam, value }); + }, + }, + }, + methods: { + ...mapActions(['setQuery']), + radioLabel(filter) { + return filter.value === this.ANY.value + ? sprintf(s__('Any %{header}'), { header: this.filterData.header.toLowerCase() }) + : filter.label; + }, + }, +}; +</script> + +<template> + <div> + <h5 class="gl-mt-0">{{ filterData.header }}</h5> + <gl-form-radio-group v-model="selectedFilter"> + <gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value"> + {{ radioLabel(f) }} + </gl-form-radio> + </gl-form-radio-group> + </div> +</template> diff --git a/app/assets/javascripts/search/sidebar/components/status_filter.vue b/app/assets/javascripts/search/sidebar/components/status_filter.vue new file mode 100644 index 00000000000..5cec2090906 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/status_filter.vue @@ -0,0 +1,26 @@ +<script> +import { mapState } from 'vuex'; +import { stateFilterData } from '../constants/state_filter_data'; +import RadioFilter from './radio_filter.vue'; + +export default { + name: 'StatusFilter', + components: { + RadioFilter, + }, + computed: { + ...mapState(['query']), + showDropdown() { + return Object.values(stateFilterData.scopes).includes(this.query.scope); + }, + }, + stateFilterData, +}; +</script> + +<template> + <div v-if="showDropdown"> + <radio-filter :filter-data="$options.stateFilterData" /> + <hr class="gl-my-5 gl-border-gray-100" /> + </div> +</template> diff --git a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js b/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js index b29daca89cb..ecb63ed9eea 100644 --- a/app/assets/javascripts/search/dropdown_filter/constants/confidential_filter_data.js +++ b/app/assets/javascripts/search/sidebar/constants/confidential_filter_data.js @@ -27,7 +27,7 @@ const filterByScope = { const filterParam = 'confidential'; -export default { +export const confidentialFilterData = { header, filters, scopes, diff --git a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js index 0b93aa0be29..7c9a029ffe4 100644 --- a/app/assets/javascripts/search/dropdown_filter/constants/state_filter_data.js +++ b/app/assets/javascripts/search/sidebar/constants/state_filter_data.js @@ -33,7 +33,7 @@ const filterByScope = { const filterParam = 'state'; -export default { +export const stateFilterData = { header, filters, scopes, diff --git a/app/assets/javascripts/search/sidebar/index.js b/app/assets/javascripts/search/sidebar/index.js new file mode 100644 index 00000000000..6419e8ac2c6 --- /dev/null +++ b/app/assets/javascripts/search/sidebar/index.js @@ -0,0 +1,19 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import GlobalSearchSidebar from './components/app.vue'; + +Vue.use(Translate); + +export const initSidebar = store => { + const el = document.getElementById('js-search-sidebar'); + + if (!el) return false; + + return new Vue({ + el, + store, + render(createElement) { + return createElement(GlobalSearchSidebar); + }, + }); +}; diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js new file mode 100644 index 00000000000..447278aa223 --- /dev/null +++ b/app/assets/javascripts/search/store/actions.js @@ -0,0 +1,29 @@ +import Api from '~/api'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; +import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; +import * as types from './mutation_types'; + +export const fetchGroups = ({ commit }, search) => { + commit(types.REQUEST_GROUPS); + Api.groups(search) + .then(data => { + commit(types.RECEIVE_GROUPS_SUCCESS, data); + }) + .catch(() => { + createFlash({ message: __('There was a problem fetching groups.') }); + commit(types.RECEIVE_GROUPS_ERROR); + }); +}; + +export const setQuery = ({ commit }, { key, value }) => { + commit(types.SET_QUERY, { key, value }); +}; + +export const applyQuery = ({ state }) => { + visitUrl(setUrlParams({ ...state.query, page: null })); +}; + +export const resetQuery = ({ state }) => { + visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null })); +}; diff --git a/app/assets/javascripts/search/store/index.js b/app/assets/javascripts/search/store/index.js index 10cfb647a92..e0a7e488f9f 100644 --- a/app/assets/javascripts/search/store/index.js +++ b/app/assets/javascripts/search/store/index.js @@ -1,10 +1,14 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; import createState from './state'; Vue.use(Vuex); export const getStoreConfig = ({ query }) => ({ + actions, + mutations, state: createState({ query }), }); diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js new file mode 100644 index 00000000000..2482621d4d7 --- /dev/null +++ b/app/assets/javascripts/search/store/mutation_types.js @@ -0,0 +1,5 @@ +export const REQUEST_GROUPS = 'REQUEST_GROUPS'; +export const RECEIVE_GROUPS_SUCCESS = 'RECEIVE_GROUPS_SUCCESS'; +export const RECEIVE_GROUPS_ERROR = 'RECEIVE_GROUPS_ERROR'; + +export const SET_QUERY = 'SET_QUERY'; diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js new file mode 100644 index 00000000000..e57850b870e --- /dev/null +++ b/app/assets/javascripts/search/store/mutations.js @@ -0,0 +1,18 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_GROUPS](state) { + state.fetchingGroups = true; + }, + [types.RECEIVE_GROUPS_SUCCESS](state, data) { + state.fetchingGroups = false; + state.groups = data; + }, + [types.RECEIVE_GROUPS_ERROR](state) { + state.fetchingGroups = false; + state.groups = []; + }, + [types.SET_QUERY](state, { key, value }) { + state.query[key] = value; + }, +}; diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js index 9115a613767..70a8aab9998 100644 --- a/app/assets/javascripts/search/store/state.js +++ b/app/assets/javascripts/search/store/state.js @@ -1,4 +1,6 @@ const createState = ({ query }) => ({ query, + groups: [], + fetchingGroups: false, }); export default createState; diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue index 29a61cfbbfe..71f2e948917 100644 --- a/app/assets/javascripts/serverless/components/area.vue +++ b/app/assets/javascripts/serverless/components/area.vue @@ -138,8 +138,8 @@ export default { :width="width" :include-legend-avg-max="false" > - <template #tooltipTitle>{{ tooltipPopoverTitle }}</template> - <template #tooltipContent>{{ tooltipPopoverContent }}</template> + <template #tooltip-title>{{ tooltipPopoverTitle }}</template> + <template #tooltip-content>{{ tooltipPopoverContent }}</template> </gl-area-chart> </div> </template> diff --git a/app/assets/javascripts/set_status_modal/components/user_availability_status.vue b/app/assets/javascripts/set_status_modal/components/user_availability_status.vue new file mode 100644 index 00000000000..e86d94f86c6 --- /dev/null +++ b/app/assets/javascripts/set_status_modal/components/user_availability_status.vue @@ -0,0 +1,26 @@ +<script> +import { AVAILABILITY_STATUS, isUserBusy, isValidAvailibility } from '../utils'; + +export default { + name: 'UserAvailabilityStatus', + props: { + availability: { + type: String, + required: true, + validator: isValidAvailibility, + }, + }, + computed: { + isBusy() { + const { availability = AVAILABILITY_STATUS.NOT_SET } = this; + return isUserBusy(availability); + }, + }, +}; +</script> + +<template> + <span v-if="isBusy" class="gl-font-weight-normal gl-text-gray-500">{{ + s__('UserAvailability|(Busy)') + }}</span> +</template> diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index 09e893ff285..30e4e92d0cc 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -2,24 +2,35 @@ /* eslint-disable vue/no-v-html */ import $ from 'jquery'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; -import { GlModal, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __, s__ } from '~/locale'; import Api from '~/api'; import EmojiMenuInModal from './emoji_menu_in_modal'; +import { isUserBusy, isValidAvailibility } from './utils'; import * as Emoji from '~/emoji'; const emojiMenuClass = 'js-modal-status-emoji-menu'; +export const AVAILABILITY_STATUS = { + BUSY: 'busy', + NOT_SET: 'not_set', +}; export default { components: { GlIcon, GlModal, + GlFormCheckbox, }, directives: { GlTooltip: GlTooltipDirective, }, props: { + defaultEmoji: { + type: String, + required: false, + default: '', + }, currentEmoji: { type: String, required: true, @@ -28,6 +39,17 @@ export default { type: String, required: true, }, + currentAvailability: { + type: String, + required: false, + validator: isValidAvailibility, + default: '', + }, + canSetUserAvailability: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -39,11 +61,15 @@ export default { message: this.currentMessage, modalId: 'set-user-status-modal', noEmoji: true, + availability: isUserBusy(this.currentAvailability), }; }, computed: { + isCustomEmoji() { + return this.emoji !== this.defaultEmoji; + }, isDirty() { - return this.message.length || this.emoji.length; + return Boolean(this.message.length || this.isCustomEmoji); }, }, mounted() { @@ -67,7 +93,7 @@ export default { this.emojiTag = Emoji.glEmojiTag(this.emoji); } this.noEmoji = this.emoji === ''; - this.defaultEmojiTag = Emoji.glEmojiTag('speech_balloon'); + this.defaultEmojiTag = Emoji.glEmojiTag(this.defaultEmoji); this.emojiMenu = new EmojiMenuInModal( Emoji, @@ -76,6 +102,7 @@ export default { this.setEmoji, this.$refs.userStatusForm, ); + this.setDefaultEmoji(); }) .catch(() => createFlash(__('Failed to load emoji list.'))); }, @@ -94,7 +121,7 @@ export default { }, setDefaultEmoji() { const { emojiTag } = this; - const hasStatusMessage = this.message; + const hasStatusMessage = Boolean(this.message.length); if (hasStatusMessage && emojiTag) { return; } @@ -126,20 +153,26 @@ export default { this.hideEmojiMenu(); }, removeStatus() { + this.availability = false; this.clearStatusInputs(); this.setStatus(); }, setStatus() { - const { emoji, message } = this; + const { emoji, message, availability } = this; Api.postUserStatus({ emoji, message, + availability: availability ? AVAILABILITY_STATUS.BUSY : AVAILABILITY_STATUS.NOT_SET, }) .then(this.onUpdateSuccess) .catch(this.onUpdateFail); }, onUpdateSuccess() { + this.$toast.show(s__('SetStatusModal|Status updated'), { + type: 'success', + position: 'top-center', + }); this.closeModal(); window.location.reload(); }, @@ -175,11 +208,11 @@ export default { name="user[status][emoji]" /> <div ref="userStatusForm" class="form-group position-relative m-0"> - <div class="input-group"> + <div class="input-group gl-mb-5"> <span class="input-group-prepend"> <button ref="toggleEmojiMenuButton" - v-gl-tooltip.bottom + v-gl-tooltip.bottom.hover :title="s__('SetStatusModal|Add status emoji')" :aria-label="s__('SetStatusModal|Add status emoji')" name="button" @@ -223,6 +256,22 @@ export default { </button> </span> </div> + <div v-if="canSetUserAvailability" class="form-group"> + <div class="gl-display-flex"> + <gl-form-checkbox + v-model="availability" + data-testid="user-availability-checkbox" + class="gl-mb-0" + > + <span class="gl-font-weight-bold">{{ s__('SetStatusModal|Busy') }}</span> + </gl-form-checkbox> + </div> + <div class="gl-display-flex"> + <span class="gl-text-gray-600 gl-ml-5"> + {{ s__('SetStatusModal|"Busy" will be shown next to your name') }} + </span> + </div> + </div> </div> </div> </gl-modal> diff --git a/app/assets/javascripts/set_status_modal/utils.js b/app/assets/javascripts/set_status_modal/utils.js new file mode 100644 index 00000000000..dccb66be11f --- /dev/null +++ b/app/assets/javascripts/set_status_modal/utils.js @@ -0,0 +1,9 @@ +export const AVAILABILITY_STATUS = { + BUSY: 'busy', + NOT_SET: 'not_set', +}; + +export const isUserBusy = status => status === AVAILABILITY_STATUS.BUSY; + +export const isValidAvailibility = availability => + availability.length ? Object.values(AVAILABILITY_STATUS).includes(availability) : true; diff --git a/app/assets/javascripts/shared/milestones/form.js b/app/assets/javascripts/shared/milestones/form.js index 0ff84dc4667..9ee02f923d5 100644 --- a/app/assets/javascripts/shared/milestones/form.js +++ b/app/assets/javascripts/shared/milestones/form.js @@ -16,5 +16,6 @@ export default (initGFM = true) => { milestones: initGFM, labels: initGFM, snippets: initGFM, + vulnerabilities: initGFM, }); }; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index 052bb3dcb53..00f1339d7f2 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -22,7 +22,9 @@ export default { return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); }, avatarUrl() { - return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; + return ( + this.user.avatarUrl || this.user.avatar || this.user.avatar_url || gon.default_avatar_url + ); }, isMergeRequest() { return this.issuableType === 'merge_request'; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index 878b331fb3c..fbbe2e341a7 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -16,10 +16,6 @@ export default { type: Object, required: true, }, - rootPath: { - type: String, - required: true, - }, tooltipPlacement: { type: String, default: 'bottom', @@ -76,7 +72,7 @@ export default { <!-- use d-flex so that slot can be appropriately styled --> <span class="d-flex"> <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> - <slot :user="user"></slot> + <slot></slot> </span> </gl-link> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 20dc7cb07e7..5f8ba844218 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -29,7 +29,8 @@ export default { }, changing: { type: Boolean, - required: true, + required: false, + default: false, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index 4697d85472b..cf6a0a4a151 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -26,7 +26,6 @@ export default { <template> <div class="gl-display-flex gl-flex-direction-column"> - <label data-testid="assigneeLabel">{{ assigneesText }}</label> <div v-if="emptyUsers" data-testid="none"> <span> {{ __('None') }} diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index 95934c0ef2a..31d5d7c0077 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -13,10 +13,6 @@ export default { type: Array, required: true, }, - rootPath: { - type: String, - required: true, - }, issuableType: { type: String, required: false, @@ -66,22 +62,20 @@ export default { <template> <assignee-avatar-link v-if="hasOneUser" - #default="{ user }" tooltip-placement="left" :tooltip-has-name="false" :user="firstUser" - :root-path="rootPath" :issuable-type="issuableType" > <div class="ml-2 gl-line-height-normal"> - <div>{{ user.name }}</div> + <div>{{ firstUser.name }}</div> <div>{{ username }}</div> </div> </assignee-avatar-link> <div v-else> <div class="user-list"> <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item"> - <assignee-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" /> + <assignee-avatar-link :user="user" :issuable-type="issuableType" /> </div> </div> <div v-if="renderShowMoreSection" class="user-list-more"> diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue index 1af1bc18e3e..1785174e8d7 100644 --- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue +++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue @@ -1,11 +1,26 @@ <script> import $ from 'jquery'; -import { difference, union } from 'lodash'; -import flash from '~/flash'; -import axios from '~/lib/utils/axios_utils'; +import { camelCase, difference, union } from 'lodash'; +import updateIssueLabelsMutation from '~/boards/queries/issue_set_labels.mutation.graphql'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; import { __ } from '~/locale'; +import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; +import { toLabelGid } from '~/sidebar/utils'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import { getIdFromGraphQLId, MutationOperationMode } from '~/graphql_shared/utils'; + +const mutationMap = { + [IssuableType.Issue]: { + mutation: updateIssueLabelsMutation, + mutationName: 'updateIssue', + }, + [IssuableType.MergeRequest]: { + mutation: updateMergeRequestLabelsMutation, + mutationName: 'mergeRequestSetLabels', + }, +}; export default { components: { @@ -21,7 +36,6 @@ export default { 'issuableType', 'labelsFetchPath', 'labelsManagePath', - 'labelsUpdatePath', 'projectIssuesPath', 'projectPath', ], @@ -35,37 +49,79 @@ export default { handleDropdownClose() { $(this.$el).trigger('hidden.gl.dropdown'); }, - handleUpdateSelectedLabels(dropdownLabels) { + getUpdateVariables(dropdownLabels) { const currentLabelIds = this.selectedLabels.map(label => label.id); const userAddedLabelIds = dropdownLabels.filter(label => label.set).map(label => label.id); const userRemovedLabelIds = dropdownLabels.filter(label => !label.set).map(label => label.id); const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); - this.updateSelectedLabels(labelIds); + switch (this.issuableType) { + case IssuableType.Issue: + return { + addLabelIds: userAddedLabelIds, + iid: this.iid, + projectPath: this.projectPath, + removeLabelIds: userRemovedLabelIds, + }; + case IssuableType.MergeRequest: + return { + iid: this.iid, + labelIds: labelIds.map(toLabelGid), + operationMode: MutationOperationMode.Replace, + projectPath: this.projectPath, + }; + default: + return {}; + } + }, + handleUpdateSelectedLabels(dropdownLabels) { + this.updateSelectedLabels(this.getUpdateVariables(dropdownLabels)); + }, + getRemoveVariables(labelId) { + switch (this.issuableType) { + case IssuableType.Issue: + return { + iid: this.iid, + projectPath: this.projectPath, + removeLabelIds: [labelId], + }; + case IssuableType.MergeRequest: + return { + iid: this.iid, + labelIds: [toLabelGid(labelId)], + operationMode: MutationOperationMode.Remove, + projectPath: this.projectPath, + }; + default: + return {}; + } }, handleLabelRemove(labelId) { - const currentLabelIds = this.selectedLabels.map(label => label.id); - const labelIds = difference(currentLabelIds, [labelId]); - - this.updateSelectedLabels(labelIds); + this.updateSelectedLabels(this.getRemoveVariables(labelId)); }, - updateSelectedLabels(labelIds) { + updateSelectedLabels(inputVariables) { this.isLabelsSelectInProgress = true; - axios({ - data: { - [this.issuableType]: { - label_ids: labelIds, - }, - }, - method: 'put', - url: this.labelsUpdatePath, - }) + this.$apollo + .mutate({ + mutation: mutationMap[this.issuableType].mutation, + variables: { input: inputVariables }, + }) .then(({ data }) => { - this.selectedLabels = data.labels; + const { mutationName } = mutationMap[this.issuableType]; + + if (data[mutationName]?.errors?.length) { + throw new Error(); + } + + const issuableType = camelCase(this.issuableType); + this.selectedLabels = data[mutationName]?.[issuableType]?.labels?.nodes?.map(label => ({ + ...label, + id: getIdFromGraphQLId(label.id), + })); }) - .catch(() => flash(__('An error occurred while updating labels.'))) + .catch(() => createFlash({ message: __('An error occurred while updating labels.') })) .finally(() => { this.isLabelsSelectInProgress = false; }); diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index 0457aad8795..6e004084077 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -1,9 +1,8 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import Tracking from '~/tracking'; import toggleButton from '~/vue_shared/components/toggle_button.vue'; -import tooltip from '~/vue_shared/directives/tooltip'; import eventHub from '../../event_hub'; const ICON_ON = 'notifications'; @@ -13,7 +12,7 @@ const LABEL_OFF = __('Notifications off'); export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -110,12 +109,9 @@ export default { <div> <span ref="tooltip" - v-tooltip - class="sidebar-collapsed-icon" + v-gl-tooltip.viewport.left :title="notificationTooltip" - data-container="body" - data-placement="left" - data-boundary="viewport" + class="sidebar-collapsed-icon" @click="onClickCollapsedIcon" > <gl-icon diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue index 9d72bf4394e..7b67c34ded6 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue @@ -96,7 +96,12 @@ export default { </script> <template> - <div v-gl-tooltip:body.viewport.left :title="tooltipText" class="sidebar-collapsed-icon"> + <div + v-gl-tooltip:body.viewport.left + :title="tooltipText" + data-testid="collapsedState" + class="sidebar-collapsed-icon" + > <gl-icon name="timer" /> <div class="time-tracking-collapsed-summary"> <div :class="divClass"> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue index d4cc98e3743..99302993b9a 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue @@ -70,14 +70,19 @@ export default { </script> <template> - <div class="time-tracking-comparison-pane"> + <div data-testid="timeTrackingComparisonPane"> <div v-gl-tooltip + data-testid="compareMeter" :title="timeRemainingTooltip" :class="timeRemainingStatusClass" class="compare-meter" > - <gl-progress-bar :value="timeRemainingPercent" :variant="progressBarVariant" /> + <gl-progress-bar + data-testid="timeRemainingProgress" + :value="timeRemainingPercent" + :variant="progressBarVariant" + /> <div class="compare-display-container"> <div class="compare-display float-left"> <span class="compare-label">{{ s__('TimeTracking|Spent') }}</span> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue index 305726d9725..8a80b1bf13f 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.vue @@ -11,7 +11,8 @@ export default { </script> <template> - <div class="time-tracking-estimate-only-pane"> - <span class="bold"> {{ s__('TimeTracking|Estimated:') }} </span> {{ timeEstimateHumanReadable }} + <div data-testid="estimateOnlyPane"> + <span class="gl-font-weight-bold">{{ s__('TimeTracking|Estimated:') }} </span + >{{ timeEstimateHumanReadable }} </div> </template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue index b45746e789d..8bc828091c0 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -34,7 +34,7 @@ export default { </script> <template> - <div class="time-tracking-help-state"> + <div data-testid="helpPane" class="time-tracking-help-state"> <div class="time-tracking-info"> <h4>{{ __('Track time with quick actions') }}</h4> <p>{{ __('Quick actions can be used in the issues description and comment boxes.') }}</p> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue index 45552589e50..2d3d0ce8dc5 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.vue @@ -5,7 +5,7 @@ export default { </script> <template> - <div class="time-tracking-no-tracking-pane"> - <span class="no-value"> {{ __('No estimate or time spent') }} </span> + <div data-testid="noTrackingPane"> + <span class="no-value">{{ __('No estimate or time spent') }}</span> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 406677941b7..6bef5ed67a4 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -57,7 +57,6 @@ export default { :human-time-estimate="store.humanTimeEstimate" :human-time-spent="store.humanTotalTimeSpent" :limit-to-hours="store.timeTrackingLimitToHours" - :root-path="store.rootPath" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue index b2b3b289c5c..33c6ac6e2ba 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue @@ -15,7 +15,7 @@ export default { return sprintf( s__('TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}'), { - startTag: '<span class="bold">', + startTag: '<span class="gl-font-weight-bold">', endTag: '</span>', timeSpentHumanReadable: this.timeSpentHumanReadable, }, @@ -27,5 +27,5 @@ export default { </script> <template> - <div class="time-tracking-spend-only-pane" v-html="timeSpent"></div> + <div data-testid="spentOnlyPane" v-html="timeSpent"></div> </template> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index a2fb0ebcbc6..3199ed1e615 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -44,6 +44,21 @@ export default { default: false, required: false, }, + /* + In issue list, "time-tracking-collapsed-state" is always rendered even if the sidebar isn't collapsed. + The actual hiding is controlled with css classes: + Hide "time-tracking-collapsed-state" + if .right-sidebar .right-sidebar-collapsed .sidebar-collapsed-icon + Show "time-tracking-collapsed-state" + if .right-sidebar .right-sidebar-expanded .sidebar-collapsed-icon + + In Swimlanes sidebar, we do not use collapsed state at all. + */ + showCollapsed: { + type: Boolean, + default: true, + required: false, + }, }, data() { return { @@ -93,8 +108,9 @@ export default { </script> <template> - <div v-cloak class="time_tracker time-tracking-component-wrap"> + <div v-cloak class="time-tracker time-tracking-component-wrap" data-testid="time-tracker"> <time-tracking-collapsed-state + v-if="showCollapsed" :show-comparison-state="showComparisonState" :show-no-time-tracking-state="showNoTimeTrackingState" :show-help-state="showHelpState" @@ -103,13 +119,19 @@ export default { :time-spent-human-readable="humanTimeSpent" :time-estimate-human-readable="humanTimeEstimate" /> - <div class="title hide-collapsed"> + <div class="title hide-collapsed gl-mb-3"> {{ __('Time tracking') }} - <div v-if="!showHelpState" class="help-button float-right" @click="toggleHelpState(true)"> + <div + v-if="!showHelpState" + data-testid="helpButton" + class="help-button float-right" + @click="toggleHelpState(true)" + > <gl-icon name="question-o" /> </div> <div - v-if="showHelpState" + v-else + data-testid="closeHelpButton" class="close-help-button float-right" @click="toggleHelpState(false)" > diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 00b4e2de5e5..984cd8a3b1d 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -91,8 +91,13 @@ export function mountSidebarLabels() { return false; } + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + return new Vue({ el, + apolloProvider, provide: { ...el.dataset, allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate), diff --git a/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql new file mode 100644 index 00000000000..3c09daad793 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_merge_request_labels.mutation.graphql @@ -0,0 +1,15 @@ +mutation mergeRequestSetLabels($input: MergeRequestSetLabelsInput!) { + mergeRequestSetLabels(input: $input) { + errors + mergeRequest { + labels { + nodes { + color + description + id + title + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/utils.js b/app/assets/javascripts/sidebar/utils.js new file mode 100644 index 00000000000..23730508b56 --- /dev/null +++ b/app/assets/javascripts/sidebar/utils.js @@ -0,0 +1 @@ +export const toLabelGid = id => `gid://gitlab/Label/${id}`; diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 5fa6cef7195..3492f19c996 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -7,11 +7,14 @@ import { deprecatedCreateFlash as createFlash } from './flash'; import FilesCommentButton from './files_comment_button'; import initImageDiffHelper from './image_diff/helpers/init_image_diff'; import syntaxHighlight from './syntax_highlight'; +import { spriteIcon } from '~/lib/utils/common_utils'; const WRAPPER = '<div class="diff-content"></div>'; const LOADING_HTML = '<span class="spinner"></span>'; -const ERROR_HTML = - '<div class="nothing-here-block"><i class="fa fa-warning"></i> Could not load diff</div>'; +const ERROR_HTML = `<div class="nothing-here-block">${spriteIcon( + 'warning-solid', + 's16', +)} Could not load diff</div>`; const COLLAPSED_HTML = '<div class="nothing-here-block diff-collapsed">This diff is collapsed. <button class="click-to-expand btn btn-link">Click to expand it.</button></div>'; diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index dd77d49803f..08683f25651 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -9,19 +9,14 @@ import FormFooterActions from '~/vue_shared/components/form/form_footer_actions. import { SNIPPET_MARK_EDIT_APP_START, SNIPPET_MEASURE_BLOBS_CONTENT, -} from '~/performance_constants'; +} from '~/performance/constants'; import eventHub from '~/blob/components/eventhub'; -import { performanceMarkAndMeasure } from '~/performance_utils'; +import { performanceMarkAndMeasure } from '~/performance/utils'; import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql'; import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql'; import { getSnippetMixin } from '../mixins/snippets'; -import { - SNIPPET_CREATE_MUTATION_ERROR, - SNIPPET_UPDATE_MUTATION_ERROR, - SNIPPET_VISIBILITY_PRIVATE, -} from '../constants'; -import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql'; +import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants'; import { markBlobPerformance } from '../utils/blob'; import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue'; @@ -41,15 +36,7 @@ export default { GlLoadingIcon, }, mixins: [getSnippetMixin], - apollo: { - defaultVisibility: { - query: defaultVisibilityQuery, - manual: true, - result({ data: { selectedLevel } }) { - this.selectedLevelDefault = selectedLevel; - }, - }, - }, + inject: ['selectedLevel'], props: { markdownPreviewPath: { type: String, @@ -73,9 +60,12 @@ export default { data() { return { isUpdating: false, - newSnippet: false, actions: [], - selectedLevelDefault: SNIPPET_VISIBILITY_PRIVATE, + snippet: { + title: '', + description: '', + visibilityLevel: this.selectedLevel, + }, }; }, computed: { @@ -112,13 +102,6 @@ export default { } return this.snippet.webUrl; }, - newSnippetSchema() { - return { - title: '', - description: '', - visibilityLevel: this.selectedLevelDefault, - }; - }, }, beforeCreate() { performanceMarkAndMeasure({ mark: SNIPPET_MARK_EDIT_APP_START }); @@ -145,20 +128,6 @@ export default { Flash(sprintf(defaultErrorMsg, { err })); this.isUpdating = false; }, - onNewSnippetFetched() { - this.newSnippet = true; - this.snippet = this.newSnippetSchema; - }, - onExistingSnippetFetched() { - this.newSnippet = false; - }, - onSnippetFetch(snippetRes) { - if (snippetRes.data.snippets.nodes.length === 0) { - this.onNewSnippetFetched(); - } else { - this.onExistingSnippetFetched(); - } - }, getAttachedFiles() { const fileInputs = Array.from(this.$el.querySelectorAll('[name="files[]"]')); return fileInputs.map(node => node.value); @@ -209,7 +178,7 @@ export default { </script> <template> <form - class="snippet-form js-requires-input js-quick-submit common-note-form" + class="snippet-form js-quick-submit common-note-form" :data-snippet-type="isProjectSnippet ? 'project' : 'personal'" data-testid="snippet-edit-form" @submit.prevent="handleFormSubmit" diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index 4a2f060ff7c..a3e5535c5fa 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -9,8 +9,8 @@ import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; import { SNIPPET_MARK_VIEW_APP_START, SNIPPET_MEASURE_BLOBS_CONTENT, -} from '~/performance_constants'; -import { performanceMarkAndMeasure } from '~/performance_utils'; +} from '~/performance/constants'; +import { performanceMarkAndMeasure } from '~/performance/utils'; import eventHub from '~/blob/components/eventhub'; import { getSnippetMixin } from '../mixins/snippets'; diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index e88126ea56a..b965c15306d 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -1,9 +1,9 @@ <script> +import GetBlobContent from 'shared_queries/snippet/snippet_blob_content.query.graphql'; + import BlobHeader from '~/blob/components/blob_header.vue'; import BlobContent from '~/blob/components/blob_content.vue'; -import GetBlobContent from '../queries/snippet.blob.content.query.graphql'; - import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER, @@ -21,7 +21,7 @@ export default { query: GetBlobContent, variables() { return { - ids: this.snippet.id, + ids: [this.snippet.id], rich: this.activeViewerType === RICH_BLOB_VIEWER, paths: [this.blob.path], }; @@ -51,6 +51,13 @@ export default { required: true, }, }, + provide() { + return { + blobHash: Math.random() + .toString() + .split('.')[1], + }; + }, data() { return { blobContent: '', diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 30de5a9d0e0..32c4c1039f5 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -6,17 +6,17 @@ import { GlModal, GlAlert, GlLoadingIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, GlButton, GlTooltipDirective, } from '@gitlab/ui'; +import CanCreatePersonalSnippet from 'shared_queries/snippet/user_permissions.query.graphql'; +import CanCreateProjectSnippet from 'shared_queries/snippet/project_permissions.query.graphql'; import { __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; -import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql'; -import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql'; import { joinPaths } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; @@ -28,8 +28,8 @@ export default { GlModal, GlAlert, GlLoadingIcon, - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, TimeAgoTooltip, GlButton, }, @@ -120,7 +120,7 @@ export default { ? __('The snippet is visible only to project members.') : __('The snippet is visible only to me.'); case 'internal': - return __('The snippet is visible to any logged in user.'); + return __('The snippet is visible to any logged in user except external users.'); default: return __('The snippet can be accessed without any authentication.'); } @@ -231,17 +231,17 @@ export default { </template> </div> <div class="d-block d-sm-none dropdown"> - <gl-deprecated-dropdown :text="__('Options')" class="w-100" toggle-class="text-center"> - <gl-deprecated-dropdown-item + <gl-dropdown :text="__('Options')" block> + <gl-dropdown-item v-for="(action, index) in personalSnippetActions" :key="index" :disabled="action.disabled" :title="action.title" :href="action.href" @click="action.click ? action.click() : undefined" - >{{ action.text }}</gl-deprecated-dropdown-item + >{{ action.text }}</gl-dropdown-item > - </gl-deprecated-dropdown> + </gl-dropdown> </div> </div> diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue index 25ad7c214b2..ee5076835ab 100644 --- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue @@ -1,6 +1,5 @@ <script> import { GlIcon, GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui'; -import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql'; import { defaultSnippetVisibilityLevels } from '../utils/blob'; import { SNIPPET_LEVELS_RESTRICTED, SNIPPET_LEVELS_DISABLED } from '~/snippets/constants'; @@ -12,16 +11,7 @@ export default { GlFormRadioGroup, GlLink, }, - apollo: { - defaultVisibility: { - query: defaultVisibilityQuery, - manual: true, - result({ data: { visibilityLevels, multipleLevelsRestricted } }) { - this.visibilityLevels = defaultSnippetVisibilityLevels(visibilityLevels); - this.multipleLevelsRestricted = multipleLevelsRestricted; - }, - }, - }, + inject: ['visibilityLevels', 'multipleLevelsRestricted'], props: { helpLink: { type: String, @@ -38,11 +28,10 @@ export default { required: true, }, }, - data() { - return { - visibilityLevels: [], - multipleLevelsRestricted: false, - }; + computed: { + defaultVisibilityLevels() { + return defaultSnippetVisibilityLevels(this.visibilityLevels); + }, }, SNIPPET_LEVELS_DISABLED, SNIPPET_LEVELS_RESTRICTED, @@ -59,7 +48,7 @@ export default { <gl-form-group id="visibility-level-setting" class="gl-mb-0"> <gl-form-radio-group :checked="value" stacked v-bind="$attrs" v-on="$listeners"> <gl-form-radio - v-for="option in visibilityLevels" + v-for="option in defaultVisibilityLevels" :key="option.value" :value="option.value" class="mb-3" @@ -78,7 +67,9 @@ export default { </gl-form-group> <div class="text-muted" data-testid="restricted-levels-info"> - <template v-if="!visibilityLevels.length">{{ $options.SNIPPET_LEVELS_DISABLED }}</template> + <template v-if="!defaultVisibilityLevels.length">{{ + $options.SNIPPET_LEVELS_DISABLED + }}</template> <template v-else-if="multipleLevelsRestricted">{{ $options.SNIPPET_LEVELS_RESTRICTED }}</template> diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js index e75922df15f..2a9ecbc27dc 100644 --- a/app/assets/javascripts/snippets/constants.js +++ b/app/assets/javascripts/snippets/constants.js @@ -14,7 +14,7 @@ export const SNIPPET_VISIBILITY = { [SNIPPET_VISIBILITY_INTERNAL]: { label: __('Internal'), icon: 'shield', - description: __('The snippet is visible to any logged in user.'), + description: __('The snippet is visible to any logged in user except external users.'), }, [SNIPPET_VISIBILITY_PUBLIC]: { label: __('Public'), diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index b55e1baf41e..853ccb0c2fe 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -24,17 +24,14 @@ export default function appFactory(el, Component) { ...restDataset } = el.dataset; - apolloProvider.clients.defaultClient.cache.writeData({ - data: { + return new Vue({ + el, + apolloProvider, + provide: { visibilityLevels: JSON.parse(visibilityLevels), selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE, multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset, }, - }); - - return new Vue({ - el, - apolloProvider, render(createElement) { return createElement(Component, { props: { diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js index d5e69e2a889..5844a55e4f5 100644 --- a/app/assets/javascripts/snippets/mixins/snippets.js +++ b/app/assets/javascripts/snippets/mixins/snippets.js @@ -1,4 +1,4 @@ -import GetSnippetQuery from '../queries/snippet.query.graphql'; +import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql'; const blobsDefault = []; @@ -8,7 +8,7 @@ export const getSnippetMixin = { query: GetSnippetQuery, variables() { return { - ids: this.snippetGid, + ids: [this.snippetGid], }; }, update: data => { @@ -21,9 +21,9 @@ export const getSnippetMixin = { }, result(res) { this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault; - if (this.onSnippetFetch) { - this.onSnippetFetch(res); - } + }, + skip() { + return this.newSnippet; }, }, }, @@ -36,7 +36,7 @@ export const getSnippetMixin = { data() { return { snippet: {}, - newSnippet: false, + newSnippet: !this.snippetGid, blobs: blobsDefault, }; }, diff --git a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql b/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql deleted file mode 100644 index 03c81460fb5..00000000000 --- a/app/assets/javascripts/snippets/queries/projectPermissions.query.graphql +++ /dev/null @@ -1,7 +0,0 @@ -query CanCreateProjectSnippet($fullPath: ID!) { - project(fullPath: $fullPath) { - userPermissions { - createSnippet - } - } -} diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql deleted file mode 100644 index 0e04ee9b7b8..00000000000 --- a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql +++ /dev/null @@ -1,14 +0,0 @@ -query SnippetBlobContent($ids: [ID!], $rich: Boolean!, $paths: [String!]) { - snippets(ids: $ids) { - nodes { - id - blobs(paths: $paths) { - nodes { - path - richData @include(if: $rich) - plainData @skip(if: $rich) - } - } - } - } -} diff --git a/app/assets/javascripts/snippets/queries/snippet.query.graphql b/app/assets/javascripts/snippets/queries/snippet.query.graphql deleted file mode 100644 index 2f385050d89..00000000000 --- a/app/assets/javascripts/snippets/queries/snippet.query.graphql +++ /dev/null @@ -1,15 +0,0 @@ -#import '../fragments/snippetBase.fragment.graphql' -#import '../fragments/project.fragment.graphql' -#import "~/graphql_shared/fragments/author.fragment.graphql" - -query GetSnippetQuery($ids: [ID!]) { - snippets(ids: $ids) { - nodes { - ...SnippetBase - ...SnippetProject - author { - ...Author - } - } - } -} diff --git a/app/assets/javascripts/snippets/queries/snippet_visibility.query.graphql b/app/assets/javascripts/snippets/queries/snippet_visibility.query.graphql deleted file mode 100644 index 5bd6c131bab..00000000000 --- a/app/assets/javascripts/snippets/queries/snippet_visibility.query.graphql +++ /dev/null @@ -1,5 +0,0 @@ -query defaultSnippetVisibility { - visibilityLevels @client - selectedLevel @client - multipleLevelsRestricted @client -} diff --git a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql b/app/assets/javascripts/snippets/queries/userPermissions.query.graphql deleted file mode 100644 index c3e5519e266..00000000000 --- a/app/assets/javascripts/snippets/queries/userPermissions.query.graphql +++ /dev/null @@ -1,7 +0,0 @@ -query CanCreatePersonalSnippet { - currentUser { - userPermissions { - createSnippet - } - } -} diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js index c47559b82b8..5081c648e36 100644 --- a/app/assets/javascripts/snippets/utils/blob.js +++ b/app/assets/javascripts/snippets/utils/blob.js @@ -7,8 +7,8 @@ import { SNIPPET_LEVELS_MAP, SNIPPET_VISIBILITY, } from '../constants'; -import { performanceMarkAndMeasure } from '~/performance_utils'; -import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants'; +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { SNIPPET_MARK_BLOBS_CONTENT, SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants'; const createLocalId = () => uniqueId('blob_local_'); diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index e602f26acdf..69eabfe5339 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -6,10 +6,10 @@ import EditDrawer from './edit_drawer.vue'; import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue'; import parseSourceFile from '~/static_site_editor/services/parse_source_file'; import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; -import { DEFAULT_IMAGE_UPLOAD_PATH } from '../constants'; import imageRepository from '../image_repository'; import formatter from '../services/formatter'; import templater from '../services/templater'; +import renderImage from '../services/renderers/render_image'; export default { components: { @@ -37,21 +37,35 @@ export default { required: false, default: '', }, + branch: { + type: String, + required: true, + }, + baseUrl: { + type: String, + required: true, + }, + mounts: { + type: Array, + required: true, + }, + project: { + type: String, + required: true, + }, imageRoot: { type: String, - required: false, - default: DEFAULT_IMAGE_UPLOAD_PATH, - validator: prop => prop.endsWith('/'), + required: true, }, }, data() { return { - saveable: false, parsedSource: parseSourceFile(this.preProcess(true, this.content)), editorMode: EDITOR_TYPES.wysiwyg, - isModified: false, hasMatter: false, isDrawerOpen: false, + isModified: false, + isSaveable: false, }; }, imageRepository: imageRepository(), @@ -68,6 +82,18 @@ export default { isWysiwygMode() { return this.editorMode === EDITOR_TYPES.wysiwyg; }, + customRenderers() { + const imageRenderer = renderImage.build( + this.mounts, + this.project, + this.branch, + this.baseUrl, + this.$options.imageRepository, + ); + return { + image: [imageRenderer], + }; + }, }, created() { this.refreshEditHelpers(); @@ -81,8 +107,11 @@ export default { return templatedContent; }, refreshEditHelpers() { - this.isModified = this.parsedSource.isModified(); - this.hasMatter = this.parsedSource.hasMatter(); + const { isModified, hasMatter, isMatterValid } = this.parsedSource; + this.isModified = isModified(); + this.hasMatter = hasMatter(); + const hasValidMatter = this.hasMatter ? isMatterValid() : true; + this.isSaveable = this.isModified && hasValidMatter; }, onDrawerOpen() { this.isDrawerOpen = true; @@ -133,17 +162,18 @@ export default { :content="editableContent" :initial-edit-type="editorMode" :image-root="imageRoot" + :options="{ customRenderers }" class="mb-9 pb-6 h-100" @modeChange="onModeChange" @input="onInputChange" @uploadImage="onUploadImage" /> - <unsaved-changes-confirm-dialog :modified="isModified" /> + <unsaved-changes-confirm-dialog :modified="isSaveable" /> <publish-toolbar class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full" :has-settings="hasSettings" :return-url="returnUrl" - :saveable="isModified" + :saveable="isSaveable" :saving-changes="savingChanges" @editSettings="onDrawerOpen" @submit="onSubmit" diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue index 9f75c65a316..c6247632b6e 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_controls.vue @@ -1,9 +1,21 @@ <script> -import { GlForm, GlFormGroup, GlFormInput, GlFormTextarea } from '@gitlab/ui'; -import AccessorUtilities from '~/lib/utils/accessor'; +import { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, +} from '@gitlab/ui'; + +import { __ } from '~/locale'; export default { components: { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, GlForm, GlFormGroup, GlFormInput, @@ -18,56 +30,47 @@ export default { type: String, required: true, }, - }, - data() { - return { - editable: { - title: this.title, - description: this.description, - }, - }; + templates: { + type: Array, + required: false, + default: null, + }, + currentTemplate: { + type: Object, + required: false, + default: null, + }, }, computed: { - editableStorageKey() { - return this.getId('local-storage', 'editable'); + dropdownLabel() { + return this.currentTemplate ? this.currentTemplate.name : __('None'); }, - hasLocalStorage() { - return AccessorUtilities.isLocalStorageAccessSafe(); + hasTemplates() { + return this.templates?.length > 0; }, }, mounted() { - this.initCachedEditable(); this.preSelect(); }, methods: { getId(type, key) { return `sse-merge-request-meta-${type}-${key}`; }, - initCachedEditable() { - if (this.hasLocalStorage) { - const cachedEditable = JSON.parse(localStorage.getItem(this.editableStorageKey)); - if (cachedEditable) { - this.editable = cachedEditable; - } - } - }, preSelect() { this.$nextTick(() => { this.$refs.title.$el.select(); }); }, - resetCachedEditable() { - if (this.hasLocalStorage) { - window.localStorage.removeItem(this.editableStorageKey); - } + onChangeTemplate(template) { + this.$emit('changeTemplate', template || null); }, - onUpdate() { - const payload = { ...this.editable }; + onUpdate(field, value) { + const payload = { + title: this.title, + description: this.description, + [field]: value, + }; this.$emit('updateSettings', payload); - - if (this.hasLocalStorage) { - window.localStorage.setItem(this.editableStorageKey, JSON.stringify(payload)); - } }, }, }; @@ -83,21 +86,44 @@ export default { <gl-form-input :id="getId('control', 'title')" ref="title" - v-model.lazy="editable.title" + :value="title" type="text" - @input="onUpdate" + @input="onUpdate('title', $event)" /> </gl-form-group> <gl-form-group + v-if="hasTemplates" + key="template" + :label="__('Description template')" + :label-for="getId('control', 'template')" + > + <gl-dropdown :text="dropdownLabel"> + <gl-dropdown-item key="none" @click="onChangeTemplate(null)"> + {{ __('None') }} + </gl-dropdown-item> + + <gl-dropdown-divider /> + + <gl-dropdown-item + v-for="template in templates" + :key="template.key" + @click="onChangeTemplate(template)" + > + {{ template.name }} + </gl-dropdown-item> + </gl-dropdown> + </gl-form-group> + + <gl-form-group key="description" :label="__('Goal of the changes and what reviewers should be aware of')" :label-for="getId('control', 'description')" > <gl-form-textarea :id="getId('control', 'description')" - v-model.lazy="editable.description" - @input="onUpdate" + :value="description" + @input="onUpdate('description', $event)" /> </gl-form-group> </gl-form> diff --git a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue index 4e5245bd892..f583d2049af 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_meta_modal.vue @@ -1,22 +1,38 @@ <script> import { GlModal } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; +import Api from '~/api'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import EditMetaControls from './edit_meta_controls.vue'; +import { ISSUABLE_TYPE, MR_META_LOCAL_STORAGE_KEY } from '../constants'; + export default { components: { GlModal, EditMetaControls, + LocalStorageSync, }, props: { sourcePath: { type: String, required: true, }, + namespace: { + type: String, + required: true, + }, + project: { + type: String, + required: true, + }, }, data() { return { + clearStorage: false, + currentTemplate: null, + mergeRequestTemplates: null, mergeRequestMeta: { title: sprintf(s__(`StaticSiteEditor|Update %{sourcePath} file`), { sourcePath: this.sourcePath, @@ -42,24 +58,42 @@ export default { }; }, }, + mounted() { + this.initTemplates(); + }, methods: { hide() { this.$refs.modal.hide(); }, + initTemplates() { + const { namespace, project } = this; + Api.issueTemplates(namespace, project, ISSUABLE_TYPE, (err, templates) => { + if (err) return; // Error handled by global AJAX error handler + this.mergeRequestTemplates = templates; + }); + }, show() { this.$refs.modal.show(); }, onPrimary() { this.$emit('primary', this.mergeRequestMeta); - this.$refs.editMetaControls.resetCachedEditable(); + this.clearStorage = true; }, onSecondary() { this.hide(); }, + onChangeTemplate(template) { + this.currentTemplate = template; + + const description = this.currentTemplate ? this.currentTemplate.content : ''; + const mergeRequestMeta = { ...this.mergeRequestMeta, description }; + this.onUpdateSettings(mergeRequestMeta); + }, onUpdateSettings(mergeRequestMeta) { this.mergeRequestMeta = { ...mergeRequestMeta }; }, }, + storageKey: MR_META_LOCAL_STORAGE_KEY, }; </script> @@ -75,11 +109,20 @@ export default { @secondary="onSecondary" @hide="() => $emit('hide')" > + <local-storage-sync + v-model="mergeRequestMeta" + :storage-key="$options.storageKey" + :clear="clearStorage" + as-json + /> <edit-meta-controls ref="editMetaControls" :title="mergeRequestMeta.title" :description="mergeRequestMeta.description" + :templates="mergeRequestTemplates" + :current-template="currentTemplate" @updateSettings="onUpdateSettings" + @changeTemplate="onChangeTemplate" /> </gl-modal> </template> diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index 49db9ab7ca5..faa4026c064 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -2,6 +2,7 @@ import { s__, __ } from '~/locale'; export const BRANCH_SUFFIX_COUNT = 8; export const DEFAULT_TARGET_BRANCH = 'master'; +export const ISSUABLE_TYPE = 'merge_request'; export const SUBMIT_CHANGES_BRANCH_ERROR = s__('StaticSiteEditor|Branch could not be created.'); export const SUBMIT_CHANGES_COMMIT_ERROR = s__( @@ -20,4 +21,4 @@ export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit'; export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request'; export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor'; -export const DEFAULT_IMAGE_UPLOAD_PATH = 'source/images/uploads/'; +export const MR_META_LOCAL_STORAGE_KEY = 'sse-merge-request-meta-storage-key'; diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js index cc68bc57bb0..a13f7d3ad53 100644 --- a/app/assets/javascripts/static_site_editor/graphql/index.js +++ b/app/assets/javascripts/static_site_editor/graphql/index.js @@ -25,11 +25,15 @@ const createApolloProvider = appData => { }, ); + // eslint-disable-next-line @gitlab/require-i18n-strings + const mounts = appData.mounts.map(mount => ({ __typename: 'Mount', ...mount })); + defaultClient.cache.writeData({ data: { appData: { __typename: 'AppData', ...appData, + mounts, }, }, }); diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql index 9f4b0afe55f..e422a4b6036 100644 --- a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql @@ -6,5 +6,12 @@ query appData { sourcePath username returnUrl + branch + baseUrl + mounts { + source + target + } + imageUploadPath } } diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql index 0ded1722d26..00af6c10359 100644 --- a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql +++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql @@ -14,6 +14,11 @@ type SavedContentMeta { branch: SavedContentField! } +type Mount { + source: String! + target: String +} + type AppData { isSupportedContent: Boolean! hasSubmittedChanges: Boolean! @@ -21,6 +26,10 @@ type AppData { returnUrl: String sourcePath: String! username: String! + branch: String! + baseUrl: String! + mounts: [Mount]! + imageUploadPath: String! } input HasSubmittedChangesInput { diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js index 02285ccdba3..b5ff4385d3c 100644 --- a/app/assets/javascripts/static_site_editor/image_repository.js +++ b/app/assets/javascripts/static_site_editor/image_repository.js @@ -12,9 +12,11 @@ const imageRepository = () => { .catch(() => flash(__('Something went wrong while inserting your image. Please try again.'))); }; + const get = path => images.get(path); + const getAll = () => images; - return { add, getAll }; + return { add, get, getAll }; }; export default imageRepository; diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index fceef8f9084..b58564388de 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -9,6 +9,7 @@ const initStaticSiteEditor = el => { isSupportedContent, path: sourcePath, baseUrl, + branch, namespace, project, mergeRequestsIllustrationPath, @@ -16,13 +17,9 @@ const initStaticSiteEditor = el => { // so we are adding them here as a convenience for future use. // eslint-disable-next-line no-unused-vars staticSiteGenerator, - // eslint-disable-next-line no-unused-vars imageUploadPath, mounts, } = el.dataset; - // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object. - // eslint-disable-next-line no-unused-vars - const mountsObject = JSON.parse(mounts); const { current_username: username } = window.gon; const returnUrl = el.dataset.returnUrl || null; const router = createRouter(baseUrl); @@ -30,9 +27,13 @@ const initStaticSiteEditor = el => { isSupportedContent: parseBoolean(isSupportedContent), hasSubmittedChanges: false, project: `${namespace}/${project}`, + mounts: JSON.parse(mounts), // NOTE that the object in 'mounts' is a JSON string from the data attribute, so it must be parsed into an object. + branch, + baseUrl, returnUrl, sourcePath, username, + imageUploadPath, }); return new Vue({ diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 27bd1c99ae2..68943113c14 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -64,6 +64,9 @@ export default { isContentLoaded() { return Boolean(this.sourceContent); }, + projectSplit() { + return this.appData.project.split('/'); // TODO: refactor so `namespace` and `project` remain distinct + }, }, mounted() { Tracking.event(document.body.dataset.page, TRACKING_ACTION_INITIALIZE_EDITOR); @@ -138,11 +141,18 @@ export default { :content="sourceContent.content" :saving-changes="isSavingChanges" :return-url="appData.returnUrl" + :mounts="appData.mounts" + :branch="appData.branch" + :base-url="appData.baseUrl" + :project="appData.project" + :image-root="appData.imageUploadPath" @submit="onPrepareSubmit" /> <edit-meta-modal ref="editMetaModal" :source-path="appData.sourcePath" + :namespace="projectSplit[0]" + :project="projectSplit[1]" @primary="onSubmit" @hide="onHideModal" /> diff --git a/app/assets/javascripts/static_site_editor/services/front_matterify.js b/app/assets/javascripts/static_site_editor/services/front_matterify.js index cbf0fffd515..60a5d799d11 100644 --- a/app/assets/javascripts/static_site_editor/services/front_matterify.js +++ b/app/assets/javascripts/static_site_editor/services/front_matterify.js @@ -16,6 +16,7 @@ export const frontMatterify = source => { const NO_FRONTMATTER = { source, matter: null, + hasMatter: false, spacing: null, content: source, delimiter: null, @@ -53,6 +54,7 @@ export const frontMatterify = source => { return { source, matter, + hasMatter: true, spacing, content, delimiter, diff --git a/app/assets/javascripts/static_site_editor/services/parse_source_file.js b/app/assets/javascripts/static_site_editor/services/parse_source_file.js index d4fc8b2edb6..39126eb7bcc 100644 --- a/app/assets/javascripts/static_site_editor/services/parse_source_file.js +++ b/app/assets/javascripts/static_site_editor/services/parse_source_file.js @@ -1,15 +1,18 @@ import { frontMatterify, stringify } from './front_matterify'; const parseSourceFile = raw => { - const remake = source => frontMatterify(source); - - let editable = remake(raw); + let editable; const syncContent = (newVal, isBody) => { if (isBody) { editable.content = newVal; } else { - editable = remake(newVal); + try { + editable = frontMatterify(newVal); + editable.isMatterValid = true; + } catch (e) { + editable.isMatterValid = false; + } } }; @@ -23,10 +26,15 @@ const parseSourceFile = raw => { const isModified = () => stringify(editable) !== raw; - const hasMatter = () => Boolean(editable.matter); + const hasMatter = () => editable.hasMatter; + + const isMatterValid = () => editable.isMatterValid; + + syncContent(raw); return { matter, + isMatterValid, syncMatter, content, syncContent, diff --git a/app/assets/javascripts/static_site_editor/services/renderers/render_image.js b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js new file mode 100644 index 00000000000..b0d863bdb5a --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/renderers/render_image.js @@ -0,0 +1,89 @@ +import { isAbsolute, getBaseURL, joinPaths } from '~/lib/utils/url_utility'; + +const canRender = ({ type }) => type === 'image'; + +let metadata; + +const getCachedContent = basePath => metadata.imageRepository.get(basePath); + +const isRelativeToCurrentDirectory = basePath => !basePath.startsWith('/'); + +const extractSourceDirectory = url => { + const sourceDir = /^(.+)\/([^/]+)$/.exec(url); // Extracts the base path and fileName from an image path + return sourceDir || [null, null, url]; // If no source directory was extracted it means only a fileName was specified (e.g. url='file.png') +}; + +const parseCurrentDirectory = basePath => { + const baseUrl = decodeURIComponent(metadata.baseUrl); + const sourceDirectory = extractSourceDirectory(baseUrl)[1]; + const currentDirectory = sourceDirectory.split(`/-/sse/${metadata.branch}`)[1]; + + return joinPaths(currentDirectory, basePath); +}; + +// For more context around this logic, please see the following comment: +// https://gitlab.com/gitlab-org/gitlab/-/issues/241166#note_409413500 +const generateSourceDirectory = basePath => { + let sourceDir = ''; + let defaultSourceDir = ''; + + if (!basePath || isRelativeToCurrentDirectory(basePath)) { + return parseCurrentDirectory(basePath); + } + + if (!metadata.mounts.length) { + return basePath; + } + + metadata.mounts.forEach(({ source, target }) => { + const hasTarget = target !== ''; + + if (hasTarget && basePath.includes(target)) { + sourceDir = source; + } else if (!hasTarget) { + defaultSourceDir = joinPaths(source, basePath); + } + }); + + return sourceDir || defaultSourceDir; +}; + +const resolveFullPath = (originalSrc, cachedContent) => { + if (cachedContent) { + return `data:image;base64,${cachedContent}`; + } + + if (isAbsolute(originalSrc)) { + return originalSrc; + } + + const sourceDirectory = extractSourceDirectory(originalSrc); + const [, basePath, fileName] = sourceDirectory; + const sourceDir = generateSourceDirectory(basePath); + + return joinPaths(getBaseURL(), metadata.project, '/-/raw/', metadata.branch, sourceDir, fileName); +}; + +const render = ({ destination: originalSrc, firstChild }, { skipChildren }) => { + skipChildren(); + + const cachedContent = getCachedContent(originalSrc); + + return { + type: 'openTag', + tagName: 'img', + selfClose: true, + attributes: { + 'data-original-src': !isAbsolute(originalSrc) || cachedContent ? originalSrc : '', + src: resolveFullPath(originalSrc, cachedContent), + alt: firstChild.literal, + }, + }; +}; + +const build = (mounts = [], project, branch, baseUrl, imageRepository) => { + metadata = { mounts, project, branch, baseUrl, imageRepository }; + return { canRender, render }; +}; + +export default { build }; diff --git a/app/assets/javascripts/terraform/components/empty_state.vue b/app/assets/javascripts/terraform/components/empty_state.vue new file mode 100644 index 00000000000..d86ba3af2b1 --- /dev/null +++ b/app/assets/javascripts/terraform/components/empty_state.vue @@ -0,0 +1,44 @@ +<script> +import { GlEmptyState, GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; + +export default { + components: { + GlEmptyState, + GlIcon, + GlLink, + GlSprintf, + }, + props: { + image: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <gl-empty-state :svg-path="image" :title="s__('Terraform|Get started with Terraform')"> + <template #description> + <p> + <gl-sprintf + :message=" + s__( + 'Terraform|Find out how to use the %{linkStart}GitLab managed Terraform State%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link + href="https://docs.gitlab.com/ee/user/infrastructure/index.html" + target="_blank" + > + {{ content }} + <gl-icon name="external-link" /> + </gl-link> + </template> + </gl-sprintf> + </p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue new file mode 100644 index 00000000000..2e4c18c5a5b --- /dev/null +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -0,0 +1,101 @@ +<script> +import { GlBadge, GlIcon, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + components: { + GlBadge, + GlIcon, + GlSprintf, + GlTable, + GlTooltip, + TimeAgoTooltip, + }, + mixins: [timeagoMixin], + props: { + states: { + required: true, + type: Array, + }, + }, + computed: { + fields() { + return [ + { + key: 'name', + thClass: 'gl-display-none', + }, + { + key: 'updated', + thClass: 'gl-display-none', + tdClass: 'gl-text-right', + }, + ]; + }, + }, + methods: { + createdByUserName(item) { + return item.latestVersion?.createdByUser?.name; + }, + lockedByUserName(item) { + return item.lockedByUser?.name || s__('Terraform|Unknown User'); + }, + updatedTime(item) { + return item.latestVersion?.updatedAt || item.updatedAt; + }, + }, +}; +</script> + +<template> + <gl-table :items="states" :fields="fields" data-testid="terraform-states-table"> + <template #cell(name)="{ item }"> + <div class="gl-display-flex align-items-center" data-testid="terraform-states-table-name"> + <p class="gl-font-weight-bold gl-m-0 gl-text-gray-900"> + {{ item.name }} + </p> + + <div v-if="item.lockedAt" id="terraformLockedBadgeContainer" class="gl-mx-2"> + <gl-badge id="terraformLockedBadge"> + <gl-icon name="lock" /> + {{ s__('Terraform|Locked') }} + </gl-badge> + + <gl-tooltip + container="terraformLockedBadgeContainer" + placement="right" + target="terraformLockedBadge" + > + <gl-sprintf :message="s__('Terraform|Locked by %{user} %{timeAgo}')"> + <template #user> + {{ lockedByUserName(item) }} + </template> + + <template #timeAgo> + {{ timeFormatted(item.lockedAt) }} + </template> + </gl-sprintf> + </gl-tooltip> + </div> + </div> + </template> + + <template #cell(updated)="{ item }"> + <p class="gl-m-0" data-testid="terraform-states-table-updated"> + <gl-sprintf :message="s__('Terraform|%{user} updated %{timeAgo}')"> + <template #user> + <span v-if="item.latestVersion"> + {{ createdByUserName(item) }} + </span> + </template> + + <template #timeAgo> + <time-ago-tooltip :time="updatedTime(item)" /> + </template> + </gl-sprintf> + </p> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue new file mode 100644 index 00000000000..f614bdc8d43 --- /dev/null +++ b/app/assets/javascripts/terraform/components/terraform_list.vue @@ -0,0 +1,134 @@ +<script> +import { GlAlert, GlBadge, GlKeysetPagination, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui'; +import getStatesQuery from '../graphql/queries/get_states.query.graphql'; +import EmptyState from './empty_state.vue'; +import StatesTable from './states_table.vue'; +import { MAX_LIST_COUNT } from '../constants'; + +export default { + apollo: { + states: { + query: getStatesQuery, + variables() { + return { + projectPath: this.projectPath, + ...this.cursor, + }; + }, + update: data => { + return { + count: data?.project?.terraformStates?.count, + list: data?.project?.terraformStates?.nodes, + pageInfo: data?.project?.terraformStates?.pageInfo, + }; + }, + error() { + this.states = null; + }, + }, + }, + components: { + EmptyState, + GlAlert, + GlBadge, + GlKeysetPagination, + GlLoadingIcon, + GlTab, + GlTabs, + StatesTable, + }, + props: { + emptyStateImage: { + required: true, + type: String, + }, + projectPath: { + required: true, + type: String, + }, + }, + data() { + return { + cursor: { + first: MAX_LIST_COUNT, + after: null, + last: null, + before: null, + }, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.states.loading; + }, + pageInfo() { + return this.states?.pageInfo || {}; + }, + showPagination() { + return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; + }, + statesCount() { + return this.states?.count; + }, + statesList() { + return this.states?.list; + }, + }, + methods: { + updatePagination(item) { + if (item === this.pageInfo.endCursor) { + this.cursor = { + first: MAX_LIST_COUNT, + after: item, + last: null, + before: null, + }; + } else { + this.cursor = { + first: null, + after: null, + last: MAX_LIST_COUNT, + before: item, + }; + } + }, + }, +}; +</script> + +<template> + <section> + <gl-tabs> + <gl-tab> + <template slot="title"> + <p class="gl-m-0"> + {{ s__('Terraform|States') }} + <gl-badge v-if="statesCount">{{ statesCount }}</gl-badge> + </p> + </template> + + <gl-loading-icon v-if="isLoading" size="md" class="gl-mt-3" /> + + <div v-else-if="statesList"> + <div v-if="statesCount"> + <states-table :states="statesList" /> + + <div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-keyset-pagination + v-bind="pageInfo" + @prev="updatePagination" + @next="updatePagination" + /> + </div> + </div> + + <empty-state v-else :image="emptyStateImage" /> + </div> + + <gl-alert v-else variant="danger" :dismissible="false"> + {{ s__('Terraform|An error occurred while loading your Terraform States') }} + </gl-alert> + </gl-tab> + </gl-tabs> + </section> +</template> diff --git a/app/assets/javascripts/terraform/constants.js b/app/assets/javascripts/terraform/constants.js new file mode 100644 index 00000000000..bbc4630f83b --- /dev/null +++ b/app/assets/javascripts/terraform/constants.js @@ -0,0 +1 @@ +export const MAX_LIST_COUNT = 25; diff --git a/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql new file mode 100644 index 00000000000..49f9ae3dd97 --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/fragments/state.fragment.graphql @@ -0,0 +1,17 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" +#import "./state_version.fragment.graphql" + +fragment State on TerraformState { + id + name + lockedAt + updatedAt + + lockedByUser { + ...User + } + + latestVersion { + ...StateVersion + } +} diff --git a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql new file mode 100644 index 00000000000..c7e9700c696 --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql @@ -0,0 +1,9 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +fragment StateVersion on TerraformStateVersion { + updatedAt + + createdByUser { + ...User + } +} diff --git a/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql new file mode 100644 index 00000000000..9453e32b1b5 --- /dev/null +++ b/app/assets/javascripts/terraform/graphql/queries/get_states.query.graphql @@ -0,0 +1,18 @@ +#import "../fragments/state.fragment.graphql" +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getStates($projectPath: ID!, $first: Int, $last: Int, $before: String, $after: String) { + project(fullPath: $projectPath) { + terraformStates(first: $first, last: $last, before: $before, after: $after) { + count + + nodes { + ...State + } + + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js new file mode 100644 index 00000000000..579d2d14023 --- /dev/null +++ b/app/assets/javascripts/terraform/index.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import TerraformList from './components/terraform_list.vue'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export default () => { + const el = document.querySelector('#js-terraform-list'); + + if (!el) { + return null; + } + + const defaultClient = createDefaultClient(); + + const { emptyStateImage, projectPath } = el.dataset; + + return new Vue({ + el, + apolloProvider: new VueApollo({ defaultClient }), + render(createElement) { + return createElement(TerraformList, { + props: { + emptyStateImage, + projectPath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue index 8307f878def..05927006ea6 100644 --- a/app/assets/javascripts/tooltips/components/tooltips.vue +++ b/app/assets/javascripts/tooltips/components/tooltips.vue @@ -108,6 +108,7 @@ export default { :container="tooltip.container" :boundary="tooltip.boundary" :disabled="tooltip.disabled" + :show="tooltip.show" > <span v-if="tooltip.html" v-safe-html="tooltip.title"></span> <span v-else>{{ tooltip.title }}</span> diff --git a/app/assets/javascripts/tooltips/index.js b/app/assets/javascripts/tooltips/index.js index 9f5dce4183c..f7cad6639ae 100644 --- a/app/assets/javascripts/tooltips/index.js +++ b/app/assets/javascripts/tooltips/index.js @@ -96,6 +96,12 @@ export const initTooltips = (config = {}) => { return invokeBootstrapApi(document.body, config); }; +export const add = (elements, config = {}) => { + if (isGlTooltipsEnabled()) { + return addTooltips(elements, config); + } + return invokeBootstrapApi(elements, config); +}; export const dispose = tooltipApiInvoker({ glHandler: element => tooltipsApp().dispose(element), bsHandler: elements => invokeBootstrapApi(elements, 'dispose'), diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index c1521882682..0a1211d0a76 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -12,6 +12,7 @@ const DEFAULT_SNOWPLOW_OPTIONS = { contexts: { webPage: true, performanceTiming: true }, formTracking: false, linkClickTracking: false, + pageUnloadTimer: 10, }; const createEventPayload = (el, { suffix = '' } = {}) => { diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 20d1a3c1fcd..dccd6807f13 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -14,6 +14,7 @@ import ModalStore from '../boards/stores/modal_store'; import { parseBoolean, spriteIcon } from '../lib/utils/common_utils'; import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { fixTitle, dispose } from '~/tooltips'; // TODO: remove eventHub hack after code splitting refactor window.emitSidebarEvent = window.emitSidebarEvent || $.noop; @@ -229,7 +230,9 @@ function UsersSelect(currentUser, els, options = {}) { tooltipTitle = s__('UsersSelect|Assignee'); } $value.html(assigneeTemplate(user)); - $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle'); + $collapsedSidebar.attr('title', tooltipTitle); + fixTitle($collapsedSidebar); + return $collapsedSidebar.html(collapsedAssigneeTemplate(user)); }); }; @@ -423,7 +426,7 @@ function UsersSelect(currentUser, els, options = {}) { const { $el, e, isMarking } = options; const user = options.selectedObj; - $el.tooltip('dispose'); + dispose($el); if ($dropdown.hasClass('js-multiselect')) { const isActive = $el.hasClass('is-active'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue new file mode 100644 index 00000000000..eff26729fa7 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -0,0 +1,157 @@ +<script> +import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui'; +import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; +import StatusIcon from '../mr_widget_status_icon.vue'; + +export const LOADING_STATES = { + collapsedLoading: 'collapsedLoading', + collapsedError: 'collapsedError', + expandedLoading: 'expandedLoading', +}; + +export default { + components: { + GlButton, + GlLoadingIcon, + GlIcon, + GlLink, + GlBadge, + SmartVirtualList, + StatusIcon, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + data() { + return { + loadingState: LOADING_STATES.collapsedLoading, + collapsedData: null, + fullData: null, + isCollapsed: true, + }; + }, + computed: { + isLoadingSummary() { + return this.loadingState === LOADING_STATES.collapsedLoading; + }, + isLoadingExpanded() { + return this.loadingState === LOADING_STATES.expandedLoading; + }, + isCollapsible() { + if (this.isLoadingSummary) { + return false; + } + + return true; + }, + statusIconName() { + if (this.isLoadingSummary) { + return 'loading'; + } + + if (this.loadingState === LOADING_STATES.collapsedError) { + return 'warning'; + } + + return this.statusIcon(this.collapsedData); + }, + }, + watch: { + isCollapsed(newVal) { + if (!newVal) { + this.loadAllData(); + } else { + this.loadingState = null; + } + }, + }, + mounted() { + this.fetchCollapsedData(this.$props) + .then(data => { + this.collapsedData = data; + this.loadingState = null; + }) + .catch(e => { + this.loadingState = LOADING_STATES.collapsedError; + throw e; + }); + }, + methods: { + toggleCollapsed() { + this.isCollapsed = !this.isCollapsed; + }, + loadAllData() { + if (this.fullData) return; + + this.loadingState = LOADING_STATES.expandedLoading; + + this.fetchFullData(this.$props) + .then(data => { + this.loadingState = null; + this.fullData = data; + }) + .catch(e => { + this.loadingState = null; + throw e; + }); + }, + }, +}; +</script> + +<template> + <section class="media-section mr-widget-border-top"> + <div class="media gl-p-5"> + <status-icon :status="statusIconName" class="align-self-center" /> + <div class="media-body d-flex flex-align-self-center align-items-center"> + <div class="code-text"> + <template v-if="isLoadingSummary"> + {{ __('Loading...') }} + </template> + <div v-else v-safe-html="summary(collapsedData)"></div> + </div> + <gl-button + v-if="isCollapsible" + size="small" + class="float-right align-self-center" + @click="toggleCollapsed" + > + {{ isCollapsed ? __('Expand') : __('Collapse') }} + </gl-button> + </div> + </div> + <div v-if="!isCollapsed" class="mr-widget-grouped-section"> + <div v-if="isLoadingExpanded" class="report-block-container"> + <gl-loading-icon inline /> {{ __('Loading...') }} + </div> + <smart-virtual-list + v-else-if="fullData" + :length="fullData.length" + :remain="20" + :size="32" + wtag="ul" + wclass="report-block-list" + class="report-block-container" + > + <li v-for="data in fullData" :key="data.id" class="d-flex align-items-center"> + <div v-if="data.icon" :class="data.icon.class" class="d-flex"> + <gl-icon :name="data.icon.name" :size="24" /> + </div> + <div + class="gl-mt-2 gl-mb-2 align-content-around align-items-start flex-wrap align-self-center d-flex" + > + <div class="gl-mr-4"> + {{ data.text }} + </div> + <div v-if="data.link"> + <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> + </div> + <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> + {{ data.badge.text }} + </gl-badge> + </div> + </li> + </smart-virtual-list> + </div> + </section> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js new file mode 100644 index 00000000000..5014c12dc30 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js @@ -0,0 +1,27 @@ +import { extensions } from './index'; + +export default { + props: { + mr: { + type: Object, + required: true, + }, + }, + render(h) { + return h( + 'div', + {}, + extensions.map(extension => + h(extension, { + props: extensions[0].props.reduce( + (acc, key) => ({ + ...acc, + [key]: this.mr[key], + }), + {}, + ), + }), + ), + ); + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js new file mode 100644 index 00000000000..2bfaec8a1c9 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js @@ -0,0 +1,30 @@ +import ExtensionBase from './base.vue'; + +// Holds all the currently registered extensions +export const extensions = []; + +export const registerExtension = extension => { + // Pushes into the extenions array a dynamically created Vue component + // that gets exteneded from `base.vue` + extensions.push({ + extends: ExtensionBase, + name: extension.name, + props: extension.props, + computed: { + ...Object.keys(extension.computed).reduce( + (acc, computedKey) => ({ + ...acc, + // Making the computed property a method allows us to pass in arguments + // this allows for each computed property to recieve some data + [computedKey]() { + return extension.computed[computedKey]; + }, + }), + {}, + ), + }, + methods: { + ...extension.methods, + }, + }); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue index 598b08f4c16..5ed699acddf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_author.vue @@ -1,10 +1,10 @@ <script> -import tooltip from '../../vue_shared/directives/tooltip'; +import { GlTooltipDirective } from '@gitlab/ui'; export default { name: 'MrWidgetAuthor', directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { author: { @@ -16,11 +16,6 @@ export default { required: false, default: true, }, - showAuthorTooltip: { - type: Boolean, - required: false, - default: false, - }, }, computed: { authorUrl() { @@ -33,12 +28,7 @@ export default { }; </script> <template> - <a - :href="authorUrl" - :v-tooltip="showAuthorTooltip" - :title="author.name" - class="author-link inline" - > + <a v-gl-tooltip :href="authorUrl" :title="author.name" class="author-link inline"> <img :src="avatarUrl" class="avatar avatar-inline s16" /> <span v-if="showAuthorName" class="author">{{ author.name }}</span> </a> 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 eb8989adb2a..d5fdbe726e9 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 @@ -1,6 +1,5 @@ <script> /* eslint-disable vue/no-v-html */ -import Mousetrap from 'mousetrap'; import { escape } from 'lodash'; import { GlButton, @@ -84,17 +83,6 @@ export default { : ''; }, }, - mounted() { - Mousetrap.bind('b', this.copyBranchName); - }, - beforeDestroy() { - Mousetrap.unbind('b'); - }, - methods: { - copyBranchName() { - this.$refs.copyBranchNameButton.$el.click(); - }, - }, }; </script> <template> @@ -110,7 +98,6 @@ export default { class="label-branch label-truncate js-source-branch" v-html="mr.sourceBranchLink" /><clipboard-button - ref="copyBranchNameButton" data-testid="mr-widget-copy-clipboard" :text="branchNameClipboardData" :title="__('Copy branch name')" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue index f17e409d996..b6722de5277 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -1,10 +1,10 @@ <script> -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; export default { components: { - GlDeprecatedDropdown, - GlDeprecatedDropdownItem, + GlDropdown, + GlDropdownItem, }, props: { commits: { @@ -18,20 +18,20 @@ export default { <template> <div> - <gl-deprecated-dropdown + <gl-dropdown right text="Use an existing commit message" variant="link" class="mr-commit-dropdown" > - <gl-deprecated-dropdown-item + <gl-dropdown-item v-for="commit in commits" :key="commit.short_id" class="text-nowrap text-truncate" @click="$emit('input', commit.message)" > <span class="monospace mr-2">{{ commit.short_id }}</span> {{ commit.title }} - </gl-deprecated-dropdown-item> - </gl-deprecated-dropdown> + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 12f65a4c235..750014c599a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -1,4 +1,5 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge'; import { deprecatedCreateFlash as Flash } from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; @@ -12,6 +13,7 @@ export default { components: { MrWidgetAuthor, statusIcon, + GlLoadingIcon, }, mixins: [autoMergeMixin], props: { @@ -100,7 +102,7 @@ export default { class="btn btn-sm btn-default js-cancel-auto-merge" @click.prevent="cancelAutomaticMerge" > - <i v-if="isCancellingAutoMerge" class="fa fa-spinner fa-spin" aria-hidden="true"> </i> + <gl-loading-icon v-if="isCancellingAutoMerge" inline class="gl-mr-1" /> {{ cancelButtonText }} </a> </h4> @@ -122,7 +124,7 @@ export default { href="#" @click.prevent="removeSourceBranch" > - <i v-if="isRemovingSourceBranch" class="fa fa-spinner fa-spin" aria-hidden="true"> </i> + <gl-loading-icon v-if="isRemovingSourceBranch" inline class="gl-mr-1" /> {{ s__('mrWidget|Delete source branch') }} </a> </p> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 835f7b9e9a9..2c1f2285dda 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -1,6 +1,15 @@ <script> import { isEmpty } from 'lodash'; -import { GlIcon, GlButton, GlSprintf, GlLink } from '@gitlab/ui'; +import { + GlIcon, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlSprintf, + GlLink, + GlTooltipDirective, +} from '@gitlab/ui'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import simplePoll from '~/lib/utils/simple_poll'; import { __ } from '~/locale'; @@ -36,6 +45,9 @@ export default { GlSprintf, GlLink, GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, MergeTrainHelperText: () => import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'), MergeImmediatelyConfirmationDialog: () => @@ -43,6 +55,9 @@ export default { 'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue' ), }, + directives: { + GlTooltip: GlTooltipDirective, + }, mixins: [readyToMergeMixin], props: { mr: { type: Object, required: true }, @@ -283,7 +298,7 @@ export default { <status-icon :status="iconClass" /> <div class="media-body"> <div class="mr-widget-body-controls media space-children"> - <span class="btn-group"> + <gl-button-group> <gl-button size="medium" category="primary" @@ -294,54 +309,33 @@ export default { @click="handleMergeButtonClick(isAutoMergeAvailable)" >{{ mergeButtonText }}</gl-button > - <button + <gl-dropdown v-if="shouldShowMergeImmediatelyDropdown" + v-gl-tooltip.hover.focus="__('Select merge moment')" :disabled="isMergeButtonDisabled" - type="button" - class="btn btn-sm btn-info dropdown-toggle js-merge-moment" - data-toggle="dropdown" + variant="info" data-qa-selector="merge_moment_dropdown" - :aria-label="__('Select merge moment')" - > - <i class="fa fa-chevron-down" aria-hidden="true"></i> - </button> - <ul - v-if="shouldShowMergeImmediatelyDropdown" - class="dropdown-menu dropdown-menu-right" - role="menu" + toggle-class="btn-icon js-merge-moment" > - <li> - <a - class="auto_merge_enabled qa-merge-when-pipeline-succeeds-option" - href="#" - @click.prevent="handleMergeButtonClick(true)" - > - <span class="media"> - <gl-icon name="status_success" class="merge-opt-icon" aria-hidden="true" /> - <span class="media-body merge-opt-title">{{ autoMergeText }}</span> - </span> - </a> - </li> - <li> - <merge-immediately-confirmation-dialog - ref="confirmationDialog" - :docs-url="mr.mergeImmediatelyDocsPath" - @mergeImmediately="onMergeImmediatelyConfirmation" - /> - <a - class="accept-merge-request js-merge-immediately-button" - data-qa-selector="merge_immediately_option" - href="#" - @click.prevent="handleMergeImmediatelyButtonClick" - > - <span class="media"> - <gl-icon name="status_warning" class="merge-opt-icon" aria-hidden="true" /> - <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span> - </span> - </a> - </li> - </ul> - </span> + <template #button-content> + <gl-icon name="chevron-down" class="mr-0" /> + <span class="sr-only">{{ __('Select merge moment') }}</span> + </template> + <gl-dropdown-item + icon-name="warning" + button-class="accept-merge-request js-merge-immediately-button" + data-qa-selector="merge_immediately_option" + @click="handleMergeImmediatelyButtonClick" + > + {{ __('Merge immediately') }} + </gl-dropdown-item> + <merge-immediately-confirmation-dialog + ref="confirmationDialog" + :docs-url="mr.mergeImmediatelyDocsPath" + @mergeImmediately="onMergeImmediatelyConfirmation" + /> + </gl-dropdown> + </gl-button-group> <div class="media-body-wrap space-children"> <template v-if="shouldShowMergeControls"> <label v-if="mr.canRemoveSourceBranch"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index eba3d50fdc9..1d591168a17 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -1,6 +1,7 @@ <script> import $ from 'jquery'; import { GlButton } from '@gitlab/ui'; +import { produce } from 'immer'; import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import MergeRequest from '~/merge_request'; @@ -80,12 +81,18 @@ export default { return; } - const data = store.readQuery({ + const sourceData = store.readQuery({ query: getStateQuery, variables: mergeRequestQueryVariables, }); - data.project.mergeRequest.workInProgress = workInProgress; - data.project.mergeRequest.title = title; + + const data = produce(sourceData, draftState => { + // eslint-disable-next-line no-param-reassign + draftState.project.mergeRequest.workInProgress = workInProgress; + // eslint-disable-next-line no-param-reassign + draftState.project.mergeRequest.title = title; + }); + store.writeQuery({ query: getStateQuery, data, @@ -143,7 +150,7 @@ export default { <div class="media-body"> <div class="gl-ml-3 float-left"> <span class="gl-font-weight-bold"> - {{ __('This merge request is still a work in progress.') }} + {{ __('This merge request is still a draft.') }} </span> <span class="gl-display-block text-muted">{{ __("Draft merge requests can't be merged.") diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js new file mode 100644 index 00000000000..2d21ced1b28 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -0,0 +1,66 @@ +/* eslint-disable */ +import issuesCollapsedQuery from './issues_collapsed.query.graphql'; +import issuesQuery from './issues.query.graphql'; + +export default { + // Give the extension a name + // Make it easier to track in Vue dev tools + name: 'WidgetIssues', + // Add an array of props + // These then get mapped to values stored in the MR Widget store + props: ['targetProjectFullPath'], + // Add any extra computed props in here + computed: { + // Small summary text to be displayed in the collapsed state + // Receives the collapsed data as an argument + summary(count) { + return `<strong>${count}</strong> open issue`; + }, + // Status icon to be used next to the summary text + // Receives the collapsed data as an argument + statusIcon(count) { + return count > 0 ? 'warning' : 'success'; + }, + }, + methods: { + // Fetches the collapsed data + // Ideally, this request should return the smallest amount of data possible + // Receives an object of all the props passed in to the extension + fetchCollapsedData({ targetProjectFullPath }) { + return this.$apollo + .query({ query: issuesCollapsedQuery, variables: { projectPath: targetProjectFullPath } }) + .then(({ data }) => data.project.issues.count); + }, + // Fetches the full data when the extension is expanded + // Receives an object of all the props passed in to the extension + fetchFullData({ targetProjectFullPath }) { + return this.$apollo + .query({ query: issuesQuery, variables: { projectPath: targetProjectFullPath } }) + .then(({ data }) => { + // Return some transformed data to be rendered in the expanded state + return data.project.issues.nodes.map(issue => ({ + id: issue.id, // Required: The ID of the object + text: issue.title, // Required: The text to get used on each row + // Icon to get rendered on the side of each row + icon: { + // Required: Name maps to an icon in GitLabs SVG + name: + issue.state === 'closed' ? 'status_failed_borderless' : 'status_success_borderless', + // Optional: An extra class to be added to the icon for additional styling + class: issue.state === 'closed' ? 'text-danger' : 'text-success', + }, + // Badges get rendered next to the text on each row + badge: issue.state === 'closed' && { + text: 'Closed', // Required: Text to be used inside of the badge + // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants + }, + // Each row can have its own link that will take the user elsewhere + // link: { + // href: 'https://google.com', // Required: href for the link + // text: 'Link text', // Required: Text to be used inside the link + // }, + })); + }); + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql new file mode 100644 index 00000000000..690f571c083 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.query.graphql @@ -0,0 +1,13 @@ +query getAllIssues($projectPath: ID!) { + project(fullPath: $projectPath) { + issues { + nodes { + id + title + webPath + webUrl + state + } + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql new file mode 100644 index 00000000000..389a81e0a61 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues_collapsed.query.graphql @@ -0,0 +1,7 @@ +query getIssues($projectPath: ID!) { + project(fullPath: $projectPath) { + issues { + count + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 87e56dfcbdf..8f2cca3309a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -3,12 +3,19 @@ import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_optio import VueApollo from 'vue-apollo'; import Translate from '../vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; +import { registerExtension } from './components/extensions'; +import issueExtension from './extensions/issues'; Vue.use(Translate); Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), }); export default () => { @@ -17,6 +24,8 @@ export default () => { gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url; + registerExtension(issueExtension); + const vm = new Vue({ ...MrWidgetOptions, apolloProvider }); window.gl.mrWidget = { 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 46749fc5e87..190d790f584 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 @@ -37,6 +37,7 @@ import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue'; import MrWidgetAutoMergeEnabled from './components/states/mr_widget_auto_merge_enabled.vue'; import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue'; import CheckingState from './components/states/mr_widget_checking.vue'; +// import ExtensionsContainer from './components/extensions/container'; import eventHub from './event_hub'; import notify from '~/lib/utils/notify'; import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue'; @@ -46,7 +47,6 @@ import GroupedTestReportsApp from '../reports/components/grouped_test_reports_ap import { setFaviconOverlay } from '../lib/utils/common_utils'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; import getStateQuery from './queries/get_state.query.graphql'; -import { isExperimentEnabled } from '~/lib/utils/experimentation'; export default { el: '#js-vue-mr-widget', @@ -58,6 +58,7 @@ export default { }, components: { Loading, + // ExtensionsContainer, 'mr-widget-header': WidgetHeader, 'mr-widget-suggest-pipeline': WidgetSuggestPipeline, 'mr-widget-merge-help': WidgetMergeHelp, @@ -154,7 +155,7 @@ export default { }, shouldSuggestPipelines() { return ( - isExperimentEnabled('suggestPipeline') && + gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline @@ -455,6 +456,7 @@ export default { :service="service" /> <div class="mr-section-container mr-widget-workflow"> + <!-- <extensions-container :mr="mr" /> --> <grouped-codequality-reports-app v-if="shouldRenderCodeQuality" :base-path="mr.codeclimate.base_path" diff --git a/app/assets/javascripts/vue_shared/components/actions_button.vue b/app/assets/javascripts/vue_shared/components/actions_button.vue index 9b21de19185..cb4c5f20377 100644 --- a/app/assets/javascripts/vue_shared/components/actions_button.vue +++ b/app/assets/javascripts/vue_shared/components/actions_button.vue @@ -61,7 +61,6 @@ export default { <gl-dropdown v-if="hasMultipleActions" v-gl-tooltip="selectedAction.tooltip" - class="gl-button-deprecated-adapter" :text="selectedAction.text" :split-href="selectedAction.href" :variant="variant" diff --git a/app/assets/javascripts/vue_shared/components/alert_details_table.vue b/app/assets/javascripts/vue_shared/components/alert_details_table.vue index 34f6d384f7b..3e2b4cd35ab 100644 --- a/app/assets/javascripts/vue_shared/components/alert_details_table.vue +++ b/app/assets/javascripts/vue_shared/components/alert_details_table.vue @@ -7,7 +7,6 @@ import { convertToSentenceCase, splitCamelCase, } from '~/lib/utils/text_utility'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!'; const tdClass = 'gl-border-gray-100! gl-p-5!'; @@ -25,6 +24,7 @@ const allowedFields = [ 'endedAt', 'details', 'hosts', + 'environment', ]; export default { @@ -32,7 +32,6 @@ export default { GlLoadingIcon, GlTable, }, - mixins: [glFeatureFlagsMixin()], props: { alert: { type: Object, @@ -60,9 +59,6 @@ export default { }, ], computed: { - flaggedAllowedFields() { - return this.shouldDisplayEnvironment ? [...allowedFields, 'environment'] : allowedFields; - }, items() { if (!this.alert) { return []; @@ -84,13 +80,10 @@ export default { [], ); }, - shouldDisplayEnvironment() { - return this.glFeatures.exposeEnvironmentPathInAlertDetails; - }, }, methods: { isAllowed(fieldName) { - return this.flaggedAllowedFields.includes(fieldName); + return allowedFields.includes(fieldName); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index 2e4b9b9a135..7a687ea4ad0 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -1,8 +1,7 @@ <script> /* eslint-disable vue/no-v-html */ import { groupBy } from 'lodash'; -import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; -import tooltip from '~/vue_shared/directives/tooltip'; +import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { glEmojiTag } from '../../emoji'; import { __, sprintf } from '~/locale'; @@ -15,7 +14,7 @@ export default { GlLoadingIcon, }, directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, props: { awards: { @@ -154,10 +153,9 @@ export default { <button v-for="awardList in groupedAwards" :key="awardList.name" - v-tooltip + v-gl-tooltip.viewport :class="awardList.classes" :title="awardList.title" - data-boundary="viewport" data-testid="award-button" class="btn award-control" type="button" @@ -168,12 +166,11 @@ export default { </button> <div v-if="canAwardEmoji" class="award-menu-holder"> <button - v-tooltip + v-gl-tooltip.viewport :class="addButtonClass" class="award-control btn js-add-award" title="Add reaction" :aria-label="__('Add reaction')" - data-boundary="viewport" type="button" > <span class="award-control-icon award-control-icon-neutral"> diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js index 7a76888c916..6f7723955bf 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js @@ -1,4 +1,4 @@ -import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance_constants'; +import { SNIPPET_MEASURE_BLOBS_CONTENT } from '~/performance/constants'; import eventHub from '~/blob/components/eventhub'; export default { diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index bbe72a2b122..646e1703f1e 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -9,6 +9,7 @@ export default { GlIcon, }, mixins: [ViewerMixin], + inject: ['blobHash'], data() { return { highlightedLine: null, @@ -64,7 +65,7 @@ export default { </a> </div> <div class="blob-content"> - <pre class="code highlight"><code id="blob-code-content" v-html="content"></code></pre> + <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index ff665d9cc58..d775a093f5f 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -6,11 +6,8 @@ import { GlIcon } from '@gitlab/ui'; * * Receives status object containing: * status: { - * details_path: "/gitlab-org/gitlab-foss/pipelines/8150156" // url * group:"running" // used for CSS class * icon: "icon_status_running" // used to render the icon - * label:"running" // used for potential tooltip - * text:"running" // text rendered * } * * Used in: diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue index a42a606d446..96f800511d2 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue @@ -1,5 +1,5 @@ <script> -import { GlModal } from '@gitlab/ui'; +import { GlModal, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import csrf from '~/lib/utils/csrf'; @@ -7,6 +7,9 @@ export default { components: { GlModal, }, + directives: { + SafeHtml, + }, props: { selector: { type: String, @@ -71,7 +74,8 @@ export default { --> <input type="hidden" name="_method" :value="method" /> <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> - <div>{{ modalAttributes.message }}</div> + <div v-if="modalAttributes.messageHtml" v-safe-html="modalAttributes.messageHtml"></div> + <div v-else>{{ modalAttributes.message }}</div> </form> </gl-modal> </template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue index 494df2d7a37..7e82d8f3f9c 100644 --- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -1,10 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; export default { components: { GlLoadingIcon, + GlIcon, }, props: { isDisabled: { @@ -39,8 +40,10 @@ export default { <slot v-if="$slots.default"></slot> <span v-else class="dropdown-toggle-text"> {{ toggleText }} </span> </template> - <span v-show="!isLoading" class="dropdown-toggle-icon"> - <i class="fa fa-chevron-down" aria-hidden="true" data-hidden="true"></i> - </span> + <gl-icon + v-show="!isLoading" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" + name="chevron-down" + /> </button> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index c1c4f437dee..b4115b0c6a4 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -1,4 +1,5 @@ <script> +import { GlTruncate } from '@gitlab/ui'; import FileHeader from '~/vue_shared/components/file_row_header.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import { escapeFileUrl } from '~/lib/utils/url_utility'; @@ -8,6 +9,7 @@ export default { components: { FileHeader, FileIcon, + GlTruncate, }, props: { file: { @@ -28,6 +30,11 @@ export default { required: false, default: '', }, + truncateMiddle: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isTree() { @@ -134,9 +141,9 @@ export default { <span ref="textOutput" :style="levelIndentation" - class="file-row-name str-truncated" + class="file-row-name" data-qa-selector="file_name_content" - :class="fileClasses" + :class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]" > <file-icon class="file-row-icon" @@ -146,8 +153,10 @@ export default { :folder="isTree" :opened="file.opened" :size="16" + :submodule="file.submodule" /> - {{ file.name }} + <gl-truncate v-if="truncateMiddle" :text="file.name" position="middle" class="gl-pr-7" /> + <template v-else>{{ file.name }}</template> </span> <slot></slot> </div> diff --git a/app/assets/javascripts/vue_shared/components/file_row_header.vue b/app/assets/javascripts/vue_shared/components/file_row_header.vue index 2c3e2a3a433..5afb2408c7e 100644 --- a/app/assets/javascripts/vue_shared/components/file_row_header.vue +++ b/app/assets/javascripts/vue_shared/components/file_row_header.vue @@ -1,25 +1,21 @@ <script> -import { truncatePathMiddleToLength } from '~/lib/utils/text_utility'; - -const MAX_PATH_LENGTH = 40; +import { GlTruncate } from '@gitlab/ui'; export default { + components: { + GlTruncate, + }, props: { path: { type: String, required: true, }, }, - computed: { - truncatedPath() { - return truncatePathMiddleToLength(this.path, MAX_PATH_LENGTH); - }, - }, }; </script> <template> <div class="file-row-header bg-white sticky-top p-2 js-file-row-header" :title="path"> - <span class="bold">{{ truncatedPath }}</span> + <gl-truncate :text="path" position="middle" class="bold" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 25478ad6f4f..97b4ceda033 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -5,6 +5,7 @@ import { GlButton, GlDropdown, GlDropdownItem, + GlFormCheckbox, GlTooltipDirective, } from '@gitlab/ui'; @@ -25,6 +26,7 @@ export default { GlButton, GlDropdown, GlDropdownItem, + GlFormCheckbox, }, directives: { GlTooltip: GlTooltipDirective, @@ -59,10 +61,25 @@ export default { default: '', validator: value => value === '' || /(_desc)|(_asc)/g.test(value), }, + showCheckbox: { + type: Boolean, + required: false, + default: false, + }, + checkboxChecked: { + type: Boolean, + required: false, + default: false, + }, searchInputPlaceholder: { type: String, required: true, }, + suggestionsListClass: { + type: String, + required: false, + default: '', + }, }, data() { let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending; @@ -291,12 +308,19 @@ export default { <template> <div class="vue-filtered-search-bar-container d-md-flex"> + <gl-form-checkbox + v-if="showCheckbox" + class="gl-align-self-center" + :checked="checkboxChecked" + @input="$emit('checked-input', $event)" + /> <gl-filtered-search ref="filteredSearchInput" v-model="filterValue" :placeholder="searchInputPlaceholder" :available-tokens="tokens" :history-items="filteredRecentSearches" + :suggestions-list-class="suggestionsListClass" class="flex-grow-1" @history-item-selected="handleHistoryItemSelected" @clear-history="handleClearHistory" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue index 89952623d0d..c24df5e081d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -65,7 +65,7 @@ export default { .then(({ data }) => { this.milestones = data; }) - .catch(() => createFlash(__('There was a problem fetching milestones.'))) + .catch(() => createFlash({ message: __('There was a problem fetching milestones.') })) .finally(() => { this.loading = false; }); diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index e895a7a52ab..dde7e3ebe13 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -10,6 +10,8 @@ const AutoComplete = { Labels: 'labels', Members: 'members', MergeRequests: 'mergeRequests', + Milestones: 'milestones', + Snippets: 'snippets', }; const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings @@ -120,6 +122,22 @@ const autoCompleteMap = { 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 { @@ -157,8 +175,8 @@ export default { menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate, selectTemplate: ({ original }) => NON_WORD_OR_INTEGER.test(original.title) - ? `~"${original.title}"` - : `~${original.title}`, + ? `~"${escape(original.title)}"` + : `~${escape(original.title)}`, values: this.getValues(AutoComplete.Labels), }, { @@ -168,6 +186,20 @@ export default { 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), + }, ], }); diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue deleted file mode 100644 index 4b91d4c00e3..00000000000 --- a/app/assets/javascripts/vue_shared/components/gl_modal.vue +++ /dev/null @@ -1,6 +0,0 @@ -<script> -// This file was only introduced to not break master and shall be delete soon. -import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; - -export default DeprecatedModal2; -</script> diff --git a/app/assets/javascripts/vue_shared/components/integrations_help_text.vue b/app/assets/javascripts/vue_shared/components/integrations_help_text.vue new file mode 100644 index 00000000000..4939b5aa98c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/integrations_help_text.vue @@ -0,0 +1,35 @@ +<script> +import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; + +export default { + name: 'IntegrationsHelpText', + components: { + GlIcon, + GlLink, + GlSprintf, + }, + props: { + message: { + type: String, + required: true, + }, + messageUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span> + <gl-sprintf :message="message"> + <template #link="{ content }"> + <gl-link :href="messageUrl" target="_blank"> + {{ content }} + <gl-icon name="external-link" class="gl-vertical-align-middle" :size="12" /> + </gl-link> + </template> + </gl-sprintf> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue index 80c03342f11..33e77b6510c 100644 --- a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue +++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue @@ -22,11 +22,21 @@ export default { required: false, default: true, }, + clear: { + type: Boolean, + required: false, + default: false, + }, }, watch: { value(newVal) { this.saveValue(this.serialize(newVal)); }, + clear(newVal) { + if (newVal) { + localStorage.removeItem(this.storageKey); + } + }, }, mounted() { // On mount, trigger update if we actually have a localStorageValue diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 65116ed8ca3..9cfba85e0d8 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -141,10 +141,9 @@ export default { addMultipleToDiscussionWarning() { return sprintf( __( - '%{icon}You are about to add %{usersTag} people to the discussion. They will all receive a notification.', + 'You are about to add %{usersTag} people to the discussion. They will all receive a notification.', ), { - icon: '<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>', usersTag: `<strong><span class="js-referenced-users-count">${this.referencedUsers.length}</span></strong>`, }, false, @@ -175,9 +174,10 @@ export default { issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, epics: this.enableAutocomplete, - milestones: this.enableAutocomplete, + milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - snippets: this.enableAutocomplete, + snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, + vulnerabilities: this.enableAutocomplete, }, true, ); @@ -293,6 +293,7 @@ export default { <template v-if="previewMarkdown && !markdownPreviewLoading"> <div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div> <div v-if="shouldShowReferencedUsers" class="referenced-users"> + <gl-icon name="warning-solid" /> <span v-html="addMultipleToDiscussionWarning"></span> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index fb9636ba734..fb51840b689 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -72,6 +72,9 @@ export default { } return __('Applying suggestions...'); }, + isLoggedIn() { + return Boolean(gon.current_user_id); + }, }, methods: { applySuggestion() { @@ -141,6 +144,7 @@ export default { </gl-button> <span v-gl-tooltip.viewport="tooltipMessage" tabindex="0"> <gl-button + v-if="isLoggedIn" class="btn-inverted js-apply-btn btn-grouped" :disabled="isDisableButton" variant="success" diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 5d47aed9643..5824cb9438f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -61,43 +61,59 @@ export default { <span v-if="canAttachFile" class="uploading-container"> <span class="uploading-progress-container hide"> <template> - <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" /> + <gl-icon name="media" /> </template> <span class="attaching-file-message"></span> <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> <span class="uploading-progress">0%</span> - <gl-loading-icon inline class="align-text-bottom" /> + <gl-loading-icon inline /> </span> <span class="uploading-error-container hide"> <span class="uploading-error-icon"> - <template> - <gl-icon name="media" :size="16" class="gl-vertical-align-text-bottom" /> - </template> + <gl-icon name="media" /> </span> <span class="uploading-error-message"></span> <gl-sprintf :message=" __( - '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}', + '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}.', ) " > <template #retryButton="{content}"> - <button class="retry-uploading-link" type="button">{{ content }}</button> + <gl-button + variant="link" + category="primary" + class="retry-uploading-link gl-vertical-align-baseline" + > + {{ content }} + </gl-button> </template> <template #newFileButton="{content}"> - <button class="attach-new-file markdown-selector" type="button">{{ content }}</button> + <gl-button + variant="link" + category="primary" + class="markdown-selector attach-new-file gl-vertical-align-baseline" + > + {{ content }} + </gl-button> </template> </gl-sprintf> </span> - <gl-button class="markdown-selector button-attach-file" variant="link"> - <template> - <gl-icon name="media" :size="16" /> - </template> - <span class="text-attach-file">{{ __('Attach a file') }}</span> + <gl-button + icon="media" + variant="link" + category="primary" + class="markdown-selector button-attach-file gl-vertical-align-text-bottom" + > + {{ __('Attach a file') }} </gl-button> - <gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link"> + <gl-button + variant="link" + category="primary" + class="button-cancel-uploading-files gl-vertical-align-baseline hide" + > {{ __('Cancel') }} </gl-button> </span> diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue b/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue index 8fa3d439fc1..484dbb8fef5 100644 --- a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue +++ b/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue @@ -6,7 +6,13 @@ import { s__, sprintf } from '~/locale'; export default { name: 'UserActionButtons', - components: { ActionButtonGroup, RemoveMemberButton, LeaveButton }, + components: { + ActionButtonGroup, + RemoveMemberButton, + LeaveButton, + LdapOverrideButton: () => + import('ee_component/vue_shared/components/members/ldap/ldap_override_button.vue'), + }, props: { member: { type: Object, @@ -57,5 +63,8 @@ export default { :title="s__('Member|Remove member')" /> </div> + <div v-else-if="permissions.canOverride && !member.isOverridden" class="gl-px-1"> + <ldap-override-button :member="member" /> + </div> </action-button-group> </template> diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/vue_shared/components/members/constants.js index 6509779053e..5885420a122 100644 --- a/app/assets/javascripts/vue_shared/components/members/constants.js +++ b/app/assets/javascripts/vue_shared/components/members/constants.js @@ -51,6 +51,7 @@ export const FIELDS = [ key: 'actions', thClass: 'col-actions', tdClass: 'col-actions', + showFunction: 'showActionsField', }, ]; diff --git a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue b/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue new file mode 100644 index 00000000000..0a8af81c1d1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue @@ -0,0 +1,99 @@ +<script> +import { GlDatepicker } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import { getDateInFuture } from '~/lib/utils/datetime_utility'; +import { s__ } from '~/locale'; + +export default { + name: 'ExpirationDatepicker', + components: { GlDatepicker }, + props: { + member: { + type: Object, + required: true, + }, + permissions: { + type: Object, + required: true, + }, + }, + data() { + return { + selectedDate: null, + busy: false, + }; + }, + computed: { + minDate() { + // Members expire at the beginning of the day. + // The first selectable day should be tomorrow. + const today = new Date(); + const beginningOfToday = new Date(today.setHours(0, 0, 0, 0)); + + return getDateInFuture(beginningOfToday, 1); + }, + disabled() { + return ( + this.busy || + !this.permissions.canUpdate || + (this.permissions.canOverride && !this.member.isOverridden) + ); + }, + }, + mounted() { + if (this.member.expiresAt) { + this.selectedDate = new Date(this.member.expiresAt); + } + }, + methods: { + ...mapActions(['updateMemberExpiration']), + handleInput(date) { + this.busy = true; + this.updateMemberExpiration({ + memberId: this.member.id, + expiresAt: date, + }) + .then(() => { + this.$toast.show(s__('Members|Expiration date updated successfully.')); + this.busy = false; + }) + .catch(() => { + this.busy = false; + }); + }, + handleClear() { + this.busy = true; + + this.updateMemberExpiration({ + memberId: this.member.id, + expiresAt: null, + }) + .then(() => { + this.$toast.show(s__('Members|Expiration date removed successfully.')); + this.selectedDate = null; + this.busy = false; + }) + .catch(() => { + this.busy = false; + }); + }, + }, +}; +</script> + +<template> + <!-- `:target="null"` allows the datepicker to be opened on focus --> + <!-- `:container="null"` renders the datepicker in the body to prevent conflicting CSS table styles --> + <gl-datepicker + v-model="selectedDate" + class="gl-max-w-full" + show-clear-button + :target="null" + :container="null" + :min-date="minDate" + :placeholder="__('Expiration date')" + :disabled="disabled" + @input="handleInput" + @clear="handleClear" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue index c1a80a85dbe..a4f67caff31 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue @@ -1,6 +1,13 @@ <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 initUserPopovers from '~/user_popovers'; import MemberAvatar from './member_avatar.vue'; @@ -8,9 +15,9 @@ import MemberSource from './member_source.vue'; import CreatedAt from './created_at.vue'; import ExpiresAt from './expires_at.vue'; import MemberActionButtons from './member_action_buttons.vue'; -import MembersTableCell from './members_table_cell.vue'; import RoleDropdown from './role_dropdown.vue'; import RemoveGroupLinkModal from '../modals/remove_group_link_modal.vue'; +import ExpirationDatepicker from './expiration_datepicker.vue'; export default { name: 'MembersTable', @@ -25,23 +32,56 @@ export default { MemberActionButtons, RoleDropdown, RemoveGroupLinkModal, + ExpirationDatepicker, + LdapOverrideConfirmationModal: () => + import( + 'ee_component/vue_shared/components/members/ldap/ldap_override_confirmation_modal.vue' + ), }, computed: { - ...mapState(['members', 'tableFields']), + ...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']), filteredFields() { - return FIELDS.filter(field => this.tableFields.includes(field.key)); + return FIELDS.filter(field => this.tableFields.includes(field.key) && this.showField(field)); + }, + userIsLoggedIn() { + return this.currentUserId !== null; }, }, mounted() { initUserPopovers(this.$el.querySelectorAll('.js-user-link')); }, + methods: { + showField(field) { + if (!Object.prototype.hasOwnProperty.call(field, 'showFunction')) { + return true; + } + + return this[field.showFunction](); + }, + showActionsField() { + if (!this.userIsLoggedIn) { + return false; + } + + return this.members.some(member => { + return ( + canRemove(member, this.sourceId) || + canResend(member) || + canUpdate(member, this.currentUserId, this.sourceId) || + canOverride(member) + ); + }); + }, + }, }; </script> <template> <div> <gl-table + v-bind="tableAttrs.table" class="members-table" + data-testid="members-table" head-variant="white" stacked="lg" :fields="filteredFields" @@ -50,6 +90,7 @@ export default { thead-class="border-bottom" :empty-text="__('No members found')" show-empty + :tbody-tr-attr="tableAttrs.tr" > <template #cell(account)="{ item: member }"> <members-table-cell #default="{ memberType, isCurrentUser }" :member="member"> @@ -85,11 +126,17 @@ export default { <template #cell(maxRole)="{ item: member }"> <members-table-cell #default="{ permissions }" :member="member"> - <role-dropdown v-if="permissions.canUpdate" :member="member" /> + <role-dropdown v-if="permissions.canUpdate" :permissions="permissions" :member="member" /> <gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge> </members-table-cell> </template> + <template #cell(expiration)="{ item: member }"> + <members-table-cell #default="{ permissions }" :member="member"> + <expiration-datepicker :permissions="permissions" :member="member" /> + </members-table-cell> + </template> + <template #cell(actions)="{ item: member }"> <members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member"> <member-action-buttons @@ -106,5 +153,6 @@ export default { </template> </gl-table> <remove-group-link-modal /> + <ldap-override-confirmation-modal /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue index 5602978bb6c..11e1aef9803 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue +++ b/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue @@ -1,6 +1,7 @@ <script> import { mapState } from 'vuex'; import { MEMBER_TYPES } from '../constants'; +import { isGroup, isDirectMember, isCurrentUser, canRemove, canResend, canUpdate } from '../utils'; export default { name: 'MembersTableCell', @@ -13,7 +14,7 @@ export default { computed: { ...mapState(['sourceId', 'currentUserId']), isGroup() { - return Boolean(this.member.sharedWithGroup); + return isGroup(this.member); }, isInvite() { return Boolean(this.member.invite); @@ -33,19 +34,19 @@ export default { return MEMBER_TYPES.user; }, isDirectMember() { - return this.isGroup || this.member.source?.id === this.sourceId; + return isDirectMember(this.member, this.sourceId); }, isCurrentUser() { - return this.member.user?.id === this.currentUserId; + return isCurrentUser(this.member, this.currentUserId); }, canRemove() { - return this.isDirectMember && this.member.canRemove; + return canRemove(this.member, this.sourceId); }, canResend() { - return Boolean(this.member.invite?.canResend); + return canResend(this.member); }, canUpdate() { - return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate; + return canUpdate(this.member, this.currentUserId, this.sourceId); }, }, render() { diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue index 2b40ccc3a9d..6f6cae6072d 100644 --- a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue @@ -9,12 +9,18 @@ export default { components: { GlDropdown, GlDropdownItem, + LdapDropdownItem: () => + import('ee_component/vue_shared/components/members/ldap/ldap_dropdown_item.vue'), }, props: { member: { type: Object, required: true, }, + permissions: { + type: Object, + required: true, + }, }, data() { return { @@ -22,8 +28,21 @@ export default { busy: false, }; }, + computed: { + disabled() { + return this.busy || (this.permissions.canOverride && !this.member.isOverridden); + }, + }, mounted() { this.isDesktop = bp.isDesktop(); + + // Bootstrap Vue and GlDropdown to not support adding attributes to the dropdown toggle + // This can be changed once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1060 is implemented + const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle'); + + if (dropdownToggle) { + dropdownToggle.setAttribute('data-qa-selector', 'access_level_dropdown'); + } }, methods: { ...mapActions(['updateMemberRole']), @@ -52,19 +71,25 @@ export default { <template> <gl-dropdown + ref="glDropdown" :right="!isDesktop" :text="member.accessLevel.stringValue" :header-text="__('Change permissions')" - :disabled="busy" + :disabled="disabled" > <gl-dropdown-item v-for="(value, name) in member.validRoles" :key="value" is-check-item :is-checked="value === member.accessLevel.integerValue" + data-qa-selector="access_level_link" @click="handleSelect(value, name)" > {{ name }} </gl-dropdown-item> + <ldap-dropdown-item + v-if="permissions.canOverride && member.isOverridden" + :member-id="member.id" + /> </gl-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js index 782a0b7f96b..4229a62c0a7 100644 --- a/app/assets/javascripts/vue_shared/components/members/utils.js +++ b/app/assets/javascripts/vue_shared/components/members/utils.js @@ -17,3 +17,32 @@ export const generateBadges = (member, isCurrentUser) => [ variant: 'info', }, ]; + +export const isGroup = member => { + return Boolean(member.sharedWithGroup); +}; + +export const isDirectMember = (member, sourceId) => { + return isGroup(member) || member.source?.id === sourceId; +}; + +export const isCurrentUser = (member, currentUserId) => { + return member.user?.id === currentUserId; +}; + +export const canRemove = (member, sourceId) => { + return isDirectMember(member, sourceId) && member.canRemove; +}; + +export const canResend = member => { + return Boolean(member.invite?.canResend); +}; + +export const canUpdate = (member, currentUserId, sourceId) => { + return ( + !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate + ); +}; + +// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js` +export const canOverride = () => false; diff --git a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue index cad4439ecea..de9c84dd157 100644 --- a/app/assets/javascripts/vue_shared/components/modal_copy_button.vue +++ b/app/assets/javascripts/vue_shared/components/modal_copy_button.vue @@ -1,8 +1,7 @@ <script> -import $ from 'jquery'; import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import Clipboard from 'clipboard'; -import { __ } from '~/locale'; +import { uniqueId } from 'lodash'; export default { components: { @@ -17,6 +16,11 @@ export default { required: false, default: '', }, + id: { + type: String, + required: false, + default: () => uniqueId('modal-copy-button-'), + }, container: { type: String, required: false, @@ -52,7 +56,6 @@ export default { default: null, }, }, - copySuccessText: __('Copied'), computed: { modalDomId() { return this.modalId ? `#${this.modalId}` : ''; @@ -68,11 +71,11 @@ export default { }); this.clipboard .on('success', e => { - this.updateTooltip(e.trigger); + this.$root.$emit('bv::hide::tooltip', this.id); this.$emit('success', e); // Clear the selection and blur the trigger so it loses its border e.clearSelection(); - $(e.trigger).blur(); + e.trigger.blur(); }) .on('error', e => this.$emit('error', e)); }); @@ -82,29 +85,11 @@ export default { this.clipboard.destroy(); } }, - methods: { - updateTooltip(target) { - const $target = $(target); - const originalTitle = $target.data('originalTitle'); - - if ($target.tooltip) { - /** - * The original tooltip will continue staying there unless we remove it by hand. - * $target.tooltip('hide') isn't working. - */ - $('.tooltip').remove(); - $target.attr('title', this.$options.copySuccessText); - $target.tooltip('_fixTitle'); - $target.tooltip('show'); - $target.attr('title', originalTitle); - $target.tooltip('_fixTitle'); - } - }, - }, }; </script> <template> <gl-button + :id="id" v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }" :class="cssClasses" :data-clipboard-target="target" diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue index 8e85d93e6d1..1fc39c7cb8e 100644 --- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue @@ -308,6 +308,6 @@ export default { @input="handlePageChange" /> - <slot v-if="!showItems" name="emtpy-state"></slot> + <slot v-if="!showItems" name="empty-state"></slot> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue index 06b4309ad42..4d47a34c9a3 100644 --- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue +++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue @@ -30,8 +30,13 @@ export default { metadataSlots: [], }; }, - mounted() { - this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith('metadata-')); + async mounted() { + const METADATA_PREFIX = 'metadata-'; + this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith(METADATA_PREFIX)); + + // we need to wait for next tick to ensure that dynamic names slots are picked up + await this.$nextTick(); + this.metadataSlots = Object.keys(this.$slots).filter(k => k.startsWith(METADATA_PREFIX)); }, }; </script> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue index e1652f54982..82060d2e4ad 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue @@ -1,8 +1,7 @@ <script> import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui'; -import { isSafeURL } from '~/lib/utils/url_utility'; +import { isSafeURL, joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IMAGE_TABS } from '../../constants'; import UploadImageTab from './upload_image_tab.vue'; @@ -15,7 +14,6 @@ export default { GlTabs, GlTab, }, - mixins: [glFeatureFlagMixin()], props: { imageRoot: { type: String, @@ -34,10 +32,10 @@ export default { }, modalTitle: __('Image details'), okTitle: __('Insert image'), - urlTabTitle: __('By URL'), + urlTabTitle: __('Link to an image'), urlLabel: __('Image URL'), descriptionLabel: __('Description'), - uploadTabTitle: __('Upload file'), + uploadTabTitle: __('Upload an image'), computed: { altText() { return this.description; @@ -54,7 +52,7 @@ export default { this.$refs.modal.show(); }, onOk(event) { - if (this.glFeatures.sseImageUploads && this.tabIndex === IMAGE_TABS.UPLOAD_TAB) { + if (this.tabIndex === IMAGE_TABS.UPLOAD_TAB) { this.submitFile(event); return; } @@ -74,7 +72,7 @@ export default { return; } - const imageUrl = `${this.imageRoot}${file.name}`; + const imageUrl = joinPaths(this.imageRoot, file.name); this.$emit('addImage', { imageUrl, file, altText: altText || file.name }); }, @@ -108,7 +106,7 @@ export default { :ok-title="$options.okTitle" @ok="onOk" > - <gl-tabs v-if="glFeatures.sseImageUploads" v-model="tabIndex"> + <gl-tabs v-model="tabIndex"> <!-- Upload file Tab --> <gl-tab :title="$options.uploadTabTitle"> <upload-image-tab ref="uploadImageTab" @input="setFile" /> @@ -128,17 +126,6 @@ export default { </gl-tab> </gl-tabs> - <gl-form-group - v-else - class="gl-mt-5 gl-mb-3" - :label="$options.urlLabel" - label-for="url-input" - :state="!Boolean(urlError)" - :invalid-feedback="urlError" - > - <gl-form-input id="url-input" ref="urlInput" v-model="imageUrl" /> - </gl-form-group> - <!-- Description Input --> <gl-form-group :label="$options.descriptionLabel" label-for="description-input"> <gl-form-input id="description-input" ref="descriptionInput" v-model="description" /> diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index c2518441506..9eacf74bba8 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -53,7 +53,6 @@ export default { imageRoot: { type: String, required: true, - validator: prop => prop.endsWith('/'), }, }, data() { @@ -115,10 +114,9 @@ export default { if (file) { this.$emit('uploadImage', { file, imageUrl }); - // TODO - ensure that the actual repo URL for the image is used in Markdown mode } - addImage(this.editorInstance, image); + addImage(this.editorInstance, image, file); }, onOpenInsertVideoModal() { this.$refs.insertVideoModal.show(); diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js index 2bce691e793..9744e25a8e1 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js @@ -99,6 +99,10 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => ? `\n\n${node.innerText}\n\n` : baseRenderer.convert(node, subContent); }, + IMG(node) { + const { originalSrc } = node.dataset; + return `![${node.alt}](${originalSrc || node.src})`; + }, }; }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js index 8b3fbcabcfa..463e64b4936 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -34,6 +34,20 @@ const buildVideoIframe = src => { return wrapper; }; +const buildImg = (alt, originalSrc, file) => { + const img = document.createElement('img'); + const src = file ? URL.createObjectURL(file) : originalSrc; + const attributes = { alt, src }; + + if (file) { + img.dataset.originalSrc = originalSrc; + } + + Object.assign(img, attributes); + + return img; +}; + export const generateToolbarItem = config => { const { icon, classes, event, command, tooltip, isDivider } = config; @@ -59,7 +73,14 @@ export const addCustomEventListener = (editorApi, event, handler) => { export const removeCustomEventListener = (editorApi, event, handler) => editorApi.eventManager.removeEventHandler(event, handler); -export const addImage = ({ editor }, image) => editor.exec('AddImage', image); +export const addImage = ({ editor }, { altText, imageUrl }, file) => { + if (editor.isWysiwygMode()) { + const img = buildImg(altText, imageUrl, file); + editor.getSquire().insertElement(img); + } else { + editor.insertText(`![${altText}](${imageUrl})`); + } +}; export const insertVideo = ({ editor }, url) => { const videoIframe = buildVideoIframe(url); diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql new file mode 100644 index 00000000000..ff0626167a9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql @@ -0,0 +1,20 @@ +query getRunnerPlatforms($projectPath: ID!, $groupPath: ID!) { + runnerPlatforms { + nodes { + name + humanReadableName + architectures { + nodes { + name + downloadLocation + } + } + } + } + project(fullPath: $projectPath) { + id + } + group(fullPath: $groupPath) { + id + } +} diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql new file mode 100644 index 00000000000..643c1991807 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql @@ -0,0 +1,16 @@ +query runnerSetupInstructions( + $platform: String! + $architecture: String! + $projectId: ID! + $groupId: ID! +) { + runnerSetup( + platform: $platform + architecture: $architecture + projectId: $projectId + groupId: $groupId + ) { + installInstructions + registerInstructions + } +} diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue new file mode 100644 index 00000000000..b70b1277155 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue @@ -0,0 +1,220 @@ +<script> +import { + GlAlert, + GlButton, + GlModal, + GlModalDirective, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlIcon, +} from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import getRunnerPlatforms from './graphql/queries/get_runner_platforms.query.graphql'; +import getRunnerSetupInstructions from './graphql/queries/get_runner_setup.query.graphql'; + +export default { + components: { + GlAlert, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlModal, + GlIcon, + }, + directives: { + GlModalDirective, + }, + inject: { + projectPath: { + default: '', + }, + groupPath: { + default: '', + }, + }, + apollo: { + runnerPlatforms: { + query: getRunnerPlatforms, + variables() { + return { + projectPath: this.projectPath, + groupPath: this.groupPath, + }; + }, + update(data) { + return data; + }, + error() { + this.showAlert = true; + }, + }, + }, + data() { + return { + showAlert: false, + selectedPlatformArchitectures: [], + selectedPlatform: {}, + selectedArchitecture: {}, + runnerPlatforms: {}, + instructions: {}, + }; + }, + computed: { + isPlatformSelected() { + return Object.keys(this.selectedPlatform).length > 0; + }, + instructionsEmpty() { + return this.instructions && Object.keys(this.instructions).length === 0; + }, + groupId() { + return this.runnerPlatforms?.group?.id ?? ''; + }, + projectId() { + return this.runnerPlatforms?.project?.id ?? ''; + }, + platforms() { + return this.runnerPlatforms.runnerPlatforms?.nodes; + }, + }, + methods: { + selectPlatform(name) { + this.selectedPlatform = this.platforms.find(platform => platform.name === name); + this.selectedPlatformArchitectures = this.selectedPlatform?.architectures?.nodes; + [this.selectedArchitecture] = this.selectedPlatformArchitectures; + this.selectArchitecture(this.selectedArchitecture); + }, + selectArchitecture(architecture) { + this.selectedArchitecture = architecture; + + this.$apollo.addSmartQuery('instructions', { + variables() { + return { + platform: this.selectedPlatform.name, + architecture: this.selectedArchitecture.name, + projectId: this.projectId, + groupId: this.groupId, + }; + }, + query: getRunnerSetupInstructions, + update(data) { + return data?.runnerSetup; + }, + error() { + this.showAlert = true; + }, + }); + }, + toggleAlert(state) { + this.showAlert = state; + }, + }, + modalId: 'installation-instructions-modal', + i18n: { + installARunner: __('Install a Runner'), + architecture: s__('Runners|Architecture'), + downloadInstallBinary: s__('Runners|Download and Install Binary'), + downloadLatestBinary: s__('Runners|Download Latest Binary'), + registerRunner: s__('Runners|Register Runner'), + method: __('Method'), + fetchError: s__('An error has occurred fetching instructions'), + instructions: __('Show Runner installation instructions'), + }, + closeButton: { + text: __('Close'), + attributes: [{ variant: 'default' }], + }, +}; +</script> +<template> + <div> + <gl-button v-gl-modal-directive="$options.modalId" data-testid="show-modal-button"> + {{ $options.i18n.instructions }} + </gl-button> + <gl-modal + :modal-id="$options.modalId" + :title="$options.i18n.installARunner" + :action-secondary="$options.closeButton" + > + <gl-alert v-if="showAlert" variant="danger" @dismiss="toggleAlert(false)"> + {{ $options.i18n.fetchError }} + </gl-alert> + <h5>{{ __('Environment') }}</h5> + <gl-button-group class="gl-mb-5"> + <gl-button + v-for="platform in platforms" + :key="platform.name" + data-testid="platform-button" + @click="selectPlatform(platform.name)" + > + {{ platform.humanReadableName }} + </gl-button> + </gl-button-group> + <template v-if="isPlatformSelected"> + <h5> + {{ $options.i18n.architecture }} + </h5> + <gl-dropdown class="gl-mb-5" :text="selectedArchitecture.name"> + <gl-dropdown-item + v-for="architecture in selectedPlatformArchitectures" + :key="architecture.name" + data-testid="architecture-dropdown-item" + @click="selectArchitecture(architecture)" + > + {{ architecture.name }} + </gl-dropdown-item> + </gl-dropdown> + <div class="gl-display-flex gl-align-items-center gl-mb-5"> + <h5>{{ $options.i18n.downloadInstallBinary }}</h5> + <gl-button + class="gl-ml-auto" + :href="selectedArchitecture.downloadLocation" + download + data-testid="binary-download-button" + > + {{ $options.i18n.downloadLatestBinary }} + </gl-button> + </div> + </template> + <template v-if="!instructionsEmpty"> + <div class="gl-display-flex"> + <pre + class="bg-light gl-flex-fill-1 gl-white-space-pre-line" + data-testid="binary-instructions" + > + {{ instructions.installInstructions }} + </pre> + <gl-button + class="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + variant="link" + :data-clipboard-text="instructions.installationInstructions" + > + <gl-icon name="copy-to-clipboard" /> + </gl-button> + </div> + + <hr /> + <h5 class="gl-mb-5">{{ $options.i18n.registerRunner }}</h5> + <h5 class="gl-mb-5">{{ $options.i18n.method }}</h5> + <div class="gl-display-flex"> + <pre + class="bg-light gl-flex-fill-1 gl-white-space-pre-line" + data-testid="runner-instructions" + > + {{ instructions.registerInstructions }} + </pre> + <gl-button + class="gl-align-self-start gl-ml-2 gl-mt-2" + category="tertiary" + variant="link" + :data-clipboard-text="instructions.registerInstructions" + > + <gl-icon name="copy-to-clipboard" /> + </gl-button> + </div> + </template> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue new file mode 100644 index 00000000000..1d3bd312b09 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/issuable_move_dropdown.vue @@ -0,0 +1,211 @@ +<script> +import { + GlIcon, + GlLoadingIcon, + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlSearchBoxByType, + GlButton, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; + +import axios from '~/lib/utils/axios_utils'; + +export default { + components: { + GlIcon, + GlLoadingIcon, + GlDropdown, + GlDropdownForm, + GlDropdownItem, + GlSearchBoxByType, + GlButton, + }, + directives: { + GlTooltip, + }, + props: { + projectsFetchPath: { + type: String, + required: true, + }, + dropdownButtonTitle: { + type: String, + required: true, + }, + dropdownHeaderTitle: { + type: String, + required: true, + }, + moveInProgress: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + projectsListLoading: false, + projectsListLoadFailed: false, + searchKey: '', + projects: [], + selectedProject: null, + projectItemClick: false, + }; + }, + computed: { + hasNoSearchResults() { + return Boolean( + !this.projectsListLoading && + !this.projectsListLoadFailed && + this.searchKey && + !this.projects.length, + ); + }, + failedToLoadResults() { + return !this.projectsListLoading && this.projectsListLoadFailed; + }, + }, + watch: { + searchKey(value = '') { + this.fetchProjects(value); + }, + }, + methods: { + fetchProjects(search = '') { + this.projectsListLoading = true; + this.projectsListLoadFailed = false; + return axios + .get(this.projectsFetchPath, { + params: { + search, + }, + }) + .then(({ data }) => { + this.projects = data; + this.$refs.searchInput.focusInput(); + }) + .catch(() => { + this.projectsListLoadFailed = true; + }) + .finally(() => { + this.projectsListLoading = false; + }); + }, + isSelectedProject(project) { + if (this.selectedProject) { + return this.selectedProject.id === project.id; + } + return false; + }, + /** + * This handler is to prevent dropdown + * from closing when an item is selected + * and emit an event only when dropdown closes. + */ + handleDropdownHide(e) { + if (this.projectItemClick) { + e.preventDefault(); + this.projectItemClick = false; + } else { + this.$emit('dropdown-close'); + } + }, + handleDropdownCloseClick() { + this.$refs.dropdown.hide(); + }, + handleProjectSelect(project) { + this.selectedProject = project.id === this.selectedProject?.id ? null : project; + this.projectItemClick = true; + }, + handleMoveClick() { + this.$refs.dropdown.hide(); + this.$emit('move-issuable', this.selectedProject); + }, + }, +}; +</script> + +<template> + <div class="block js-issuable-move-block issuable-move-dropdown sidebar-move-issue-dropdown"> + <div + v-gl-tooltip.left.viewport + data-testid="move-collapsed" + :title="dropdownButtonTitle" + class="sidebar-collapsed-icon" + @click="$emit('toggle-collapse')" + > + <gl-icon name="arrow-right" /> + </div> + <gl-dropdown + ref="dropdown" + :block="true" + :disabled="moveInProgress" + class="hide-collapsed" + toggle-class="js-sidebar-dropdown-toggle" + @shown="fetchProjects" + @hide="handleDropdownHide" + > + <template #button-content + ><gl-loading-icon v-if="moveInProgress" class="gl-mr-3" />{{ + dropdownButtonTitle + }}</template + > + <gl-dropdown-form class="gl-pt-0"> + <div + data-testid="header" + class="gl-display-flex gl-pb-3 gl-border-1 gl-border-b-solid gl-border-gray-100" + > + <span class="gl-flex-grow-1 gl-text-center gl-font-weight-bold gl-py-1">{{ + dropdownHeaderTitle + }}</span> + <gl-button + variant="link" + icon="close" + class="gl-mr-2 gl-w-auto! gl-p-2!" + @click.prevent="handleDropdownCloseClick" + /> + </div> + <gl-search-box-by-type + ref="searchInput" + v-model.trim="searchKey" + :placeholder="__('Search project')" + :debounce="300" + /> + <div data-testid="content" class="dropdown-content"> + <gl-loading-icon v-if="projectsListLoading" size="md" class="gl-p-5" /> + <ul v-else> + <gl-dropdown-item + v-for="project in projects" + :key="project.id" + :is-check-item="true" + :is-checked="isSelectedProject(project)" + @click.stop.prevent="handleProjectSelect(project)" + >{{ project.name_with_namespace }}</gl-dropdown-item + > + </ul> + <div v-if="hasNoSearchResults" class="gl-text-center gl-p-3"> + {{ __('No matching results') }} + </div> + <div v-if="failedToLoadResults" class="gl-text-center gl-p-3"> + {{ __('Failed to load projects') }} + </div> + </div> + <div + data-testid="footer" + class="gl-pt-3 gl-px-3 gl-border-1 gl-border-t-solid gl-border-gray-100" + > + <gl-button + category="primary" + variant="success" + :disabled="!Boolean(selectedProject)" + class="gl-text-center! issuable-move-button" + @click="handleMoveClick" + >{{ __('Move') }}</gl-button + > + </div> + </gl-dropdown-form> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue index c2ebf78d541..973cc314ee3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue @@ -1,11 +1,10 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; export default { directives: { - tooltip, + GlTooltip: GlTooltipDirective, }, components: { GlIcon, @@ -45,12 +44,9 @@ export default { <template> <div - v-tooltip + v-gl-tooltip.left.viewport :title="labelsList" class="sidebar-collapsed-icon" - data-placement="left" - data-container="body" - data-boundary="viewport" @click="handleClick" > <gl-icon name="labels" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index 353dee862d0..a365673f7a1 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -92,6 +92,13 @@ export default { } } }, + handleComponentAppear() { + // We can avoid putting `catch` block here + // as failure is handled within actions.js already. + return this.fetchLabels().then(() => { + this.$refs.searchInput.focusInput(); + }); + }, /** * We want to remove loaded labels to ensure component * fetches fresh set of labels every time when shown. @@ -139,7 +146,7 @@ export default { </script> <template> - <gl-intersection-observer @appear="fetchLabels" @disappear="handleComponentDisappear"> + <gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear"> <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" @@ -158,8 +165,8 @@ export default { </div> <div class="dropdown-input" @click.stop="() => {}"> <gl-search-box-by-type + ref="searchInput" v-model="searchKey" - :autofocus="true" :disabled="labelsFetchInProgress" data-qa-selector="dropdown_input_field" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index e624bd1eaee..14b46c1c431 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -20,7 +20,7 @@ export const receiveLabelsFailure = ({ commit }) => { }; export const fetchLabels = ({ state, dispatch }) => { dispatch('requestLabels'); - axios + return axios .get(state.labelsFetchPath) .then(({ data }) => { dispatch('receiveLabelsSuccess', data); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue new file mode 100644 index 00000000000..c5bbe1b33fb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue @@ -0,0 +1,29 @@ +<script> +import { GlDropdown, GlDropdownForm } from '@gitlab/ui'; + +export default { + components: { + GlDropdownForm, + GlDropdown, + }, + props: { + headerText: { + type: String, + required: true, + }, + text: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <gl-dropdown class="show" :text="text" :header-text="headerText"> + <slot name="search"></slot> + <gl-dropdown-form> + <slot name="items"></slot> + </gl-dropdown-form> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql new file mode 100644 index 00000000000..612a0c02e82 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql @@ -0,0 +1,13 @@ +query issueParticipants($id: IssueID!) { + issue(id: $id) { + participants { + nodes { + username + name + webUrl + avatarUrl + id + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql new file mode 100644 index 00000000000..9ead95a3801 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql @@ -0,0 +1,17 @@ +mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $projectPath: ID!) { + issueSetAssignees( + input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath } + ) { + issue { + assignees { + nodes { + username + id + name + webUrl + avatarUrl + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue index f2e9c4a4fbb..9b6d0a87374 100644 --- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue +++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue @@ -1,7 +1,7 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import { roundOffFloat } from '~/lib/utils/common_utils'; +import { roundDownFloat } from '~/lib/utils/common_utils'; export default { directives: { @@ -89,7 +89,7 @@ export default { return 0; } - const percent = roundOffFloat((count / this.totalCount) * 100, 1); + const percent = roundDownFloat((count / this.totalCount) * 100, 1); if (percent > 0 && percent < 1) { return '< 1'; } diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js b/app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js new file mode 100644 index 00000000000..85414704cb6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/constants.js @@ -0,0 +1,9 @@ +// We may wish to make this more restrictive, as per +// https://gitlab.com/gitlab-org/gitlab/issues/118611 +export const VALID_IMAGE_FILE_MIMETYPE = { + mimetype: 'image/*', + regex: /image\/.+/, +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types +export const VALID_DATA_TRANSFER_TYPE = 'Files'; diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue index 6694b0dab8d..b645758d891 100644 --- a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue @@ -1,10 +1,8 @@ <script> import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql'; -import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages'; -import { isValidDesignFile } from '../../utils/design_management_utils'; -import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; +import { __ } from '~/locale'; +import { isValidImage } from './utils'; +import { VALID_DATA_TRANSFER_TYPE, VALID_IMAGE_FILE_MIMETYPE } from './constants'; export default { components: { @@ -13,15 +11,31 @@ export default { GlSprintf, }, props: { - hasDesigns: { + displayAsCard: { type: Boolean, - required: true, + required: false, + default: false, }, - isDraggingDesign: { + enableDragBehavior: { type: Boolean, required: false, default: false, }, + dropToStartMessage: { + type: String, + required: false, + default: __('Drop your files to start your upload.'), + }, + isFileValid: { + type: Function, + required: false, + default: isValidImage, + }, + validFileMimetypes: { + type: Array, + required: false, + default: () => [VALID_IMAGE_FILE_MIMETYPE.mimetype], + }, }, data() { return { @@ -35,14 +49,17 @@ export default { }, iconStyles() { return { - size: this.hasDesigns ? 24 : 16, - class: this.hasDesigns ? 'gl-mb-2' : 'gl-mr-3 gl-text-gray-500', + size: this.displayAsCard ? 24 : 16, + class: this.displayAsCard ? 'gl-mb-2' : 'gl-mr-3 gl-text-gray-500', }; }, + validMimeTypeString() { + return this.validFileMimetypes.join(); + }, }, methods: { isValidUpload(files) { - return files.every(isValidDesignFile); + return files.every(this.isFileValid); }, isValidDragDataType({ dataTransfer }) { return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE)); @@ -56,7 +73,7 @@ export default { const { files } = dataTransfer; if (!this.isValidUpload(Array.from(files))) { - createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR); + this.$emit('error'); return; } @@ -72,12 +89,10 @@ export default { openFileUpload() { this.$refs.fileUpload.click(); }, - onDesignInputChange(e) { + onFileInputChange(e) { this.$emit('change', e.target.files); }, }, - uploadDesignMutation, - VALID_DESIGN_FILE_MIMETYPE, }; </script> @@ -93,23 +108,25 @@ export default { > <slot> <button - class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" @click="openFileUpload" > <div - :class="{ 'gl-flex-direction-column': hasDesigns }" + :class="{ 'gl-flex-direction-column': displayAsCard }" class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center" data-testid="dropzone-area" > <gl-icon name="upload" :size="iconStyles.size" :class="iconStyles.class" /> <p class="gl-mb-0"> - <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} designs to attach')"> - <template #link="{ content }"> - <gl-link @click.stop="openFileUpload"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> + <slot name="upload-text" :openFileUpload="openFileUpload"> + <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} files to attach')"> + <template #link="{ content }"> + <gl-link @click.stop="openFileUpload"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </slot> </p> </div> </button> @@ -117,29 +134,37 @@ export default { <input ref="fileUpload" type="file" - name="design_file" - :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype" + name="upload_file" + :accept="validFileMimetypes" class="hide" multiple - @change="onDesignInputChange" + @change="onFileInputChange" /> </slot> - <transition name="design-dropzone-fade"> + <transition name="upload-dropzone-fade"> <div - v-show="dragging && !isDraggingDesign" - class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" + v-show="dragging && !enableDragBehavior" + class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white" > <div v-show="!isDragDataValid" class="mw-50 gl-text-center"> - <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Oh no!') }}</h3> - <span>{{ - __( - 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.', - ) - }}</span> + <slot name="invalid-drag-data-slot"> + <h3 :class="{ 'gl-font-base gl-display-inline': !displayAsCard }"> + {{ __('Oh no!') }} + </h3> + <span>{{ + __( + 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.', + ) + }}</span> + </slot> </div> <div v-show="isDragDataValid" class="mw-50 gl-text-center"> - <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Incoming!') }}</h3> - <span>{{ __('Drop your designs to start your upload.') }}</span> + <slot name="valid-drag-data-slot"> + <h3 :class="{ 'gl-font-base gl-display-inline': !displayAsCard }"> + {{ __('Incoming!') }} + </h3> + <span>{{ dropToStartMessage }}</span> + </slot> </div> </div> </transition> diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js b/app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js new file mode 100644 index 00000000000..cf51a570d46 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/utils.js @@ -0,0 +1,4 @@ +import { VALID_IMAGE_FILE_MIMETYPE } from './constants'; + +export const isValidImage = ({ type }) => + (type.match(VALID_IMAGE_FILE_MIMETYPE.regex) || []).length > 0; diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index 3f5738b2b93..2ab4c55d9b0 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -6,6 +6,7 @@ import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlIcon, } from '@gitlab/ui'; +import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue'; import UserAvatarImage from '../user_avatar/user_avatar_image.vue'; import { glEmojiTag } from '../../../emoji'; @@ -25,6 +26,7 @@ export default { GlPopover, GlSkeletonLoading, UserAvatarImage, + UserAvailabilityStatus, }, props: { target: { @@ -63,6 +65,9 @@ export default { websiteUrl.length ); }, + availabilityStatus() { + return this.user?.status?.availability || null; + }, }, }; </script> @@ -89,6 +94,10 @@ export default { <div class="gl-mb-3"> <h5 class="gl-m-0"> {{ user.name }} + <user-availability-status + v-if="availabilityStatus" + :availability="availabilityStatus" + /> </h5> <span class="gl-text-gray-500">@{{ user.username }}</span> </div> diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 877414519f7..dbb1a075e76 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -169,7 +169,7 @@ export default { </script> <template> - <div class="d-inline-block gl-ml-3"> + <div class="gl-sm-ml-3"> <actions-button :actions="actions" :selected-key="selection" diff --git a/app/assets/javascripts/vue_shared/directives/validation.js b/app/assets/javascripts/vue_shared/directives/validation.js new file mode 100644 index 00000000000..09bec78edcc --- /dev/null +++ b/app/assets/javascripts/vue_shared/directives/validation.js @@ -0,0 +1,132 @@ +import { merge } from 'lodash'; +import { s__ } from '~/locale'; + +/** + * Validation messages will take priority based on the property order. + * + * For example: + * { valueMissing: {...}, urlTypeMismatch: {...} } + * + * `valueMissing` will be displayed the user has entered a value + * after that, if the input is not a valid URL then `urlTypeMismatch` will show + */ +const defaultFeedbackMap = { + valueMissing: { + isInvalid: el => el.validity?.valueMissing, + message: s__('Please fill out this field.'), + }, + urlTypeMismatch: { + isInvalid: el => el.type === 'url' && el.validity?.typeMismatch, + message: s__('Please enter a valid URL format, ex: http://www.example.com/home'), + }, +}; + +const getFeedbackForElement = (feedbackMap, el) => + Object.values(feedbackMap).find(f => f.isInvalid(el))?.message || el.validationMessage; + +const focusFirstInvalidInput = e => { + const { target: formEl } = e; + const invalidInput = formEl.querySelector('input:invalid'); + + if (invalidInput) { + invalidInput.focus(); + } +}; + +const isEveryFieldValid = form => Object.values(form.fields).every(({ state }) => state === true); + +const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => { + const { form } = context; + const { name } = el; + + if (!name) { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.warn( + '[gitlab] the validation directive requires the given input to have "name" attribute', + ); + } + return; + } + + const formField = form.fields[name]; + const isValid = el.checkValidity(); + + // This makes sure we always report valid fields - this can be useful for cases where the consuming + // component's logic depends on certain fields being in a valid state. + // Invalid input, on the other hand, should only be reported once we want to display feedback to the user. + // (eg.: After a field has been touched and moved away from, a submit-button has been clicked, ...) + formField.state = reportInvalidInput ? isValid : isValid || null; + formField.feedback = reportInvalidInput ? getFeedbackForElement(feedbackMap, el) : ''; + + form.state = isEveryFieldValid(form); +}; + +/** + * Takes an object that allows to add or change custom feedback messages. + * + * The passed in object will be merged with the built-in feedback + * so it is possible to override a built-in message. + * + * @example + * validate({ + * tooLong: { + * check: el => el.validity.tooLong === true, + * message: 'Your custom feedback' + * } + * }) + * + * @example + * validate({ + * valueMissing: { + * message: 'Your custom feedback' + * } + * }) + * + * @param {Object<string, { message: string, isValid: ?function}>} customFeedbackMap + * @returns {{ inserted: function, update: function }} validateDirective + */ +export default function(customFeedbackMap = {}) { + const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap); + const elDataMap = new WeakMap(); + + return { + inserted(el, binding, { context }) { + const { arg: showGlobalValidation } = binding; + const { form: formEl } = el; + + const validate = createValidator(context, feedbackMap); + const elData = { validate, isTouched: false, isBlurred: false }; + + elDataMap.set(el, elData); + + el.addEventListener('input', function markAsTouched() { + elData.isTouched = true; + // once the element has been marked as touched we can stop listening on the 'input' event + el.removeEventListener('input', markAsTouched); + }); + + el.addEventListener('blur', function markAsBlurred({ target }) { + if (elData.isTouched) { + elData.isBlurred = true; + validate({ el: target, reportInvalidInput: true }); + // this event handler can be removed, since the live-feedback in `update` takes over + el.removeEventListener('blur', markAsBlurred); + } + }); + + if (formEl) { + formEl.addEventListener('submit', focusFirstInvalidInput); + } + + validate({ el, reportInvalidInput: showGlobalValidation }); + }, + update(el, binding) { + const { arg: showGlobalValidation } = binding; + const { validate, isTouched, isBlurred } = elDataMap.get(el); + const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred); + + validate({ el, reportInvalidInput: showValidationFeedback }); + }, + }; +} diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js index c0fc055a01b..56da2637825 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -1,7 +1,6 @@ import { isEmpty } from 'lodash'; import { sprintf, __ } from '~/locale'; import { formatDate } from '~/lib/utils/datetime_utility'; -import tooltip from '~/vue_shared/directives/tooltip'; import timeagoMixin from '~/vue_shared/mixins/timeago'; const mixins = { @@ -99,9 +98,6 @@ const mixins = { default: () => ({}), }, }, - directives: { - tooltip, - }, mixins: [timeagoMixin], computed: { hasState() { diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js new file mode 100644 index 00000000000..2f87c4e7878 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -0,0 +1,3 @@ +export const FEEDBACK_TYPE_DISMISSAL = 'dismissal'; +export const FEEDBACK_TYPE_ISSUE = 'issue'; +export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request'; 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 d5696e3c8cf..89253cc7116 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 @@ -3,6 +3,7 @@ import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; import ReportSection from '~/reports/components/report_section.vue'; import { status } from '~/reports/constants'; import { s__ } from '~/locale'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import Flash from '~/flash'; import Api from '~/api'; @@ -52,12 +53,27 @@ export default { }); }, methods: { - checkHasSecurityReports(reportTypes) { - return Api.pipelineJobs(this.projectId, this.pipelineId).then(({ data: jobs }) => - jobs.some(({ artifacts = [] }) => + async checkHasSecurityReports(reportTypes) { + let page = 1; + while (page) { + // eslint-disable-next-line no-await-in-loop + const { data: jobs, headers } = await Api.pipelineJobs(this.projectId, this.pipelineId, { + per_page: 100, + page, + }); + + const hasSecurityReports = jobs.some(({ artifacts = [] }) => artifacts.some(({ file_type }) => reportTypes.includes(file_type)), - ), - ); + ); + + if (hasSecurityReports) { + return true; + } + + page = parseIntPagination(normalizeHeaders(headers)).nextPage; + } + + return false; }, activatePipelinesTab() { if (window.mrTabs) { diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js new file mode 100644 index 00000000000..22a45341c51 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/actions.js @@ -0,0 +1,24 @@ +import * as types from './mutation_types'; +import { fetchDiffData } from '../../utils'; + +export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path); + +export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF); + +export const receiveDiffSuccess = ({ commit }, response) => + commit(types.RECEIVE_DIFF_SUCCESS, response); + +export const receiveDiffError = ({ commit }, response) => + commit(types.RECEIVE_DIFF_ERROR, response); + +export const fetchDiff = ({ state, rootState, dispatch }) => { + dispatch('requestDiff'); + + return fetchDiffData(rootState, state.paths.diffEndpoint, 'sast') + .then(data => { + dispatch('receiveDiffSuccess', data); + }) + .catch(() => { + dispatch('receiveDiffError'); + }); +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js new file mode 100644 index 00000000000..68c81bb4509 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; + +export default { + namespaced: true, + state, + mutations, + actions, +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js new file mode 100644 index 00000000000..aacec0fb679 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutation_types.js @@ -0,0 +1,4 @@ +export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS'; +export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR'; +export const REQUEST_DIFF = 'REQUEST_DIFF'; +export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js new file mode 100644 index 00000000000..5f6153ca3b1 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/mutations.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import * as types from './mutation_types'; +import { parseDiff } from '../../utils'; + +export default { + [types.SET_DIFF_ENDPOINT](state, path) { + Vue.set(state.paths, 'diffEndpoint', path); + }, + + [types.REQUEST_DIFF](state) { + state.isLoading = true; + }, + + [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) { + const { added, fixed, existing } = parseDiff(diff, enrichData); + const baseReportOutofDate = diff.base_report_out_of_date || false; + const hasBaseReport = Boolean(diff.base_report_created_at); + + state.isLoading = false; + state.newIssues = added; + state.resolvedIssues = fixed; + state.allIssues = existing; + state.baseReportOutofDate = baseReportOutofDate; + state.hasBaseReport = hasBaseReport; + }, + + [types.RECEIVE_DIFF_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js new file mode 100644 index 00000000000..e860e3af924 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js @@ -0,0 +1,16 @@ +export default () => ({ + paths: { + head: null, + base: null, + diffEndpoint: null, + }, + + isLoading: false, + hasError: false, + + newIssues: [], + resolvedIssues: [], + allIssues: [], + baseReportOutofDate: false, + hasBaseReport: false, +}); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js new file mode 100644 index 00000000000..c9da824613d --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/actions.js @@ -0,0 +1,24 @@ +import { fetchDiffData } from '../../utils'; +import * as types from './mutation_types'; + +export const setDiffEndpoint = ({ commit }, path) => commit(types.SET_DIFF_ENDPOINT, path); + +export const requestDiff = ({ commit }) => commit(types.REQUEST_DIFF); + +export const receiveDiffSuccess = ({ commit }, response) => + commit(types.RECEIVE_DIFF_SUCCESS, response); + +export const receiveDiffError = ({ commit }, response) => + commit(types.RECEIVE_DIFF_ERROR, response); + +export const fetchDiff = ({ state, rootState, dispatch }) => { + dispatch('requestDiff'); + + return fetchDiffData(rootState, state.paths.diffEndpoint, 'secret_detection') + .then(data => { + dispatch('receiveDiffSuccess', data); + }) + .catch(() => { + dispatch('receiveDiffError'); + }); +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js new file mode 100644 index 00000000000..68c81bb4509 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/index.js @@ -0,0 +1,10 @@ +import state from './state'; +import mutations from './mutations'; +import * as actions from './actions'; + +export default { + namespaced: true, + state, + mutations, + actions, +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js new file mode 100644 index 00000000000..aacec0fb679 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutation_types.js @@ -0,0 +1,4 @@ +export const RECEIVE_DIFF_SUCCESS = 'RECEIVE_DIFF_SUCCESS'; +export const RECEIVE_DIFF_ERROR = 'RECEIVE_DIFF_ERROR'; +export const REQUEST_DIFF = 'REQUEST_DIFF'; +export const SET_DIFF_ENDPOINT = 'SET_DIFF_ENDPOINT'; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js new file mode 100644 index 00000000000..ee943b0621c --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/mutations.js @@ -0,0 +1,30 @@ +import { parseDiff } from '~/vue_shared/security_reports/store/utils'; +import * as types from './mutation_types'; + +export default { + [types.SET_DIFF_ENDPOINT](state, path) { + state.paths.diffEndpoint = path; + }, + + [types.REQUEST_DIFF](state) { + state.isLoading = true; + }, + + [types.RECEIVE_DIFF_SUCCESS](state, { diff, enrichData }) { + const { added, fixed, existing } = parseDiff(diff, enrichData); + const baseReportOutofDate = diff.base_report_out_of_date || false; + const hasBaseReport = Boolean(diff.base_report_created_at); + + state.isLoading = false; + state.newIssues = added; + state.resolvedIssues = fixed; + state.allIssues = existing; + state.baseReportOutofDate = baseReportOutofDate; + state.hasBaseReport = hasBaseReport; + }, + + [types.RECEIVE_DIFF_ERROR](state) { + state.isLoading = false; + state.hasError = true; + }, +}; diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js new file mode 100644 index 00000000000..e860e3af924 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js @@ -0,0 +1,16 @@ +export default () => ({ + paths: { + head: null, + base: null, + diffEndpoint: null, + }, + + isLoading: false, + hasError: false, + + newIssues: [], + resolvedIssues: [], + allIssues: [], + baseReportOutofDate: false, + hasBaseReport: false, +}); diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js new file mode 100644 index 00000000000..6e50efae741 --- /dev/null +++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js @@ -0,0 +1,75 @@ +import pollUntilComplete from '~/lib/utils/poll_until_complete'; +import axios from '~/lib/utils/axios_utils'; +import { + FEEDBACK_TYPE_DISMISSAL, + FEEDBACK_TYPE_ISSUE, + FEEDBACK_TYPE_MERGE_REQUEST, +} from '../constants'; + +export const fetchDiffData = (state, endpoint, category) => { + const requests = [pollUntilComplete(endpoint)]; + + if (state.canReadVulnerabilityFeedback) { + requests.push(axios.get(state.vulnerabilityFeedbackPath, { params: { category } })); + } + + return Promise.all(requests).then(([diffResponse, enrichResponse]) => ({ + diff: diffResponse.data, + enrichData: enrichResponse?.data ?? [], + })); +}; + +/** + * Returns given vulnerability enriched with the corresponding + * feedback (`dismissal` or `issue` type) + * @param {Object} vulnerability + * @param {Array} feedback + */ +export const enrichVulnerabilityWithFeedback = (vulnerability, feedback = []) => + feedback + .filter(fb => fb.project_fingerprint === vulnerability.project_fingerprint) + .reduce((vuln, fb) => { + if (fb.feedback_type === FEEDBACK_TYPE_DISMISSAL) { + return { + ...vuln, + isDismissed: true, + dismissalFeedback: fb, + }; + } + if (fb.feedback_type === FEEDBACK_TYPE_ISSUE && fb.issue_iid) { + return { + ...vuln, + hasIssue: true, + issue_feedback: fb, + }; + } + if (fb.feedback_type === FEEDBACK_TYPE_MERGE_REQUEST && fb.merge_request_iid) { + return { + ...vuln, + hasMergeRequest: true, + merge_request_feedback: fb, + }; + } + return vuln; + }, vulnerability); + +/** + * Generates the added, fixed, and existing vulnerabilities from the API report. + * + * @param {Object} diff The original reports. + * @param {Object} enrichData Feedback data to add to the reports. + * @returns {Object} + */ +export const parseDiff = (diff, enrichData) => { + const enrichVulnerability = vulnerability => ({ + ...enrichVulnerabilityWithFeedback(vulnerability, enrichData), + category: vulnerability.report_type, + title: vulnerability.message || vulnerability.name, + }); + + return { + added: diff.added ? diff.added.map(enrichVulnerability) : [], + fixed: diff.fixed ? diff.fixed.map(enrichVulnerability) : [], + existing: diff.existing ? diff.existing.map(enrichVulnerability) : [], + }; +}; diff --git a/app/assets/javascripts/vuex_shared/modules/members/actions.js b/app/assets/javascripts/vuex_shared/modules/members/actions.js index f7fdddfd070..4c31b3c9744 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/actions.js +++ b/app/assets/javascripts/vuex_shared/modules/members/actions.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; +import { formatDate } from '~/lib/utils/datetime_utility'; export const updateMemberRole = async ({ state, commit }, { memberId, accessLevel }) => { try { @@ -23,3 +24,21 @@ export const showRemoveGroupLinkModal = ({ commit }, groupLink) => { export const hideRemoveGroupLinkModal = ({ commit }) => { commit(types.HIDE_REMOVE_GROUP_LINK_MODAL); }; + +export const updateMemberExpiration = async ({ state, commit }, { memberId, expiresAt }) => { + try { + await axios.put( + state.memberPath.replace(':id', memberId), + state.requestFormatter({ expires_at: expiresAt ? formatDate(expiresAt, 'isoDate') : '' }), + ); + + commit(types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, { + memberId, + expiresAt: expiresAt ? formatDate(expiresAt, 'isoUtcDateTime') : null, + }); + } catch (error) { + commit(types.RECEIVE_MEMBER_EXPIRATION_ERROR); + + throw error; + } +}; diff --git a/app/assets/javascripts/vuex_shared/modules/members/index.js b/app/assets/javascripts/vuex_shared/modules/members/index.js index 682e85298ad..586d52a5288 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/index.js +++ b/app/assets/javascripts/vuex_shared/modules/members/index.js @@ -1,6 +1,6 @@ import createState from 'ee_else_ce/vuex_shared/modules/members/state'; -import * as actions from './actions'; -import mutations from './mutations'; +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, diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js b/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js index 00f4c910669..77307aa745b 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js +++ b/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js @@ -1,6 +1,9 @@ export const RECEIVE_MEMBER_ROLE_SUCCESS = 'RECEIVE_MEMBER_ROLE_SUCCESS'; export const RECEIVE_MEMBER_ROLE_ERROR = 'RECEIVE_MEMBER_ROLE_ERROR'; +export const RECEIVE_MEMBER_EXPIRATION_SUCCESS = 'RECEIVE_MEMBER_EXPIRATION_SUCCESS'; +export const RECEIVE_MEMBER_EXPIRATION_ERROR = 'RECEIVE_MEMBER_EXPIRATION_ERROR'; + export const HIDE_ERROR = 'HIDE_ERROR'; export const SHOW_REMOVE_GROUP_LINK_MODAL = 'SHOW_REMOVE_GROUP_LINK_MODAL'; diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutations.js b/app/assets/javascripts/vuex_shared/modules/members/mutations.js index 281c947e68f..2415e744290 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/mutations.js +++ b/app/assets/javascripts/vuex_shared/modules/members/mutations.js @@ -19,6 +19,21 @@ export default { ); state.showError = true; }, + [types.RECEIVE_MEMBER_EXPIRATION_SUCCESS](state, { memberId, expiresAt }) { + const member = findMember(state, memberId); + + if (!member) { + return; + } + + Vue.set(member, 'expiresAt', expiresAt); + }, + [types.RECEIVE_MEMBER_EXPIRATION_ERROR](state) { + state.errorMessage = s__( + "Members|An error occurred while updating the member's expiration date, please try again.", + ); + state.showError = true; + }, [types.HIDE_ERROR](state) { state.showError = false; state.errorMessage = ''; diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/vuex_shared/modules/members/state.js index e4867819e17..ab3ebb34616 100644 --- a/app/assets/javascripts/vuex_shared/modules/members/state.js +++ b/app/assets/javascripts/vuex_shared/modules/members/state.js @@ -3,6 +3,7 @@ export default ({ sourceId, currentUserId, tableFields, + tableAttrs, memberPath, requestFormatter, }) => ({ @@ -10,6 +11,7 @@ export default ({ sourceId, currentUserId, tableFields, + tableAttrs, memberPath, requestFormatter, showError: false, diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue index 9400dacedc2..3c1de57252a 100644 --- a/app/assets/javascripts/whats_new/components/app.vue +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -1,8 +1,16 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlDrawer, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; +import { + GlDrawer, + GlBadge, + GlIcon, + GlLink, + GlInfiniteScroll, + GlResizeObserverDirective, +} from '@gitlab/ui'; import SkeletonLoader from './skeleton_loader.vue'; import Tracking from '~/tracking'; +import { getDrawerBodyHeight } from '../utils/get_drawer_body_height'; const trackingMixin = Tracking.mixin(); @@ -12,8 +20,12 @@ export default { GlBadge, GlIcon, GlLink, + GlInfiniteScroll, SkeletonLoader, }, + directives: { + GlResizeObserver: GlResizeObserverDirective, + }, mixins: [trackingMixin], props: { storageKey: { @@ -23,7 +35,7 @@ export default { }, }, computed: { - ...mapState(['open', 'features']), + ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight']), }, mounted() { this.openDrawer(this.storageKey); @@ -35,36 +47,64 @@ export default { this.track('click_whats_new_drawer', { label: 'namespace_id', value: namespaceId }); }, methods: { - ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems']), + ...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']), + bottomReached() { + if (this.pageInfo.nextPage) { + this.fetchItems(this.pageInfo.nextPage); + } + }, + handleResize() { + const height = getDrawerBodyHeight(this.$refs.drawer.$el); + this.setDrawerBodyHeight(height); + }, }, }; </script> <template> <div> - <gl-drawer class="whats-new-drawer" :open="open" @close="closeDrawer"> + <gl-drawer + ref="drawer" + v-gl-resize-observer="handleResize" + class="whats-new-drawer" + :open="open" + @close="closeDrawer" + > <template #header> - <h4 class="page-title my-2">{{ __("What's new at GitLab") }}</h4> + <h4 class="page-title gl-my-2">{{ __("What's new at GitLab") }}</h4> </template> - <div class="pb-6"> - <template v-if="features"> - <div v-for="feature in features" :key="feature.title" class="mb-6"> + <gl-infinite-scroll + v-if="features.length" + :fetched-items="features.length" + :max-list-height="drawerBodyHeight" + class="gl-p-0" + @bottomReached="bottomReached" + > + <template #items> + <div + v-for="feature in features" + :key="feature.title" + class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + > <gl-link :href="feature.url" target="_blank" - data-testid="whats-new-title-link" + class="whats-new-item-title-link" data-track-event="click_whats_new_item" :data-track-label="feature.title" :data-track-property="feature.url" > - <h5 class="gl-font-base">{{ feature.title }}</h5> + <h5 class="gl-font-lg">{{ feature.title }}</h5> </gl-link> <div v-if="feature.packages" class="gl-mb-3"> - <template v-for="package_name in feature.packages"> - <gl-badge :key="package_name" size="sm" class="whats-new-item-badge gl-mr-2"> - <gl-icon name="license" />{{ package_name }} - </gl-badge> - </template> + <gl-badge + v-for="package_name in feature.packages" + :key="package_name" + size="sm" + class="whats-new-item-badge gl-mr-2" + > + <gl-icon name="license" />{{ package_name }} + </gl-badge> </div> <gl-link :href="feature.url" @@ -76,7 +116,7 @@ export default { <img :alt="feature.title" :src="feature.image_url" - class="img-thumbnail px-6 gl-py-3 whats-new-item-image" + class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image" /> </gl-link> <p class="gl-pt-3">{{ feature.body }}</p> @@ -90,10 +130,10 @@ export default { > </div> </template> - <div v-else class="gl-mt-5"> - <skeleton-loader /> - <skeleton-loader /> - </div> + </gl-infinite-scroll> + <div v-else class="gl-mt-5"> + <skeleton-loader /> + <skeleton-loader /> </div> </gl-drawer> <div v-if="open" class="whats-new-modal-backdrop modal-backdrop"></div> diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js index a84dfb399d8..532febd61cb 100644 --- a/app/assets/javascripts/whats_new/store/actions.js +++ b/app/assets/javascripts/whats_new/store/actions.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; export default { closeDrawer({ commit }) { @@ -12,9 +13,33 @@ export default { localStorage.setItem(storageKey, JSON.stringify(false)); } }, - fetchItems({ commit }) { - return axios.get('/-/whats_new').then(({ data }) => { - commit(types.SET_FEATURES, data); - }); + fetchItems({ commit, state }, page) { + if (state.fetching) { + return false; + } + + commit(types.SET_FETCHING, true); + + return axios + .get('/-/whats_new', { + params: { + page, + }, + }) + .then(({ data, headers }) => { + commit(types.ADD_FEATURES, data); + + const normalizedHeaders = normalizeHeaders(headers); + const { nextPage } = parseIntPagination(normalizedHeaders); + commit(types.SET_PAGE_INFO, { + nextPage, + }); + }) + .finally(() => { + commit(types.SET_FETCHING, false); + }); + }, + setDrawerBodyHeight({ commit }, height) { + commit(types.SET_DRAWER_BODY_HEIGHT, height); }, }; diff --git a/app/assets/javascripts/whats_new/store/mutation_types.js b/app/assets/javascripts/whats_new/store/mutation_types.js index 124d33a88b1..5715c442f66 100644 --- a/app/assets/javascripts/whats_new/store/mutation_types.js +++ b/app/assets/javascripts/whats_new/store/mutation_types.js @@ -1,3 +1,6 @@ export const CLOSE_DRAWER = 'CLOSE_DRAWER'; export const OPEN_DRAWER = 'OPEN_DRAWER'; -export const SET_FEATURES = 'SET_FEATURES'; +export const ADD_FEATURES = 'ADD_FEATURES'; +export const SET_PAGE_INFO = 'SET_PAGE_INFO'; +export const SET_FETCHING = 'SET_FETCHING'; +export const SET_DRAWER_BODY_HEIGHT = 'SET_DRAWER_BODY_HEIGHT'; diff --git a/app/assets/javascripts/whats_new/store/mutations.js b/app/assets/javascripts/whats_new/store/mutations.js index 4fb7b17244e..725521780dc 100644 --- a/app/assets/javascripts/whats_new/store/mutations.js +++ b/app/assets/javascripts/whats_new/store/mutations.js @@ -7,7 +7,16 @@ export default { [types.OPEN_DRAWER](state) { state.open = true; }, - [types.SET_FEATURES](state, data) { - state.features = data; + [types.ADD_FEATURES](state, data) { + state.features = state.features.concat(data); + }, + [types.SET_PAGE_INFO](state, pageInfo) { + state.pageInfo = pageInfo; + }, + [types.SET_FETCHING](state, fetching) { + state.fetching = fetching; + }, + [types.SET_DRAWER_BODY_HEIGHT](state, height) { + state.drawerBodyHeight = height; }, }; diff --git a/app/assets/javascripts/whats_new/store/state.js b/app/assets/javascripts/whats_new/store/state.js index 4c76284b865..793c6aa2b98 100644 --- a/app/assets/javascripts/whats_new/store/state.js +++ b/app/assets/javascripts/whats_new/store/state.js @@ -1,4 +1,9 @@ export default { open: false, - features: null, + features: [], + fetching: false, + drawerBodyHeight: null, + pageInfo: { + nextPage: null, + }, }; diff --git a/app/assets/javascripts/whats_new/utils/get_drawer_body_height.js b/app/assets/javascripts/whats_new/utils/get_drawer_body_height.js new file mode 100644 index 00000000000..21fc90c34a4 --- /dev/null +++ b/app/assets/javascripts/whats_new/utils/get_drawer_body_height.js @@ -0,0 +1,6 @@ +export const getDrawerBodyHeight = drawer => { + const drawerViewableHeight = drawer.clientHeight - drawer.getBoundingClientRect().top; + const drawerHeaderHeight = drawer.querySelector('.gl-drawer-header').clientHeight; + + return drawerViewableHeight - drawerHeaderHeight; +}; diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss index a31cb0b0485..52bc19fddd9 100644 --- a/app/assets/stylesheets/_page_specific_files.scss +++ b/app/assets/stylesheets/_page_specific_files.scss @@ -1,8 +1,5 @@ @import './pages/admin'; -@import './pages/alert_management/details'; -@import './pages/alert_management/severity-icons'; @import './pages/branches'; -@import './pages/builds'; @import './pages/ci_projects'; @import './pages/clusters'; @import './pages/commits'; @@ -27,7 +24,6 @@ @import './pages/notes'; @import './pages/notifications'; @import './pages/pages'; -@import './pages/pipeline_schedules'; @import './pages/pipelines'; @import './pages/profile'; @import './pages/profiles/preferences'; @@ -39,7 +35,6 @@ @import './pages/settings'; @import './pages/settings_ci_cd'; @import './pages/sherlock'; -@import './pages/status'; @import './pages/storage_quota'; @import './pages/tree'; @import './pages/trials'; diff --git a/app/assets/stylesheets/behaviors.scss b/app/assets/stylesheets/behaviors.scss index 120a139ff3d..bcfa5bac5d5 100644 --- a/app/assets/stylesheets/behaviors.scss +++ b/app/assets/stylesheets/behaviors.scss @@ -1,28 +1,3 @@ -// Details -//-------- -.js-details-container { - .content { - display: none; - &.hide { display: block; } - } - - &.open .content { - display: block; - &.hide { display: none; } - } -} - -// Toggle between two states. -.js-toggler-container { - .turn-on { display: block; } - .turn-off { display: none; } - - &.on { - .turn-on { display: none; } - .turn-off { display: block; } - } -} - // Hide element if Vue is still working on rendering it fully. [v-cloak='true'] { display: none !important; diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index aac32e7fb2d..3d5076f485c 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -115,14 +115,10 @@ code { background-color: $gray-50; border-radius: $border-radius-default; - .code > & { - background-color: inherit; - padding: unset; - } - + .code > &, .build-trace & { background-color: inherit; - padding: inherit; + padding: unset; } } @@ -131,12 +127,6 @@ table { border-spacing: 0; } -.tooltip, -.no-pointer-events { - // Fix bootstrap4 bug whereby tooltips flicker when they are hovered over their borders - pointer-events: none; -} - @each $breakpoint in map-keys($grid-breakpoints) { @include media-breakpoint-up($breakpoint) { $infix: breakpoint-infix($breakpoint, $grid-breakpoints); diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss index 81f2091e915..579a86a94a4 100644 --- a/app/assets/stylesheets/components/design_management/design.scss +++ b/app/assets/stylesheets/components/design_management/design.scss @@ -75,10 +75,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); left: 0; } -.design-scaler { - z-index: 1; -} - .design-scaler-wrapper { bottom: 0; left: 50%; @@ -185,41 +181,3 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); .design-card-header { background: transparent; } - -.design-dropzone-border { - border: 2px dashed $gray-100; -} - -.design-dropzone-card { - transition: border $gl-transition-duration-medium $general-hover-transition-curve; - color: $gl-text-color; - - &:focus, - &:active { - outline: none; - border: 2px dashed $purple; - color: $gl-text-color; - } - - &:hover { - border-color: $gray-300; - } -} - -.design-dropzone-overlay { - border: 2px dashed $purple; - top: 0; - left: 0; - pointer-events: none; - opacity: 1; -} - -.design-dropzone-fade-enter-active, -.design-dropzone-fade-leave-active { - transition: opacity $general-hover-transition-duration $general-hover-transition-curve; -} - -.design-dropzone-fade-enter, -.design-dropzone-fade-leave-to { - opacity: 0; -} diff --git a/app/assets/stylesheets/pages/alert_management/severity-icons.scss b/app/assets/stylesheets/components/severity/icons.scss index f58ad87a673..8ddf873196a 100644 --- a/app/assets/stylesheets/pages/alert_management/severity-icons.scss +++ b/app/assets/stylesheets/components/severity/icons.scss @@ -2,26 +2,26 @@ .incident-management-list, .alert-management-details { .icon-critical { - color: $red-800; + @include gl-text-red-800; } .icon-high { - color: $red-600; + @include gl-text-red-600; } .icon-medium { - color: $orange-400; + @include gl-text-orange-400; } .icon-low { - color: $orange-300; + @include gl-text-orange-300; } .icon-info { - color: $blue-400; + @include gl-text-blue-400; } .icon-unknown { - color: $gray-200; + @include gl-text-gray-200; } } diff --git a/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss new file mode 100644 index 00000000000..2bc6eba3342 --- /dev/null +++ b/app/assets/stylesheets/components/upload_dropzone/upload_dropzone.scss @@ -0,0 +1,37 @@ +.upload-dropzone-border { + border: 2px dashed $gray-100; +} + +.upload-dropzone-card { + transition: border $gl-transition-duration-medium $general-hover-transition-curve; + color: $gl-text-color; + + &:focus, + &:active { + outline: none; + border: 2px dashed $purple; + color: $gl-text-color; + } + + &:hover { + border-color: $gray-300; + } +} + +.upload-dropzone-overlay { + border: 2px dashed $purple; + top: 0; + left: 0; + pointer-events: none; + opacity: 1; +} + +.upload-dropzone-fade-enter-active, +.upload-dropzone-fade-leave-active { + transition: opacity $general-hover-transition-duration $general-hover-transition-curve; +} + +.upload-dropzone-fade-enter, +.upload-dropzone-fade-leave-to { + opacity: 0; +} diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss index 6c58346b750..64e82531c30 100644 --- a/app/assets/stylesheets/components/whats_new.scss +++ b/app/assets/stylesheets/components/whats_new.scss @@ -1,6 +1,11 @@ .whats-new-drawer { margin-top: $header-height; @include gl-shadow-none; + overflow-y: hidden; + + .gl-infinite-scroll-legend { + @include gl-display-none; + } } .with-performance-bar .whats-new-drawer { @@ -13,6 +18,14 @@ @include gl-font-weight-bold; } +.whats-new-item-title-link { + &:hover, + &:focus, + &:active { + @include gl-text-gray-900; + } +} + .whats-new-item-image { border-color: $gray-50; } diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss index a3338ff13b5..8a955cffc49 100644 --- a/app/assets/stylesheets/fontawesome_custom.scss +++ b/app/assets/stylesheets/fontawesome_custom.scss @@ -92,55 +92,23 @@ content: '\f0d7'; } -.fa-check::before { - content: '\f00c'; -} - .fa-warning::before, .fa-exclamation-triangle::before { content: '\f071'; } -.fa-external-link::before { - content: '\f08e'; -} - .fa-spinner::before { content: '\f110'; } -.fa-trash-o::before { - content: '\f014'; -} - .fa-caret-right::before { content: '\f0da'; } -.fa-refresh::before { - content: '\f021'; -} - -.fa-chevron-up::before { - content: '\f077'; -} - -.fa-paperclip::before { - content: '\f0c6'; -} - -.fa-bug::before { - content: '\f188'; -} - .fa-exclamation-circle::before { content: '\f06a'; } -.fa-bell::before { - content: '\f0f3'; -} - .fa-file-o::before { content: '\f016'; } @@ -153,10 +121,6 @@ content: '\f111'; } -.fa-git::before { - content: '\f1d3'; -} - .fa-thumb-tack::before { content: '\f08d'; } @@ -165,38 +129,6 @@ content: '\f06d'; } -.fa-pause::before { - content: '\f04c'; -} - -.fa-play::before { - content: '\f04b'; -} - -.fa-share::before { - content: '\f064'; -} - -.fa-book::before { - content: '\f02d'; -} - -.fa-times-circle::before { - content: '\f057'; -} - -.fa-skype::before { - content: '\f17e'; -} - -.fa-linkedin-square::before { - content: '\f08c'; -} - -.fa-twitter-square::before { - content: '\f081'; -} - .fa-file-pdf-o::before { content: '\f1c1'; } @@ -229,6 +161,14 @@ content: '\f1c8'; } +.fa-square-o::before { + content: '\f096'; +} + +.fa-check-square-o::before { + content: '\f046'; +} + .sr-only { position: absolute; width: 1px; diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss index c1647c16c77..b8934d2797a 100644 --- a/app/assets/stylesheets/framework/broadcast_messages.scss +++ b/app/assets/stylesheets/framework/broadcast_messages.scss @@ -15,10 +15,6 @@ .broadcast-banner-message { text-align: center; - - .broadcast-message-dismiss { - color: inherit; - } } .broadcast-notification-message { @@ -36,10 +32,6 @@ &.preview { position: static; } - - .broadcast-message-dismiss { - color: $gray-700; - } } .toggle-colors { diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index de767ac3fe0..5b7f1a3f38b 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -14,14 +14,11 @@ .str-truncated { max-width: 70%; } - - .user-calendar-activities-loading { - font-size: 24px; - } } .user-calendar { text-align: center; + min-height: 172px; .calendar { display: inline-block; @@ -42,12 +39,9 @@ .calendar-hint { font-size: 12px; - - &.bottom-right { - direction: ltr; - margin-top: -23px; - float: right; - } + direction: ltr; + margin-top: -23px; + float: right; } .pika-single.gitlab-theme { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 8dbed9c03f2..deb2d6c4641 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -424,7 +424,6 @@ img.emoji { .w-15p { width: 15%; } .w-30p { width: 30%; } .w-60p { width: 60%; } -.w-70p { width: 70%; } .h-12em { height: 12em; } .h-32-px { height: 32px;} diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index c0a2350d080..e16ab5ee72f 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -6,11 +6,18 @@ border-top: 1px solid $border-color; } + &.has-body { + .file-title { + box-shadow: 0 -2px 0 0 var(--white); + } + } + + table.code tr:last-of-type td:last-of-type { + @include gl-rounded-bottom-right-base(); + } + .file-title, .file-title-flex-parent { - border-top-left-radius: $border-radius-default; - border-top-right-radius: $border-radius-default; - box-shadow: 0 -2px 0 0 var(--white); cursor: pointer; .dropdown-menu { @@ -113,7 +120,6 @@ .diff-content { background: $white; color: $gl-text-color; - border-radius: 0 0 3px 3px; .unfold { cursor: pointer; @@ -443,6 +449,7 @@ } } +.diff-table.code, table.code { width: 100%; font-family: $monospace-font; @@ -453,14 +460,20 @@ table.code { table-layout: fixed; border-radius: 0 0 $border-radius-default $border-radius-default; + .diff-tr:first-of-type.line_expansion > .diff-td, tr:first-of-type.line_expansion > td { border-top: 0; } - tr:nth-last-of-type(2).line_expansion > td { - border-bottom: 0; + .diff-tr:nth-last-of-type(2).line_expansion > .diff-td, + tr:nth-last-of-type(2).line_expansion, + tr:last-of-type.line_expansion { + > td { + border-bottom: 0; + } } + .diff-tr.line_holder .diff-td, tr.line_holder td { line-height: $code-line-height; font-size: $code-font-size; @@ -556,24 +569,95 @@ table.code { } .line_holder:last-of-type { + .diff-td:first-child, td:first-child { border-bottom-left-radius: $border-radius-default; } } &.left-side-selected { + .diff-td.line_content.parallel.right-side, td.line_content.parallel.right-side { user-select: none; } } &.right-side-selected { + .diff-td.line_content.parallel.left-side, td.line_content.parallel.left-side { user-select: none; } } } +// Merge request diff grid layout +.diff-grid { + .diff-grid-row { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .diff-grid-left, + .diff-grid-right { + display: grid; + grid-template-columns: 50px 8px 1fr; + + .diff-td:nth-child(2) { + display: none; + } + } + + .diff-grid-comments { + display: grid; + grid-template-columns: 1fr 1fr; + } + + .diff-grid-drafts { + display: grid; + grid-template-columns: 1fr 1fr; + } + + &.inline { + .diff-grid-comments { + display: grid; + grid-template-columns: 1fr; + } + + .diff-grid-drafts { + display: grid; + grid-template-columns: 1fr; + } + + .diff-grid-row { + grid-template-columns: 1fr; + } + + .diff-grid-left, + .diff-grid-right { + grid-template-columns: 50px 50px 8px 1fr; + + .diff-td:nth-child(2) { + display: block; + } + } + + .diff-grid-left .old:nth-child(1) [data-linenumber], + .diff-grid-right .new:nth-child(2) [data-linenumber] { + display: inline; + } + + .diff-grid-left .old:nth-child(2) [data-linenumber], + .diff-grid-right .new:nth-child(1) [data-linenumber] { + display: none; + } + } +} + +// Merge request diff grid layout overrides +.diff-table.code .diff-tr.line_holder .diff-td.line_content.parallel { + width: unset; +} + .diff-stats { align-items: center; padding: 0 1rem; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ca20b18f851..2094c824286 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -407,7 +407,8 @@ } } - &.droplab-item-selected i { + &.droplab-item-selected i, + &.droplab-item-selected svg { visibility: visible; } diff --git a/app/assets/stylesheets/framework/editor-lite.scss b/app/assets/stylesheets/framework/editor-lite.scss index 20fea7a82ca..c3b287a6c3d 100644 --- a/app/assets/stylesheets/framework/editor-lite.scss +++ b/app/assets/stylesheets/framework/editor-lite.scss @@ -1,3 +1,21 @@ +[data-editor-loading] { + @include gl-relative; + @include gl-display-flex; + @include gl-justify-content-center; + @include gl-align-items-center; + + &::before { + content: ''; + @include spinner(32px, 3px); + @include gl-absolute; + @include gl-z-index-1; + } + + pre { + opacity: 0; + } +} + [id^='editor-lite-'] { height: 500px; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index f8710cc1346..fe8c27ae9b6 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -45,11 +45,6 @@ } .file-actions { - position: absolute; - top: 5px; - right: 15px; - margin-left: auto; - .btn:not(.btn-icon) { padding: 0 10px; font-size: 13px; @@ -342,30 +337,14 @@ span.idiff { padding: $gl-padding-8 $gl-padding; margin: 0; border-radius: $border-radius-default $border-radius-default 0 0; - } - - .file-header-content { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding-right: 30px; - position: relative; - width: auto; - - @media (max-width: map-get($grid-breakpoints, sm)-1) { - width: 100%; - } - } - .file-holder & { - .file-actions { - position: static; + @include media-breakpoint-up(md) { + flex-wrap: nowrap; } } - .btn-clipboard { - position: absolute; - right: 0; + .file-header-content { + padding-right: 30px; } a { @@ -384,15 +363,11 @@ span.idiff { z-index: 2; } - @include media-breakpoint-down(xs) { + @include media-breakpoint-down(sm) { display: block; .file-actions { - white-space: normal; - - .btn-group { - padding-top: 5px; - } + margin-top: 5px; } } } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 0fb91db0afb..d5f7ec68454 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -9,9 +9,15 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); &.sticky { position: sticky; - position: -webkit-sticky; top: $flash-container-top; z-index: 251; + + .flash-alert, + .flash-notice, + .flash-success, + .flash-warning { + @include gl-mb-4; + } } &.flash-container-page { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 63be2bdef8e..20d44b71bf6 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -3,6 +3,38 @@ * Mixins with fixed values */ +@keyframes blinking-dot { + 0% { + opacity: 1; + } + + 25% { + opacity: 0.4; + } + + 75% { + opacity: 0.4; + } + + 100% { + opacity: 1; + } +} + +@keyframes blinking-scroll-button { + 0% { + opacity: 0.2; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0.2; + } +} + @mixin str-truncated($max-width: 82%) { display: inline-block; overflow: hidden; diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index f8c46a4495e..372e3bed6e0 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -117,12 +117,6 @@ body.modal-open { border-bottom-right-radius: $modal-border-radius; } } - - @include media-breakpoint-up(sm) { - .modal-dialog { - margin: 64px auto; - } - } } .recaptcha-modal .recaptcha-form { @@ -134,7 +128,7 @@ body.modal-open { } .issues-import-modal, -.issues-export-modal { +.issuable-export-modal { .modal-header { justify-content: flex-start; @@ -166,8 +160,4 @@ body.modal-open { min-height: $modal-body-height; } } - - .checkmark { - color: $green-400; - } } diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index 7ebc972ac37..3e218de6af9 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -69,7 +69,7 @@ line-height: 28px; white-space: normal; - /* Small devices (phones, tablets, 768px and lower) */ + /* Small devices (phones, 768px and lower) */ @include media-breakpoint-down(xs) { width: 100%; } @@ -92,7 +92,7 @@ padding: 16px 15px 11px; } - /* Small devices (phones, tablets, 768px and lower) */ + /* Small devices (phones, 768px and lower) */ @include media-breakpoint-down(sm) { width: 100%; } @@ -102,15 +102,6 @@ display: inline-block; text-align: right; - @include media-breakpoint-down(sm) { - margin-top: $gl-padding-8; - } - - @include media-breakpoint-up(md) { - display: flex; - align-items: center; - } - > .btn, > .btn-group, > .btn-container, @@ -146,6 +137,35 @@ } } + @include media-breakpoint-up(md) { + display: flex; + align-items: center; + } + + @include media-breakpoint-down(md) { + $controls-margin: $btn-margin-5 - 2px; + flex: 0 0 100%; + margin-top: $gl-padding-8; + + .controls-item, + .controls-item-full, + .controls-item:last-child { + flex: 1 1 35%; + display: block; + width: 100%; + margin: $controls-margin; + + .btn, + .dropdown { + margin: 0; + } + } + + .controls-item-full { + flex: 1 1 100%; + } + } + @include media-breakpoint-down(sm) { padding-bottom: 0; width: 100%; @@ -239,32 +259,6 @@ pre { width: 100%; } - - @include media-breakpoint-down(md) { - .nav-controls { - $controls-margin: $btn-margin-5 - 2px; - flex: 0 0 100%; - margin-top: $gl-padding-8; - - .controls-item, - .controls-item-full, - .controls-item:last-child { - flex: 1 1 35%; - display: block; - width: 100%; - margin: $controls-margin; - - .btn, - .dropdown { - margin: 0; - } - } - - .controls-item-full { - flex: 1 1 100%; - } - } - } } .scrolling-tabs-container { diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss index a74aeb9f220..2aa0ab6c1eb 100644 --- a/app/assets/stylesheets/framework/spinner.scss +++ b/app/assets/stylesheets/framework/spinner.scss @@ -20,7 +20,7 @@ } } -.spinner { +@mixin spinner($size: 16px, $border-width: 2px, $color: $orange-400) { border-radius: 50%; position: relative; margin: 0 auto; @@ -30,8 +30,12 @@ animation-iteration-count: infinite; border-style: solid; display: inline-flex; - @include spinner-size(16px, 2px); - @include spinner-color($orange-400); + @include spinner-size($size, $border-width); + @include spinner-color($color); +} + +.spinner { + @include spinner; &.spinner-md { @include spinner-size(32px, 3px); @@ -56,3 +60,7 @@ vertical-align: text-bottom; } } + +.spin { + animation: spinner-rotate 2s infinite linear; +} diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index c15d46d43b2..3d09edfe181 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -453,11 +453,9 @@ h4, h5, h6 { - position: relative; - a.anchor { - left: -16px; - position: absolute; + float: left; + margin-left: -16px; text-decoration: none; outline: none; diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index 31075b09b83..d9b9f3694c1 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -20,6 +20,7 @@ @mixin diff-expansion($background, $border, $link) { background-color: $background; + .diff-td, td { border-top: 1px solid $border; border-bottom: 1px solid $border; @@ -41,3 +42,12 @@ border-left: 3px solid $no-coverage; } } + +@mixin line-number-hover($color) { + background-color: $color; + border-color: darken($color, 5%); + + a { + color: darken($color, 15%); + } +} diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 8d965ea4309..d51d5b7137d 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -125,6 +125,9 @@ $dark-il: #de935f; @include dark-diff-match-line; } + .diff-td.diff-line-num.hll:not(.empty-cell), + .diff-td.line-coverage.hll:not(.empty-cell), + .diff-td.line_content.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell), td.line-coverage.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { @@ -158,15 +161,17 @@ $dark-il: #de935f; } } + .diff-grid-left:hover, + .diff-grid-right:hover { + .diff-line-num:not(.empty-cell) { + @include line-number-hover($dark-over-bg); + } + } + .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - background-color: $dark-over-bg; - border-color: darken($dark-over-bg, 5%); - - a { - color: darken($dark-over-bg, 15%); - } + @include line-number-hover($dark-over-bg); } } diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 5ef2b9dcc36..e690f9c7c74 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -125,6 +125,9 @@ $monokai-gi: #a6e22e; @include dark-diff-match-line; } + .diff-td.diff-line-num.hll:not(.empty-cell), + .diff-td.line-coverage.hll:not(.empty-cell), + .diff-td.line_content.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell), td.line-coverage.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { @@ -158,15 +161,17 @@ $monokai-gi: #a6e22e; } } + .diff-grid-left:hover, + .diff-grid-right:hover { + .diff-line-num:not(.empty-cell) { + @include line-number-hover($monokai-over-bg); + } + } + .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - background-color: $monokai-over-bg; - border-color: darken($monokai-over-bg, 5%); - - a { - color: darken($monokai-over-bg, 15%); - } + @include line-number-hover($monokai-over-bg); } } diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index fb548a00526..4fc6e5dba39 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -59,6 +59,13 @@ } } + .diff-grid-left:hover, + .diff-grid-right:hover { + .diff-line-num:not(.empty-cell) { + @include line-number-hover($none-over-bg); + } + } + .diff-line-num { &.old { a { @@ -74,12 +81,7 @@ &.is-over, &.hll:not(.empty-cell).is-over { - background-color: $none-over-bg; - border-color: darken($none-over-bg, 5%); - - a { - color: darken($none-over-bg, 15%); - } + @include line-number-hover($none-over-bg); } &.hll:not(.empty-cell) { diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index 190a6e6156a..8c532f53182 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -129,6 +129,9 @@ $solarized-dark-il: #2aa198; @include dark-diff-match-line; } + .diff-td.diff-line-num.hll:not(.empty-cell), + .diff-td.line-coverage.hll:not(.empty-cell), + .diff-td.line_content.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell), td.line-coverage.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { @@ -140,6 +143,13 @@ $solarized-dark-il: #2aa198; @include line-coverage-border-color($solarized-dark-coverage, $solarized-dark-no-coverage); } + .diff-grid-left:hover, + .diff-grid-right:hover { + .diff-line-num:not(.empty-cell) { + @include line-number-hover($solarized-dark-over-bg); + } + } + .diff-line-num.new, .line-coverage.new, .line_content.new { @@ -165,12 +175,7 @@ $solarized-dark-il: #2aa198; .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - background-color: $solarized-dark-over-bg; - border-color: darken($solarized-dark-over-bg, 5%); - - a { - color: darken($solarized-dark-over-bg, 15%); - } + @include line-number-hover($solarized-dark-over-bg); } } diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 71d8dd06834..1f9042a9534 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -136,6 +136,9 @@ $solarized-light-il: #2aa198; @include match-line; } + .diff-td.diff-line-num.hll:not(.empty-cell), + .diff-td.line-coverage.hll:not(.empty-cell), + .diff-td.line_content.hll:not(.empty-cell), td.diff-line-num.hll:not(.empty-cell), td.line-coverage.hll:not(.empty-cell), td.line_content.hll:not(.empty-cell) { @@ -159,6 +162,13 @@ $solarized-light-il: #2aa198; } } + .diff-grid-left:hover, + .diff-grid-right:hover { + .diff-line-num:not(.empty-cell) { + @include line-number-hover($solarized-light-over-bg); + } + } + .diff-line-num.old, .line-coverage.old, .line_content.old { @@ -173,12 +183,7 @@ $solarized-light-il: #2aa198; .diff-line-num { &.is-over, &.hll:not(.empty-cell).is-over { - background-color: $solarized-light-over-bg; - border-color: darken($solarized-light-over-bg, 5%); - - a { - color: darken($solarized-light-over-bg, 15%); - } + @include line-number-hover($solarized-light-over-bg); } } diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 3e126a52c4b..bb5ca94af33 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -113,6 +113,13 @@ pre.code, @include match-line; } + .diff-grid-left:hover, + .diff-grid-right:hover { + .diff-line-num:not(.empty-cell) { + @include line-number-hover($white-over-bg); + } + } + .diff-line-num { &.old { background-color: $line-number-old; @@ -134,12 +141,7 @@ pre.code, &.is-over, &.hll:not(.empty-cell).is-over { - background-color: $white-over-bg; - border-color: darken($white-over-bg, 5%); - - a { - color: darken($white-over-bg, 15%); - } + @include line-number-hover($white-over-bg); } &.hll:not(.empty-cell) { diff --git a/app/assets/stylesheets/lazy_bundles/select2.scss b/app/assets/stylesheets/lazy_bundles/select2.scss new file mode 100644 index 00000000000..f2c372020ef --- /dev/null +++ b/app/assets/stylesheets/lazy_bundles/select2.scss @@ -0,0 +1,654 @@ +/* +Version: 3.5.2 Timestamp: Sat Nov 1 14:43:36 EDT 2014 +Updated 2020-10-05 by TimZ +*/ +.select2-container { + margin: 0; + position: relative; + display: inline-block; +} + +.select2-container, +.select2-drop, +.select2-search, +.select2-search input { + box-sizing: border-box; +} + +.select2-container .select2-choice { + display: block; + height: 26px; + padding: 0 0 0 8px; + overflow: hidden; + position: relative; + + border: 1px solid #aaa; + white-space: nowrap; + line-height: 26px; + color: #444; + text-decoration: none; + + border-radius: 4px; + + background-clip: padding-box; + + user-select: none; + + background-color: #fff; + background-image: linear-gradient(to top, #eee 0%, #fff 50%); +} + +html[dir='rtl'] .select2-container .select2-choice { + padding: 0 8px 0 0; +} + +.select2-container.select2-drop-above .select2-choice { + border-bottom-color: #aaa; + + border-radius: 0 0 4px 4px; + + background-image: linear-gradient(to bottom, #eee 0%, #fff 90%); +} + +.select2-container.select2-allowclear .select2-choice .select2-chosen { + margin-right: 42px; +} + +.select2-container .select2-choice > .select2-chosen { + margin-right: 26px; + display: block; + overflow: hidden; + + white-space: nowrap; + + text-overflow: ellipsis; + float: none; + width: auto; +} + +html[dir='rtl'] .select2-container .select2-choice > .select2-chosen { + margin-left: 26px; + margin-right: 0; +} + +.select2-container .select2-choice abbr { + display: none; + width: 12px; + height: 12px; + position: absolute; + right: 24px; + top: 8px; + + font-size: 1px; + text-decoration: none; + + border: 0; + /* stylelint-disable-next-line function-url-quotes */ + background: url(image-path('select2.png')) right top no-repeat; + cursor: pointer; + outline: 0; +} + +.select2-container.select2-allowclear .select2-choice abbr { + display: inline-block; +} + +.select2-container .select2-choice abbr:hover { + background-position: right -11px; + cursor: pointer; +} + +.select2-drop-mask { + border: 0; + margin: 0; + padding: 0; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 9998; + /* styles required for IE to work */ + background-color: #fff; + filter: alpha(opacity=0); +} + +.select2-drop { + width: 100%; + margin-top: -1px; + position: absolute; + z-index: 9999; + top: 100%; + + background: #fff; + color: #000; + border: 1px solid #aaa; + border-top: 0; + + border-radius: 0 0 4px 4px; + + box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15); +} + +.select2-drop.select2-drop-above { + margin-top: 1px; + border-top: 1px solid #aaa; + border-bottom: 0; + + border-radius: 4px 4px 0 0; + + box-shadow: 0 -4px 5px rgba(0, 0, 0, 0.15); +} + +.select2-drop-active { + border: 1px solid #5897fb; + border-top: 0; +} + +.select2-drop.select2-drop-above.select2-drop-active { + border-top: 1px solid #5897fb; +} + +.select2-drop-auto-width { + border-top: 1px solid #aaa; + width: auto; +} + +.select2-drop-auto-width .select2-search { + padding-top: 4px; +} + +.select2-container .select2-choice .select2-arrow { + display: inline-block; + width: 18px; + height: 100%; + position: absolute; + right: 0; + top: 0; + + border-left: 1px solid #aaa; + border-radius: 0 4px 4px 0; + + background-clip: padding-box; + + background: #ccc; + background-image: linear-gradient(to top, #ccc 0%, #eee 60%); +} + +html[dir='rtl'] .select2-container .select2-choice .select2-arrow { + left: 0; + right: auto; + + border-left: 0; + border-right: 1px solid #aaa; + border-radius: 4px 0 0 4px; +} + +.select2-container .select2-choice .select2-arrow b { + display: block; + width: 100%; + height: 100%; + /* stylelint-disable-next-line function-url-quotes */ + background: url(image-path("select2.png")) no-repeat 0 1px; +} + +html[dir='rtl'] .select2-container .select2-choice .select2-arrow b { + background-position: 2px 1px; +} + +.select2-search { + display: inline-block; + width: 100%; + min-height: 26px; + margin: 0; + padding-left: 4px; + padding-right: 4px; + + position: relative; + z-index: 10000; + + white-space: nowrap; +} + +.select2-search input { + width: 100%; + height: auto !important; + min-height: 26px; + padding: 4px 20px 4px 5px; + margin: 0; + + outline: 0; + font-family: sans-serif; + font-size: 1em; + + border: 1px solid #aaa; + border-radius: 0; + + box-shadow: none; + /* stylelint-disable-next-line function-url-quotes */ + background: url(image-path('select2.png')) no-repeat 100% -22px, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; +} + +html[dir='rtl'] .select2-search input { + padding: 4px 5px 4px 20px; + /* stylelint-disable-next-line function-url-quotes */ + background: url(image-path('select2.png')) no-repeat -37px -22px, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; +} + +.select2-drop.select2-drop-above .select2-search input { + margin-top: 4px; +} + +.select2-search input.select2-active { + /* stylelint-disable-next-line function-url-quotes */ + background: url(image-path('select2-spinner.gif')) no-repeat 100%, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; +} + +.select2-container-active .select2-choice, +.select2-container-active .select2-choices { + border: 1px solid #5897fb; + outline: none; + + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); +} + +.select2-dropdown-open .select2-choice { + border-bottom-color: transparent; + box-shadow: 0 1px 0 #fff inset; + + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + background-color: #eee; + background-image: linear-gradient(to top, #fff 0%, #eee 50%); +} + +.select2-dropdown-open.select2-drop-above .select2-choice, +.select2-dropdown-open.select2-drop-above .select2-choices { + border: 1px solid #5897fb; + border-top-color: transparent; + + background-image: linear-gradient(to bottom, #fff 0%, #eee 50%); +} + +.select2-dropdown-open .select2-choice .select2-arrow { + background: transparent; + border-left: 0; + filter: none; +} + +html[dir='rtl'] .select2-dropdown-open .select2-choice .select2-arrow { + border-right: 0; +} + +.select2-dropdown-open .select2-choice .select2-arrow b { + background-position: -18px 1px; +} + +html[dir='rtl'] .select2-dropdown-open .select2-choice .select2-arrow b { + background-position: -16px 1px; +} + +.select2-hidden-accessible { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +/* results */ +.select2-results { + max-height: 200px; + padding: 0 0 0 4px; + margin: 4px 4px 4px 0; + position: relative; + overflow-x: hidden; + overflow-y: auto; +} + +html[dir='rtl'] .select2-results { + padding: 0 4px 0 0; + margin: 4px 0 4px 4px; +} + +.select2-results ul.select2-result-sub { + margin: 0; + padding-left: 0; +} + +.select2-results li { + list-style: none; + display: list-item; + background-image: none; +} + +.select2-results li.select2-result-with-children > .select2-result-label { + font-weight: bold; +} + +.select2-results .select2-result-label { + padding: 3px 7px 4px; + margin: 0; + cursor: pointer; + + min-height: 1em; + + user-select: none; +} + +.select2-results-dept-1 .select2-result-label { padding-left: 20px; } +.select2-results-dept-2 .select2-result-label { padding-left: 40px; } +.select2-results-dept-3 .select2-result-label { padding-left: 60px; } +.select2-results-dept-4 .select2-result-label { padding-left: 80px; } +.select2-results-dept-5 .select2-result-label { padding-left: 100px; } +.select2-results-dept-6 .select2-result-label { padding-left: 110px; } +.select2-results-dept-7 .select2-result-label { padding-left: 120px; } + +.select2-results .select2-highlighted { + background: #3875d7; + color: #fff; +} + +.select2-results li em { + background: #feffde; + font-style: normal; +} + +.select2-results .select2-highlighted em { + background: transparent; +} + +.select2-results .select2-highlighted ul { + background: #fff; + color: #000; +} + +.select2-results .select2-no-results, +.select2-results .select2-searching, +.select2-results .select2-ajax-error, +.select2-results .select2-selection-limit { + background: #f4f4f4; + display: list-item; + padding-left: 5px; +} + +/* +disabled look for disabled choices in the results dropdown +*/ +.select2-results .select2-disabled.select2-highlighted { + color: #666; + background: #f4f4f4; + display: list-item; + cursor: default; +} + +.select2-results .select2-disabled { + background: #f4f4f4; + display: list-item; + cursor: default; +} + +.select2-results .select2-selected { + display: none; +} + +.select2-more-results.select2-active { + /* stylelint-disable-next-line function-url-quotes */ + background: #f4f4f4 url(image-path('select2-spinner.gif')) no-repeat 100%; +} + +.select2-results .select2-ajax-error { + background: rgba(255, 50, 50, 0.2); +} + +.select2-more-results { + background: #f4f4f4; + display: list-item; +} + +/* disabled styles */ + +.select2-container.select2-container-disabled .select2-choice { + background-color: #f4f4f4; + background-image: none; + border: 1px solid #ddd; + cursor: default; +} + +.select2-container.select2-container-disabled .select2-choice .select2-arrow { + background-color: #f4f4f4; + background-image: none; + border-left: 0; +} + +.select2-container.select2-container-disabled .select2-choice abbr { + display: none; +} + + +/* multiselect */ + +.select2-container-multi .select2-choices { + height: auto !important; + height: 1%; + margin: 0; + padding: 0 5px 0 0; + position: relative; + + border: 1px solid #aaa; + cursor: text; + overflow: hidden; + + background-color: #fff; + background-image: linear-gradient(to bottom, #eee 1%, #fff 15%); +} + +html[dir='rtl'] .select2-container-multi .select2-choices { + padding: 0 0 0 5px; +} + +.select2-locked { + padding: 3px 5px !important; +} + +.select2-container-multi .select2-choices { + min-height: 26px; +} + +.select2-container-multi.select2-container-active .select2-choices { + border: 1px solid #5897fb; + outline: none; + + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); +} + +.select2-container-multi .select2-choices li { + float: left; + list-style: none; +} + +html[dir='rtl'] .select2-container-multi .select2-choices li { + float: right; +} + +.select2-container-multi .select2-choices .select2-search-field { + margin: 0; + padding: 0; + white-space: nowrap; +} + +.select2-container-multi .select2-choices .select2-search-field input { + padding: 5px; + margin: 1px 0; + + font-family: sans-serif; + font-size: 100%; + color: #666; + outline: 0; + border: 0; + + box-shadow: none; + background: transparent !important; +} + +.select2-container-multi .select2-choices .select2-search-field input.select2-active { + /* stylelint-disable-next-line function-url-quotes */ + background: #fff url(image-path('select2-spinner.gif')) no-repeat 100% !important; +} + +.select2-default { + color: #999 !important; +} + +.select2-container-multi .select2-choices .select2-search-choice { + padding: 3px 5px 3px 18px; + margin: 3px 0 3px 5px; + position: relative; + + line-height: 13px; + color: #333; + cursor: default; + border: 1px solid #aaa; + + border-radius: 3px; + + box-shadow: 0 0 2px #fff inset, 0 1px 0 rgba(0, 0, 0, 0.05); + + background-clip: padding-box; + + user-select: none; + + background-color: #e4e4e4; + background-image: linear-gradient(to bottom, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); +} + +html[dir='rtl'] .select2-container-multi .select2-choices .select2-search-choice { + margin: 3px 5px 3px 0; + padding: 3px 18px 3px 5px; +} + +.select2-container-multi .select2-choices .select2-search-choice .select2-chosen { + cursor: default; +} + +.select2-container-multi .select2-choices .select2-search-choice-focus { + background: #d4d4d4; +} + +.select2-search-choice-close { + display: block; + width: 12px; + height: 13px; + position: absolute; + right: 3px; + top: 4px; + + font-size: 1px; + outline: none; + /* stylelint-disable-next-line function-url-quotes */ + background: url(image-path('select2.png')) right top no-repeat; +} + +html[dir='rtl'] .select2-search-choice-close { + right: auto; + left: 3px; +} + +.select2-container-multi .select2-search-choice-close { + left: 3px; +} + +html[dir='rtl'] .select2-container-multi .select2-search-choice-close { + left: auto; + right: 2px; +} + +.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover { + background-position: right -11px; +} + +.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close { + background-position: right -11px; +} + +/* disabled styles */ +.select2-container-multi.select2-container-disabled .select2-choices { + background-color: #f4f4f4; + background-image: none; + border: 1px solid #ddd; + cursor: default; +} + +.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice { + padding: 3px 5px; + border: 1px solid #ddd; + background-image: none; + background-color: #f4f4f4; +} + +.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close { + display: none; + background: none; +} +/* end multiselect */ + + +.select2-result-selectable .select2-match, +.select2-result-unselectable .select2-match { + text-decoration: underline; +} + +.select2-offscreen, +.select2-offscreen:focus { + clip: rect(0 0 0 0) !important; + width: 1px !important; + height: 1px !important; + border: 0 !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; + position: absolute !important; + outline: 0 !important; + left: 0 !important; + top: 0 !important; +} + +.select2-display-none { + display: none; +} + +.select2-measure-scrollbar { + position: absolute; + top: -10000px; + left: -10000px; + width: 100px; + height: 100px; + overflow: scroll; +} + +@media only screen and (min-resolution: 120dpi) { + .select2-search input, + .select2-search-choice-close, + .select2-container .select2-choice abbr, + .select2-container .select2-choice .select2-arrow b { + /* stylelint-disable-next-line function-url-quotes */ + background-image: url(image-path("select2x2.png")) !important; + background-repeat: no-repeat !important; + background-size: 60px 40px !important; + } + + .select2-search input { + background-position: 100% -21px !important; + } +} + +/* End of select2.css */ + +@import './select2_overrides'; diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss new file mode 100644 index 00000000000..6c51c4b0ec3 --- /dev/null +++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss @@ -0,0 +1,359 @@ +@import 'page_bundles/mixins_and_variables_and_functions'; +/** 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: $border-color; + height: 34px; + padding: $gl-vert-padding $gl-input-padding; + font-size: $gl-font-size; + line-height: 1.42857143; + border-radius: $gl-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: $gl-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: $border-color; + 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: $border-color; + 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 $border-color; + 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; + } +} + +.select2-highlighted { + .group-result { + .group-path { + color: $gray-700; + } + } +} + +.select2-result-selectable, +.select2-result-unselectable { + .select2-match { + font-weight: $gl-font-weight-bold; + text-decoration: none; + } +} + +.input-group { + .select2-container { + display: table-cell; + max-width: 180px; + } +} + +.file-editor { + .select2 { + float: right; + } +} + +.import-namespace-select { + > .select2-choice { + border-radius: $border-radius-default 0 0 $border-radius-default; + position: relative; + left: 1px; + } +} + +.issue-form { + .select2-container { + width: 250px !important; + } +} + +.new_project, +.edit-project, +.import-project { + .input-group { + .select2-container { + display: unset; + max-width: unset; + flex-grow: 1; + } + } + + .input-group-prepend, + .input-group-append { + + .select2 a { + border-radius: 0 $gl-border-radius-base $gl-border-radius-base 0; + } + } +} + +.project-path { + .select2-choice { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +} + +.transfer-project .select2-container { + min-width: 200px; +} + +.right-sidebar { + .block { + .select2-container span { + margin-top: 0; + } + } +} + +.block-truncated { + > div:not(.block):not(.select2-display-none) { + display: inline; + } +} diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss index b2050c0e73f..27c6ef20269 100644 --- a/app/assets/stylesheets/mailer.scss +++ b/app/assets/stylesheets/mailer.scss @@ -143,4 +143,21 @@ tr.footer td { color: $mailer-link-color; text-decoration: none; } + + .gitlab-info { + padding: $gl-padding-24 0; + } + + .gitlab-info-text { + max-width: 640px; + margin: 0 auto; + text-align: center; + color: $gray-400; + font-size: $gl-font-size-small; + } + + .footer-logo { + width: 90px; + height: 33px; + } } diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss index d1f7c2e9865..52cc7d3449e 100644 --- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -5,7 +5,7 @@ $bs-input-focus-border: #80bdff; $bs-input-focus-box-shadow: rgba(0, 123, 255, 0.25); - a:not(.btn), + a:not(.btn):not(.gl-tab-nav-item), .gl-button.btn-link, .gl-button.btn-link:hover, .gl-button.btn-link:focus, @@ -151,7 +151,7 @@ border-color: var(--ide-border-color-alt, $gray-100); code { - background-color: var(--ide-border-color, inherit); + background-color: var(--ide-empty-state-background, inherit); } } @@ -427,7 +427,7 @@ } .md table:not(.code) tbody { - background-color: var(--ide-border-color, $white); + background-color: var(--ide-empty-state-background, $white); } .animation-container { diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/page_bundles/alert_management_details.scss index 514f228e223..beb80a14c5a 100644 --- a/app/assets/stylesheets/pages/alert_management/details.scss +++ b/app/assets/stylesheets/page_bundles/alert_management_details.scss @@ -1,24 +1,26 @@ +@import 'mixins_and_variables_and_functions'; + .alert-management-details { @include media-breakpoint-down(xs) { .alert-details-incident-button { - width: 100%; + @include gl-w-full; } } .toggle-sidebar-mobile-button { - right: 0; + @include gl-right-0; } .dropdown-menu-toggle { &:hover { - background-color: $white; + @include gl-bg-white; } } .assignee-dropdown-item { .dropdown-item { - display: flex; - align-items: center; + @include gl-display-flex; + @include gl-align-items-center; &::before { top: 50% !important; @@ -26,7 +28,9 @@ &.is-active { &:last-child { - border-bottom: 1px solid $gray-100; + @include gl-border-b-gray-100; + @include gl-border-b-1; + @include gl-border-b-solid; } } } diff --git a/app/assets/stylesheets/page_bundles/alert_management_settings.scss b/app/assets/stylesheets/page_bundles/alert_management_settings.scss new file mode 100644 index 00000000000..fb7c1602cba --- /dev/null +++ b/app/assets/stylesheets/page_bundles/alert_management_settings.scss @@ -0,0 +1,24 @@ +@import 'mixins_and_variables_and_functions'; + +$stroke-size: 1px; + +.right-arrow { + @include gl-relative; + @include gl-w-full; + height: $stroke-size; + @include gl-display-inline-block; + background-color: var(--gray-400, $gray-400); + min-width: $gl-spacing-scale-5; + + &-head { + @include gl-absolute; + top: -$gl-spacing-scale-2; + left: calc(100% - #{$gl-spacing-scale-3} - #{2 * $stroke-size}); + border-color: var(--gray-400, $gray-400); + @include gl-border-solid; + border-width: 0 $stroke-size $stroke-size 0; + @include gl-display-inline-block; + @include gl-p-2; + transform: rotate(-45deg); + } +} diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index e908e3622ed..ffc15af6329 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -83,9 +83,6 @@ } .board { - // the next line cannot be replaced with .d-inline-block because it breaks display: none of SortableJS - // see https://gitlab.com/gitlab-org/gitlab-foss/issues/64828 - display: inline-block; width: calc(85vw - 15px); @include media-breakpoint-up(sm) { @@ -116,39 +113,10 @@ &.is-collapsed { width: 50px; - .board-title { - flex-direction: column; - } - .board-title-caret { margin-top: 1px; } - .user-avatar-link, - .milestone-icon { - margin-top: $gl-padding-8; - transform: rotate(90deg); - } - - .board-title-text { - flex-grow: 0; - margin: $gl-padding-8 0; - - .board-title-main-text { - display: block; - } - - .board-title-sub-text { - display: none; - } - } - - .issue-count-badge { - border: 0; - white-space: nowrap; - padding: 0; - } - .board-title-text > span, .issue-count-badge > span { height: 16px; @@ -197,10 +165,7 @@ } .board-title { - align-items: center; - font-size: 1em; border-bottom: 1px solid var(--gray-100, $gray-100); - padding: 0 $gl-spacing-scale-3; height: 3rem; .js-max-issue-size::before { @@ -208,21 +173,6 @@ } } -.board-title-text { - flex-grow: 1; -} - -.board-delete.gl-button { - background-color: transparent; - outline: 0; - - &:hover { - color: var(--blue-600, $blue-600); - box-shadow: none; - } -} - -.board-blank-state, .board-promotion-state { background-color: var(--white, $white); flex: 1; @@ -230,19 +180,6 @@ overflow-x: hidden; } -.board-blank-state-list { - > li:not(:last-child) { - margin-bottom: 8px; - } - - .label-color { - top: 2px; - width: 16px; - height: 16px; - margin-right: 3px; - } -} - .board-list-component { min-height: 0; // firefox fix } @@ -311,10 +248,6 @@ } } -.board-card-header { - text-align: initial; -} - .board-card-assignee { margin-top: -$gl-padding-4; margin-bottom: -$gl-padding-4; @@ -586,28 +519,6 @@ } } -.board-swimlanes { - overflow-x: auto; -} - .board-header-collapsed-info-icon:hover { color: var(--gray-900, $gray-900); } - -$epic-icons-spacing: 40px; - -.board-epic-lane { - max-width: calc(100vw - #{$contextual-sidebar-width} - #{$epic-icons-spacing}); - - .page-with-icon-sidebar & { - max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$epic-icons-spacing}); - } - - .page-with-icon-sidebar .is-compact & { - max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$gutter-width} - #{$epic-icons-spacing}); - } - - .is-compact & { - max-width: calc(100vw - #{$contextual-sidebar-width} - #{$gutter-width} - #{$epic-icons-spacing}); - } -} diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/page_bundles/build.scss index d7b4db3840e..2f0f4a46658 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -1,45 +1,4 @@ -@keyframes fade-out-status { - 0%, - 50% { - opacity: 1; - } - - 100% { - opacity: 0; - } -} - -@keyframes blinking-dot { - 0% { - opacity: 1; - } - - 25% { - opacity: 0.4; - } - - 75% { - opacity: 0.4; - } - - 100% { - opacity: 1; - } -} - -@keyframes blinking-scroll-button { - 0% { - opacity: 0.2; - } - - 50% { - opacity: 1; - } - - 100% { - opacity: 0.2; - } -} +@import 'mixins_and_variables_and_functions'; .build-page { .build-trace { @@ -325,25 +284,11 @@ } } -.build-light-text { - color: $gl-text-color-secondary; - word-wrap: break-word; -} - -.build-gutter-toggle { - position: absolute; - top: 50%; - right: 0; - margin-top: -17px; -} - -@include media-breakpoint-down(sm) { - .top-bar { - .truncated-info { - white-space: nowrap; - overflow: hidden; - max-width: 220px; - text-overflow: ellipsis; +@include media-breakpoint-down(md) { + .content-list { + &.builds-content-list { + width: 100%; + overflow: auto; } } } diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss index b37c5172ad2..8522a0a8fe4 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/page_bundles/ci_status.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .ci-status { padding: 2px 7px 4px; border: 1px solid $gray-darker; diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 71e74297ee8..15cc10d1532 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -5,7 +5,9 @@ @import './ide_theme_overrides'; @import './ide_themes/dark'; +@import './ide_themes/solarized-light'; @import './ide_themes/solarized-dark'; +@import './ide_themes/monokai'; $search-list-icon-width: 18px; $ide-activity-bar-width: 60px; @@ -176,11 +178,11 @@ $ide-commit-header-height: 48px; height: 100%; overflow: auto; padding: $gl-padding; - background-color: var(--ide-border-color, transparent); + background-color: var(--ide-empty-state-background, transparent); } .file-container { - background-color: var(--ide-border-color, $gray-darker); + background-color: var(--ide-empty-state-background, $gray-darker); display: flex; height: 100%; align-items: center; @@ -491,7 +493,7 @@ $ide-commit-header-height: 48px; height: 100vh; align-items: center; justify-content: center; - background-color: var(--ide-border-color, transparent); + background-color: var(--ide-empty-state-background, transparent); } .ide { @@ -915,12 +917,6 @@ $ide-commit-header-height: 48px; } } -.ide-pipeline-list { - flex: 1; - overflow: auto; - padding: 0 $gl-padding; -} - .ide-pipeline-header { min-height: 55px; padding-left: $gl-padding; diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss index 41f9a8e6db7..c7aae77c412 100644 --- a/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss +++ b/app/assets/stylesheets/page_bundles/ide_themes/_dark.scss @@ -12,6 +12,7 @@ --ide-highlight-background: #252526; --ide-link-color: #428fdc; --ide-footer-background: #060606; + --ide-empty-state-background: var(--ide-border-color); --ide-input-border: #868686; --ide-input-background: transparent; @@ -35,6 +36,13 @@ --ide-btn-success-hover-border-width: 2px; --ide-btn-success-focus-box-shadow: 0 0 0 1px #2da160; + // Danger styles should be the same as default styles in dark theme + --ide-btn-danger-secondary-background: var(--ide-btn-default-background); + --ide-btn-danger-secondary-border: var(--ide-btn-default-border); + --ide-btn-danger-secondary-hover-border: var(--ide-btn-default-hover-border); + --ide-btn-danger-secondary-hover-border-width: var(--ide-btn-default-hover-border-width); + --ide-btn-danger-secondary-focus-box-shadow: var(--ide-btn-default-focus-box-shadow); + --ide-btn-disabled-background: transparent; --ide-btn-disabled-border: rgba(223, 223, 223, 0.24); --ide-btn-disabled-hover-border: rgba(223, 223, 223, 0.24); diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss b/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss new file mode 100644 index 00000000000..f53ace0b6c2 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/ide_themes/_monokai.scss @@ -0,0 +1,66 @@ +// ------- +// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes +// ------- +.ide.theme-monokai { + --ide-border-color: #1a1a18; + --ide-border-color-alt: #3f4237; + --ide-highlight-accent: #fff; + --ide-text-color: #ccc; + --ide-text-color-secondary: #b7b7b7; + --ide-background: #282822; + --ide-background-hover: #2d2d2d; + --ide-highlight-background: #1f1f1d; + --ide-link-color: #428fdc; + --ide-footer-background: #404338; + --ide-empty-state-background: #1a1a18; + + --ide-input-border: #7d8175; + --ide-input-background: transparent; + --ide-input-color: #fff; + + --ide-btn-default-background: transparent; + --ide-btn-default-border: #7d8175; + --ide-btn-default-hover-border: #b5bda5; + --ide-btn-default-hover-border-width: 2px; + --ide-btn-default-focus-box-shadow: 0 0 0 1px #bfbfbf; + + --ide-btn-primary-background: #1068bf; + --ide-btn-primary-border: #428fdc; + --ide-btn-primary-hover-border: #63a6e9; + --ide-btn-primary-hover-border-width: 2px; + --ide-btn-primary-focus-box-shadow: 0 0 0 1px #63a6e9; + + --ide-btn-success-background: #217645; + --ide-btn-success-border: #108548; + --ide-btn-success-hover-border: #2da160; + --ide-btn-success-hover-border-width: 2px; + --ide-btn-success-focus-box-shadow: 0 0 0 1px #2da160; + + // Danger styles should be the same as default styles in dark theme + --ide-btn-danger-secondary-background: var(--ide-btn-default-background); + --ide-btn-danger-secondary-border: var(--ide-btn-default-border); + --ide-btn-danger-secondary-hover-border: var(--ide-btn-default-hover-border); + --ide-btn-danger-secondary-hover-border-width: var(--ide-btn-default-hover-border-width); + --ide-btn-danger-secondary-focus-box-shadow: var(--ide-btn-default-focus-box-shadow); + + --ide-btn-disabled-background: transparent; + --ide-btn-disabled-border: rgba(223, 223, 223, 0.24); + --ide-btn-disabled-hover-border: rgba(223, 223, 223, 0.24); + --ide-btn-disabled-hover-border-width: 1px; + --ide-btn-disabled-focus-box-shadow: 0 0 0 0 transparent; + --ide-btn-disabled-color: rgba(145, 145, 145, 0.48); + + --ide-dropdown-background: #36382f; + --ide-dropdown-hover-background: #404338; + + --ide-dropdown-btn-hover-border: #b5bda5; + --ide-dropdown-btn-hover-background: #3f4237; + + --ide-file-row-btn-hover-background: #404338; + + --ide-diff-insert: rgba(155, 185, 85, 0.2); + --ide-diff-remove: rgba(255, 0, 0, 0.2); + + --ide-animation-gradient-1: #404338; + --ide-animation-gradient-2: #36382f; +} diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss index ccb6f7a333b..1906b3ca938 100644 --- a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss +++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss @@ -12,6 +12,7 @@ --ide-highlight-background: #003240; --ide-link-color: #73b9ff; --ide-footer-background: var(--ide-highlight-background); + --ide-empty-state-background: var(--ide-border-color); --ide-input-border: #d8d8d8; --ide-input-background: transparent; @@ -35,6 +36,13 @@ --ide-btn-success-hover-border-width: 2px; --ide-btn-success-focus-box-shadow: 0 0 0 1px #2da160; + // Danger styles should be the same as default styles in dark theme + --ide-btn-danger-secondary-background: var(--ide-btn-default-background); + --ide-btn-danger-secondary-border: var(--ide-btn-default-border); + --ide-btn-danger-secondary-hover-border: var(--ide-btn-default-hover-border); + --ide-btn-danger-secondary-hover-border-width: var(--ide-btn-default-hover-border-width); + --ide-btn-danger-secondary-focus-box-shadow: var(--ide-btn-default-focus-box-shadow); + --ide-btn-disabled-background: transparent; --ide-btn-disabled-border: rgba(223, 223, 223, 0.24); --ide-btn-disabled-hover-border: rgba(223, 223, 223, 0.24); diff --git a/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss new file mode 100644 index 00000000000..315a0ae6202 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/ide_themes/_solarized-light.scss @@ -0,0 +1,57 @@ +// ------- +// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes +// ------- +.ide.theme-solarized-light { + --ide-border-color: #dfd7bf; + --ide-border-color-alt: #dfd7bf; + --ide-highlight-accent: #5c4e21; + --ide-text-color: #616161; + --ide-text-color-secondary: #526f76; + --ide-background: #efe8d3; + --ide-background-hover: #ded6be; + --ide-highlight-background: #fef6e1; + --ide-link-color: #955800; + --ide-footer-background: #efe8d3; + --ide-empty-state-background: #fef6e1; + + --ide-input-border: #c0b9a4; + --ide-input-background: transparent; + + --ide-btn-default-background: transparent; + --ide-btn-default-border: #c0b9a4; + --ide-btn-default-hover-border: #c0b9a4; + + --ide-btn-primary-background: #b16802; + --ide-btn-primary-border: #a35f00; + --ide-btn-primary-hover-border: #955800; + --ide-btn-primary-hover-border-width: 2px; + --ide-btn-primary-focus-box-shadow: 0 0 0 1px #dd8101; + + --ide-btn-danger-secondary-background: transparent; + + --ide-btn-disabled-background: transparent; + --ide-btn-disabled-border: rgba(192, 185, 64, 0.48); + --ide-btn-disabled-hover-border: rgba(192, 185, 64, 0.48); + --ide-btn-disabled-hover-border-width: 1px; + --ide-btn-disabled-focus-box-shadow: transparent; + --ide-btn-disabled-color: rgba(82, 82, 82, 0.48); + + --ide-dropdown-background: #fef6e1; + --ide-dropdown-hover-background: #efe8d3; + + --ide-dropdown-btn-hover-border: #dfd7bf; + --ide-dropdown-btn-hover-background: #efe8d3; + + --ide-file-row-btn-hover-background: #ded6be; + + --ide-animation-gradient-1: #d3cbb3; + --ide-animation-gradient-2: #efe8d3; + + .ide-empty-state, + .ide-sidebar, + .ide-commit-empty-state { + img { + filter: sepia(1) brightness(0.7); + } + } +} diff --git a/app/assets/stylesheets/page_bundles/jira_connect.scss b/app/assets/stylesheets/page_bundles/jira_connect.scss index b8cdd120e04..c3e49da92a6 100644 --- a/app/assets/stylesheets/page_bundles/jira_connect.scss +++ b/app/assets/stylesheets/page_bundles/jira_connect.scss @@ -1,4 +1,6 @@ -@import 'framework/variables'; +@import 'mixins_and_variables_and_functions'; +// We should only import styles that we actually use. +// @import '@gitlab/ui/src/scss/gitlab_ui'; $atlaskit-border-color: #dfe1e6; diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index 5553dffac05..be74503c21f 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -11,9 +11,19 @@ } .diff-tree-list { + // This 11px value should match the additional value found in + // /assets/stylesheets/framework/diffs.scss + // for the $mr-file-header-top SCSS variable within the + // .file-title, + // .file-title-flex-parent { + // rule. + // If they don't match, the file tree and the diff files stick + // to the top at different heights, which is a bad-looking defect + $diff-file-header-top: 11px; + $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + $diff-file-header-top; + position: -webkit-sticky; position: sticky; - $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 15px; top: $top-pos; max-height: calc(100vh - #{$top-pos}); z-index: 202; diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index 8e7be629481..1de66aa73da 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -482,3 +482,7 @@ @include build-trace(); } } + +.progress-bar.bg-primary { + background-color: $blue-500 !important; +} diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss index 81716991a36..412971253ca 100644 --- a/app/assets/stylesheets/pages/pipeline_schedules.scss +++ b/app/assets/stylesheets/page_bundles/pipeline_schedules.scss @@ -1,3 +1,5 @@ +@import 'mixins_and_variables_and_functions'; + .pipeline-schedule-form { .gl-field-error { margin: 10px 0 0; @@ -32,11 +34,11 @@ } .next-run-cell { - color: $gl-text-color-secondary; + color: var(--gray-500, $gray-500); } a { - color: $text-color; + color: var(--gl-text-color, $gl-text-color); } svg { @@ -46,13 +48,9 @@ .pipeline-schedules-user-callout { .bordered-box.content-block { - border: 1px solid $border-color; + border: 1px solid var(--border-color, $border-color); background-color: transparent; - padding: 16px; - } - - #dismiss-callout-btn { - color: $gl-text-color; + padding: $gl-spacing-scale-5; } } diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss index 6ff07017d2e..e0e56893afc 100644 --- a/app/assets/stylesheets/page_bundles/pipelines.scss +++ b/app/assets/stylesheets/page_bundles/pipelines.scss @@ -5,6 +5,10 @@ * Pipelines Bundle: Pipeline lists and Mini Pipelines */ +.pipelines-container .top-area .nav-controls > .btn:last-child { + float: none; +} + // Pipelines list // Should affect pipelines table components rendered by: // - app/assets/javascripts/commit/pipelines/pipelines_bundle.js diff --git a/app/assets/stylesheets/page_bundles/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss index 5a9dd454970..18ca5f9a3a9 100644 --- a/app/assets/stylesheets/page_bundles/reports.scss +++ b/app/assets/stylesheets/page_bundles/reports.scss @@ -8,14 +8,14 @@ .report-block-list-issue-parent { padding: $gl-padding-top $gl-padding; - border-top: 1px solid $border-color; + border-top: 1px solid var(--border-color, $border-color); } } .report-block-container { - border-top: 1px solid $border-color; + border-top: 1px solid var(--border-color, $border-color); padding: $gl-padding - 2; - background-color: $gray-light; + background-color: var(--gray-50, $gray-10); // Clean MR widget CSS line-height: 20px; diff --git a/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss b/app/assets/stylesheets/page_bundles/signup.scss index 337b5b001fe..9ed48b693b9 100644 --- a/app/assets/stylesheets/page_bundles/experimental_separate_sign_up.scss +++ b/app/assets/stylesheets/page_bundles/signup.scss @@ -1,27 +1,6 @@ @import 'mixins_and_variables_and_functions'; .signup-page { - .page-wrap { - background-color: var(--gray-10, $gray-10); - } - - .signup-box-container { - max-width: 960px; - } - - .signup-box { - background-color: var(--white, $white); - box-shadow: 0 0 0 1px var(--border-color, $border-color); - border-radius: $border-radius; - } - - .form-control { - &:active, - &:focus { - background-color: var(--white, $white); - } - } - .devise-errors { h2 { font-size: $gl-font-size; diff --git a/app/assets/stylesheets/page_bundles/todos.scss b/app/assets/stylesheets/page_bundles/todos.scss index 3eec5b53a30..3e20ca9c62f 100644 --- a/app/assets/stylesheets/page_bundles/todos.scss +++ b/app/assets/stylesheets/page_bundles/todos.scss @@ -219,7 +219,6 @@ .todos-empty-content { align-self: center; max-width: 480px; - margin-right: 20px; } .todos-empty-hero { diff --git a/app/assets/stylesheets/pages/admin.scss b/app/assets/stylesheets/pages/admin.scss index f0acb78f731..af1eefd7587 100644 --- a/app/assets/stylesheets/pages/admin.scss +++ b/app/assets/stylesheets/pages/admin.scss @@ -8,3 +8,11 @@ .usage-data { max-height: 400px; } + +[data-page='admin:jobs:index'] { + .admin-builds-table { + td:last-child { + min-width: 120px; + } + } +} diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index c55bfeb7b15..17474b95e50 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -14,12 +14,6 @@ @extend %commit-description-base; } -.js-details-expand { - &:hover { - text-decoration: none; - } -} - .commit-box { border-top: 1px solid $border-color; padding: $gl-padding 0; @@ -30,17 +24,6 @@ } } -.commit-hash-full { - @include media-breakpoint-down(sm) { - width: 80px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: inline-block; - vertical-align: bottom; - } -} - .pipeline-info { .status-icon-container { display: inline-block; @@ -225,9 +208,9 @@ display: inline-flex; .label, - .btn { + .btn:not(.gl-button) { padding: $gl-vert-padding $gl-btn-padding; - border: 1px $border-color solid; + border: 1px $gray-200 solid; font-size: $gl-font-size; line-height: $line-height-base; border-radius: 0; diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 9f9964ac447..5c845c37e90 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -8,18 +8,18 @@ background: $gray-normal; } - #editor { - border: 0; - border-radius: 0; + #editor, + .editor { + @include gl-border-0; + @include gl-m-0; + @include gl-p-0; + @include gl-relative; + @include gl-w-full; height: 500px; - margin: 0; - padding: 0; - position: relative; - width: 100%; .editor-loading-content { - height: 100%; - border: 0; + @include gl-h-full; + @include gl-border-0; } } diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index ee4f74882a1..e73b6b18afd 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -43,12 +43,6 @@ } } -.ldap-group-links { - .form-actions { - margin-bottom: $gl-padding; - } -} - .save-group-loader { margin-top: $gl-padding-50; margin-bottom: $gl-padding-50; @@ -343,11 +337,11 @@ table.pipeline-project-metrics tr td { } .user-access-role { + @include gl-px-3; display: inline-block; color: $gl-text-color-secondary; font-size: 12px; line-height: 20px; - padding: 0 $label-padding; border: 1px solid $border-color; border-radius: $label-border-radius; font-weight: $gl-font-weight-normal; diff --git a/app/assets/stylesheets/pages/incident_management_list.scss b/app/assets/stylesheets/pages/incident_management_list.scss index c0a1fa72b1f..ba363e2d119 100644 --- a/app/assets/stylesheets/pages/incident_management_list.scss +++ b/app/assets/stylesheets/pages/incident_management_list.scss @@ -8,13 +8,12 @@ @include gl-text-gray-500; tbody { - tr:not(.b-table-busy-slot) { - // TODO replace with gitlab/ui utilities: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1791 + tr:not(.b-table-busy-slot):not(.b-table-empty-row) { &:hover { - border-top-style: double; + @include gl-border-t-double; td { - border-bottom-style: initial; + @include gl-border-b-initial; } } } @@ -22,7 +21,7 @@ tr { &:focus { - outline: none; + @include gl-outline-none; } td, @@ -118,26 +117,34 @@ } .gl-tabs-nav { - border-bottom-width: 0; + @include gl-border-b-0; .gl-tab-nav-item { - color: $gray-500; + @include gl-text-gray-500; > .gl-tab-counter-badge { - color: inherit; + @include gl-reset-color; @include gl-font-sm; - background-color: $gray-50; + @include gl-bg-gray-50; } } } @include media-breakpoint-down(xs) { .list-header { - flex-direction: column-reverse; + @include gl-flex-direction-column-reverse; } .create-incident-button { @include gl-w-full; } } + + .integration-list { + .b-table-empty-row { + td { + @include gl-px-0; + } + } + } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7097c2b10c4..cc4827f75d4 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -1,16 +1,12 @@ .issuable-warning-icon { background-color: $orange-50; border-radius: $border-radius-default; + color: $orange-600; width: $issuable-warning-size; height: $issuable-warning-size; text-align: center; margin-right: $issuable-warning-icon-margin; line-height: $gl-line-height-24; - - .icon { - fill: $orange-600; - vertical-align: text-bottom; - } } .limit-container-width { @@ -77,14 +73,6 @@ } } -.issuable-filter-count { - span { - display: block; - margin-bottom: -16px; - padding: 13px 0; - } -} - .issuable-show-labels { .gl-label { margin-bottom: 5px; @@ -662,12 +650,6 @@ } } -.issuable-form-padding-top { - @include media-breakpoint-up(sm) { - padding-top: 7px; - } -} - .issuable-status-box { align-self: stretch; display: flex; @@ -822,11 +804,7 @@ } } -.time_tracker { - padding-bottom: 0; - border-bottom: 0; - - +.time-tracker { .sidebar-collapsed-icon { > .stopwatch-svg { display: inline-block; @@ -939,6 +917,25 @@ } } +/* + * Following overrides are done to prevent + * legacy dropdown styles from influencing + * GitLab UI components used within GlDropdown + */ +.issuable-move-dropdown { + .b-dropdown-form { + @include gl-p-0; + } + + .gl-search-box-by-type button.gl-clear-icon-button:hover { + @include gl-bg-transparent; + } + + .issuable-move-button:not(.disabled):hover { + @include gl-text-white; + } +} + .right-sidebar-collapsed { .sidebar-grouped-item { .sidebar-collapsed-icon { diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index d2eb00c4a4d..08faebc8ec0 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -67,7 +67,6 @@ ul.related-merge-requests > li { } } -.merge-request-ci-status, .related-merge-requests { .ci-status-link { display: block; @@ -93,11 +92,6 @@ ul.related-merge-requests > li { } } -.issues-footer { - padding-top: $gl-padding; - padding-bottom: 37px; -} - .issues-nav-controls, .new-branch-col { font-size: 0; @@ -194,6 +188,12 @@ ul.related-merge-requests > li { border-width: 1px; line-height: $line-height-base; width: auto; + + &.disabled { + background-color: $gray-light; + border-color: $gray-100; + color: $gl-text-color-disabled; + } } } diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 31606cb3ba5..4d93702f1c2 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -92,13 +92,8 @@ margin-bottom: 0; } - &.sortable-ghost { - opacity: 0.3; - } - .prioritized-labels:not(.is-not-draggable) & { box-shadow: 0 1px 2px $issue-boards-card-shadow; - cursor: move; cursor: grab; border: 0; @@ -108,126 +103,20 @@ } } - .btn-action { - .fa { - font-size: 18px; - vertical-align: middle; - pointer-events: none; - } - - &:hover { - color: $blue-600; - - &.remove-row { - color: $red-500; - } - } - } - - .color-label { - padding: $gl-padding-4 $grid-size; - } - .prepend-description-left { vertical-align: top; line-height: 24px; } } -.prioritized-labels { - margin-bottom: 30px; - - .add-priority { - display: none; - color: $gray-light; - } - - li:hover { - .draggable-handler { - display: inline-block; - opacity: 1; - } - } -} - -.other-labels { - .remove-priority { - display: none; - } -} - -.filtered-labels { - font-size: 0; - padding: 12px 16px; - - .label-row { - margin-top: 4px; - margin-bottom: 4px; - - &:not(:last-child) { - margin-right: 8px; - } - } - - .label-remove { - border-left: 1px solid $label-remove-border; - z-index: 3; - border-radius: $label-border-radius; - padding: 6px 10px 6px 9px; - - &:hover { - box-shadow: inset 0 0 0 80px $label-remove-border; - } - } - - .btn { - color: inherit; - } - - a.btn { - padding: 0; - - .has-tooltip { - top: 0; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - line-height: 1.1; - } - } -} - -.label-subscription { - vertical-align: middle; - - .dropdown-group-label a { - cursor: pointer; - } +.prioritized-labels .add-priority, +.other-labels .remove-priority { + display: none; } .label-subscribe-button { width: 105px; font-weight: 200; - - .label-subscribe-button-icon { - &[disabled] { - opacity: 0.5; - pointer-events: none; - } - } - - .label-subscribe-button-loading { - display: none; - } - - &.disabled { - .label-subscribe-button-icon { - display: none; - } - - .label-subscribe-button-loading { - display: block; - } - } } .labels-container { @@ -255,11 +144,6 @@ } .label-list-item { - .content-list &::before, - .content-list &::after { - content: none; - } - .label-name { width: 200px; @@ -268,37 +152,16 @@ } } - .label { - padding: 4px $grid-size; - font-size: $label-font-size; - position: relative; - top: $gl-padding-4; - } - .label-action { color: $gray-700; cursor: pointer; - svg { - fill: $gray-700; - } - &:hover { color: $blue-600; - - svg { - fill: $blue-600; - } } - &.remove-row { - &:hover { - color: $red-500; - - svg { - fill: $red-500; - } - } + &.remove-row:hover { + color: $red-500; } } } diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 922f95ff5df..a8b489f1273 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -225,9 +225,14 @@ } .col-actions { - width: px-to-rem(50px); + width: px-to-rem(65px); } } + + .gl-datepicker-input { + width: px-to-rem(165px); + max-width: 100%; + } } .card-mobile { diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 6f71177e870..a0ac55e4c6c 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -463,8 +463,7 @@ $mr-widget-min-height: 69px; .mr-list { .merge-request { - padding: 10px 0 10px 15px; - position: relative; + padding: 10px $gl-padding; display: flex; .issuable-info-container { @@ -737,14 +736,6 @@ $mr-widget-min-height: 69px; border-bottom: 0; } - .comments-disabled-notif { - line-height: 28px; - - .btn { - margin-left: 5px; - } - } - .mr-version-dropdown, .mr-version-compare-dropdown { margin: 0 7px; @@ -1048,9 +1039,3 @@ $mr-widget-min-height: 69px; .diff-file-row.is-active { background-color: $gray-50; } - -.merge-request-container { - .flash-container { - @include gl-mb-4; - } -} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 8b3d3268a8c..0c24ea9ccc6 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -2,6 +2,7 @@ * Note Form */ .diff-file .diff-content { + .diff-tr.line_holder:hover > .diff-td .line_note_link, tr.line_holder:hover > td .line_note_link { opacity: 1; filter: alpha(opacity = 100); @@ -226,10 +227,6 @@ table { display: none; } -.parallel-comment { - padding: 6px; -} - .error-alert > .alert { margin-top: 5px; margin-bottom: 5px; @@ -311,31 +308,12 @@ table { } } -.discussion-notes-count { - font-size: 16px; -} - -.edit_note { - .markdown-area { - min-height: 140px; - max-height: 500px; - } - - .note-form-actions { - background: transparent; - } -} - .comment-toolbar { padding-top: $gl-padding-top; color: $gl-text-color-secondary; border-top: 1px solid $border-color; } -.md-helper { - padding-top: 10px; -} - .toolbar-button { padding: 0; background: none; @@ -473,31 +451,6 @@ table { margin-right: 5px; } -.attach-new-file, -.button-attach-file, -.retry-uploading-link { - color: $blue-600; - padding: 0; - background: none; - border: 0; - font-size: 14px; - line-height: 16px; - vertical-align: initial; - - &:hover, - &:focus { - text-decoration: none; - - .text-attach-file { - text-decoration: underline; - } - } - - .gl-icon:not(:last-child) { - margin-right: 0; - } -} - .markdown-selector { color: $blue-600; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index b510822a20a..e23ec25a2f3 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -453,6 +453,8 @@ $note-form-margin-left: 72px; } .diff-file { + .diff-grid-left:hover, + .diff-grid-right:hover, .is-over { .add-diff-note { display: inline-flex; @@ -490,6 +492,7 @@ $note-form-margin-left: 72px; .notes_holder { font-family: $regular-font; + .diff-td, td { border: 1px solid $border-color; border-left: 0; @@ -798,21 +801,15 @@ $note-form-margin-left: 72px; } .note-role { - margin: 0 3px; -} - -.note-role-special { - position: relative; - display: inline-block; - color: $gl-text-color-secondary; - font-size: 12px; - text-shadow: 0 0 15px $gl-text-color-inverted; + margin: 0 8px; } /** * Line note button on the side of diffs */ +.diff-grid-left:hover, +.diff-grid-right:hover, .line_holder .is-over:not(.no-comment-btn) { .add-diff-note { opacity: 1; @@ -895,6 +892,15 @@ $note-form-margin-left: 72px; outline: 0; transition: color $general-hover-transition-duration $general-hover-transition-curve; + &[disabled] { + padding: 0 8px !important; + box-shadow: none !important; + + .gl-button-loading-indicator { + margin-right: 0 !important; + } + } + &.is-disabled { cursor: default; } @@ -902,16 +908,22 @@ $note-form-margin-left: 72px; &:not(.is-disabled) { &:hover, &:focus { - color: $green-600; + svg { + color: $green-600; + } } } &.is-active { - color: $green-600; + svg { + @include gl-text-green-500; + } &:hover, &:focus { - color: $green-700; + svg { + color: $green-700; + } } } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 2df43b861b2..b37aa6cd285 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -1,11 +1,17 @@ -@include media-breakpoint-down(md) { - .content-list { - &.builds-content-list { - width: 100%; - overflow: auto; - } - } -} +/** + * !! NOTE: Do not add more code in this file: + * + * https://gitlab.com/gitlab-org/gitlab/-/issues/267602 + * + * For new pipeline CSS please consider: + * + * For pipelines tables and lists: + * - `app/assets/stylesheets/page_bundles/pipelines.scss` + * + * For individual pipelines and mini-pipelines: + * - `app/assets/stylesheets/page_bundles/pipeline.scss` + * +**/ .ci-table { .avatar { @@ -81,38 +87,13 @@ } } -[data-page='admin:jobs:index'] { - .admin-builds-table { - td:last-child { - min-width: 120px; +@include media-breakpoint-down(sm) { + .top-bar { + .truncated-info { + white-space: nowrap; + overflow: hidden; + max-width: 220px; + text-overflow: ellipsis; } } } - -.pipelines-container .top-area .nav-controls > .btn:last-child { - float: none; -} - -.progress-bar.bg-primary { - background-color: $blue-500 !important; -} - -.pipeline-stage-pill { - width: 10rem; -} - -.pipeline-job-pill { - width: 8rem; -} - -.stage-rounded { - border-radius: 2rem; -} - -.stage-left-rounded { - border-radius: 2rem 0 0 2rem; -} - -.stage-right-rounded { - border-radius: 0 2rem 2rem 0; -} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 938d8d34717..09501d3713d 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -252,15 +252,6 @@ } } -.split-one { - display: inline-table; - margin-right: 12px; - - > a { - margin: -1px; - } -} - .save-project-loader { margin-top: 50px; margin-bottom: 50px; @@ -505,92 +496,6 @@ } } -.create-project-options { - display: flex; - - @include media-breakpoint-down(xs) { - display: block; - } - - .first-column { - @include media-breakpoint-up(xs) { - max-width: 50%; - padding-right: 30px; - } - - @include media-breakpoint-down(xs) { - max-width: 100%; - width: 100%; - } - } - - .second-column { - @include media-breakpoint-up(xs) { - width: 50%; - flex: 1; - padding-left: 30px; - position: relative; - } - - @include media-breakpoint-down(xs) { - max-width: 100%; - width: 100%; - padding-left: 0; - position: relative; - } - - // Mobile - @include media-breakpoint-down(xs) { - padding-top: 30px; - } - - &::before { - content: 'OR'; - position: absolute; - left: -10px; - top: 50%; - z-index: 10; - padding: $gl-padding-8 0; - text-align: center; - background-color: $white; - color: $gl-text-color-tertiary; - transform: translateY(-50%); - font-size: 12px; - font-weight: $gl-font-weight-bold; - line-height: 20px; - - // Mobile - @include media-breakpoint-down(xs) { - left: 50%; - top: 0; - transform: translateX(-50%); - padding: 0 $gl-padding-8; - } - } - - &::after { - content: ''; - position: absolute; - background-color: $border-color; - bottom: 0; - left: 0; - right: auto; - height: 100%; - width: 1px; - top: 0; - - // Mobile - @include media-breakpoint-down(xs) { - top: 10px; - left: 10px; - right: 10px; - height: 1px; - width: auto; - } - } - } -} - .project-stats, .project-buttons { .scrolling-tabs-container { @@ -754,17 +659,6 @@ pre.light-well { } } -.project-footer { - margin-top: 20px; - - .btn-remove { - @include btn-middle; - @include btn-red; - - float: left !important; - } -} - /* * Projects list rendered on dashboard and user page */ @@ -1059,24 +953,6 @@ pre.light-well { } } -.cannot-be-merged, -.cannot-be-merged:hover { - color: $red-500; - margin-top: 2px; - position: relative; - z-index: 2; -} - -.private-forks-notice .private-fork-icon { - i:nth-child(1) { - color: $green-600; - } - - i:nth-child(2) { - color: $white; - } -} - .new-protected-branch, .new-protected-tag { label { @@ -1117,23 +993,6 @@ pre.light-well { } } -.custom-notifications-form { - .is-loading { - .custom-notification-event-loading { - display: inline-block; - } - } -} - -.custom-notification-event-loading { - display: none; - margin-left: 5px; - - &.is-done { - color: $green-600; - } -} - .project-refs-form .dropdown-menu, .dropdown-menu-projects { width: 300px; @@ -1233,34 +1092,6 @@ pre.light-well { } } -.variables-table { - table-layout: fixed; - - &.table-responsive { - border: 0; - } - - .variable-key { - max-width: 120px; - overflow: hidden; - word-wrap: break-word; - white-space: nowrap; - text-overflow: ellipsis; - } - - .variable-value { - max-width: 150px; - overflow: hidden; - word-wrap: break-word; - white-space: nowrap; - text-overflow: ellipsis; - } - - .variable-menu { - text-align: right; - } -} - .services-installation-info .row { margin-bottom: 10px; } @@ -1286,18 +1117,6 @@ pre.light-well { padding-bottom: 37px; } -.project-ci-body { - .incorrect-syntax { - font-size: 18px; - color: $red-500; - } - - .correct-syntax { - font-size: 18px; - color: $green-500; - } -} - .project-ci-linter { .ci-editor { height: 400px; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index a62e28a9b8a..502a1881fd2 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -270,7 +270,8 @@ input[type='checkbox']:hover { width: 100%; } - .dropdown-menu-toggle { + .dropdown-menu-toggle, + .gl-new-dropdown { @include media-breakpoint-up(lg) { width: 240px; } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 73fe76f139f..429181c2ad4 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -62,7 +62,7 @@ .tree-controls { margin-bottom: 10px; - .btn, + .btn:not(.dropdown-toggle-split), .dropdown, .btn-group { width: 100%; diff --git a/app/assets/stylesheets/pages/users.scss b/app/assets/stylesheets/pages/users.scss index 0863b573f75..917d16a9c53 100644 --- a/app/assets/stylesheets/pages/users.scss +++ b/app/assets/stylesheets/pages/users.scss @@ -51,43 +51,6 @@ outline: 0; } -.flex-users-panel { - display: flex; - flex-direction: row; - align-items: center; - justify-content: center; - - @include media-breakpoint-down(sm) { - display: block; - - .flex-project-title { - vertical-align: top; - display: inline-block; - max-width: 90%; - } - } - - .flex-project-title { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - .badge.badge-pill { - height: 17px; - line-height: 16px; - margin-right: 5px; - padding-top: 1px; - padding-bottom: 1px; - } - - .flex-users-form { - flex-wrap: nowrap; - white-space: nowrap; - margin-left: auto; - } -} - .content-list.members-list li { display: flex; justify-content: space-between; diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index dc127cd2554..c6c9f3b7365 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -6,7 +6,7 @@ left: 0; top: 0; width: 100%; - z-index: #{$zindex-modal-backdrop + 1}; + z-index: #{$zindex-modal-backdrop - 1}; height: $performance-bar-height; background: $black; diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index 9ed1600419d..7b66b61ff36 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -31,7 +31,7 @@ nav.navbar-collapse.collapse, .nav, .btn, ul.notes-form, -.merge-request-ci-status .ci-status-link::after, +.ci-status-link::after, .issuable-gutter-toggle, .gutter-toggle, .issuable-details .content-block-small, diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss index 66cc4452858..6ab02bd5e27 100644 --- a/app/assets/stylesheets/themes/_dark.scss +++ b/app/assets/stylesheets/themes/_dark.scss @@ -201,6 +201,15 @@ $line-removed-dark: $red-200; // Misc component overrides that should live elsewhere .gl-label { filter: brightness(0.9) contrast(1.1); + + // This applies to the gl-label markups + // rendered and cached in the backend (labels_helper.rb) + &.gl-label-scoped { + .gl-label-text-scoped, + .gl-label-close { + color: $gray-900; + } + } } // white-ish text for light labels @@ -210,6 +219,15 @@ $line-removed-dark: $red-200; color: $gray-900; } +// This applies to "gl-labels" from "gitlab-ui" +.gl-label.gl-label-scoped.gl-label-text-dark, +.gl-label.gl-label-scoped.gl-label-text-light { + .gl-label-text-scoped, + .gl-label-close { + color: $gray-900; + } +} + // duplicated class as the original .atwho-view style is added later .atwho-view.atwho-view { background-color: $white; diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index e236c264f5c..a3bb7c868df 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -122,3 +122,10 @@ margin-left: $gl-spacing-scale-3; } } + +// This is used to help prevent issues with margin collapsing. +// See https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing. +.gl-force-block-formatting-context::after { + content: ''; + display: flex; +} |