diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-12 19:26:10 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-12 19:26:10 +0300 |
commit | 6653ccc011dec86e5140a5d09ea3b2357eab6714 (patch) | |
tree | 897193f37bcd98152a0ac214f80a3c4cfe1047c5 /app/assets/javascripts | |
parent | bff35a05aed6a31380a73c39113808fd262c2c37 (diff) |
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc41
Diffstat (limited to 'app/assets/javascripts')
568 files changed, 12159 insertions, 5677 deletions
diff --git a/app/assets/javascripts/access_tokens/components/projects_field.vue b/app/assets/javascripts/access_tokens/components/projects_field.vue new file mode 100644 index 00000000000..066cea5e90c --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/projects_field.vue @@ -0,0 +1,69 @@ +<script> +import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui'; +import ProjectsTokenSelector from './projects_token_selector.vue'; + +export default { + name: 'ProjectsField', + ALL_PROJECTS: 'ALL_PROJECTS', + SELECTED_PROJECTS: 'SELECTED_PROJECTS', + components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector }, + props: { + inputAttrs: { + type: Object, + required: true, + }, + }, + data() { + return { + selectedRadio: !this.inputAttrs.value + ? this.$options.ALL_PROJECTS + : this.$options.SELECTED_PROJECTS, + selectedProjects: [], + }; + }, + computed: { + allProjectsRadioSelected() { + return this.selectedRadio === this.$options.ALL_PROJECTS; + }, + hiddenInputValue() { + return this.allProjectsRadioSelected + ? null + : this.selectedProjects.map((project) => project.id).join(','); + }, + initialProjectIds() { + if (!this.inputAttrs.value) { + return []; + } + + return this.inputAttrs.value.split(','); + }, + }, + methods: { + handleTokenSelectorFocus() { + this.selectedRadio = this.$options.SELECTED_PROJECTS; + }, + }, +}; +</script> + +<template> + <div> + <gl-form-group :label="__('Projects')" label-class="gl-pb-0!"> + <gl-form-text class="gl-pb-3">{{ + __('Set access permissions for this token.') + }}</gl-form-text> + <gl-form-radio v-model="selectedRadio" :value="$options.ALL_PROJECTS">{{ + __('All projects') + }}</gl-form-radio> + <gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{ + __('Selected projects') + }}</gl-form-radio> + <input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" /> + <projects-token-selector + v-model="selectedProjects" + :initial-project-ids="initialProjectIds" + @focus="handleTokenSelectorFocus" + /> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue new file mode 100644 index 00000000000..cc5532696c7 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue @@ -0,0 +1,158 @@ +<script> +import { + GlTokenSelector, + GlAvatar, + GlAvatarLabeled, + GlIntersectionObserver, + GlLoadingIcon, +} from '@gitlab/ui'; +import produce from 'immer'; + +import { convertToGraphQLIds, convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; + +import getProjectsQuery from '../graphql/queries/get_projects.query.graphql'; + +const DEBOUNCE_DELAY = 250; +const PROJECTS_PER_PAGE = 20; +const GRAPHQL_ENTITY_TYPE = 'Project'; + +export default { + name: 'ProjectsTokenSelector', + components: { + GlTokenSelector, + GlAvatar, + GlAvatarLabeled, + GlIntersectionObserver, + GlLoadingIcon, + }, + model: { + prop: 'selectedProjects', + }, + props: { + selectedProjects: { + type: Array, + required: true, + }, + initialProjectIds: { + type: Array, + required: true, + }, + }, + apollo: { + projects: { + query: getProjectsQuery, + debounce: DEBOUNCE_DELAY, + variables() { + return { + search: this.searchQuery, + after: null, + first: PROJECTS_PER_PAGE, + }; + }, + update({ projects }) { + return { + list: convertNodeIdsFromGraphQLIds(projects.nodes), + pageInfo: projects.pageInfo, + }; + }, + result() { + this.isLoadingMoreProjects = false; + this.isSearching = false; + }, + }, + initialProjects: { + query: getProjectsQuery, + variables() { + return { + ids: convertToGraphQLIds(GRAPHQL_ENTITY_TYPE, this.initialProjectIds), + }; + }, + manual: true, + skip() { + return !this.initialProjectIds.length; + }, + result({ data: { projects } }) { + this.$emit('input', convertNodeIdsFromGraphQLIds(projects.nodes)); + }, + }, + }, + data() { + return { + projects: { + list: [], + pageInfo: {}, + }, + searchQuery: '', + isLoadingMoreProjects: false, + isSearching: false, + }; + }, + methods: { + handleSearch(query) { + this.isSearching = true; + this.searchQuery = query; + }, + loadMoreProjects() { + this.isLoadingMoreProjects = true; + + this.$apollo.queries.projects.fetchMore({ + variables: { + after: this.projects.pageInfo.endCursor, + first: PROJECTS_PER_PAGE, + }, + updateQuery(previousResult, { fetchMoreResult: { projects: newProjects } }) { + const { projects: previousProjects } = previousResult; + + return produce(previousResult, (draftData) => { + /* eslint-disable no-param-reassign */ + draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes]; + draftData.projects.pageInfo = newProjects.pageInfo; + /* eslint-enable no-param-reassign */ + }); + }, + }); + }, + }, +}; +</script> + +<template> + <div class="gl-relative"> + <gl-token-selector + :selected-tokens="selectedProjects" + :dropdown-items="projects.list" + :loading="isSearching" + :placeholder="__('Select projects')" + menu-class="gl-w-full! gl-max-w-full!" + @input="$emit('input', $event)" + @focus="$emit('focus', $event)" + @text-input="handleSearch" + @keydown.enter.prevent + > + <template #token-content="{ token: project }"> + <gl-avatar + :entity-id="project.id" + :entity-name="project.name" + :src="project.avatarUrl" + :size="16" + /> + {{ project.nameWithNamespace }} + </template> + <template #dropdown-item-content="{ dropdownItem: project }"> + <gl-avatar-labeled + :entity-id="project.id" + :entity-name="project.name" + :size="32" + :src="project.avatarUrl" + :label="project.name" + :sub-label="project.nameWithNamespace" + /> + </template> + <template #dropdown-footer> + <gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects"> + <gl-loading-icon v-if="isLoadingMoreProjects" size="md" /> + </gl-intersection-observer> + </template> + </gl-token-selector> + </div> +</template> diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql new file mode 100644 index 00000000000..60110437ecd --- /dev/null +++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql @@ -0,0 +1,28 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getProjects( + $search: String = "" + $after: String = "" + $first: Int = null + $ids: [ID!] = null +) { + projects( + search: $search + after: $after + first: $first + ids: $ids + membership: true + searchNamespaces: true + sort: "UPDATED_ASC" + ) { + nodes { + id + name + nameWithNamespace + avatarUrl + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index b4353af30d5..43d56295f78 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -1,4 +1,7 @@ import Vue from 'vue'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + import ExpiresAtField from './components/expires_at_field.vue'; const getInputAttrs = (el) => { @@ -7,11 +10,12 @@ const getInputAttrs = (el) => { return { id: input.id, name: input.name, + value: input.value, placeholder: input.placeholder, }; }; -const initExpiresAtField = () => { +export const initExpiresAtField = () => { const el = document.querySelector('.js-access-tokens-expires-at'); if (!el) { @@ -32,4 +36,58 @@ const initExpiresAtField = () => { }); }; -export default initExpiresAtField; +export const initProjectsField = () => { + const el = document.querySelector('.js-access-tokens-projects'); + + if (!el) { + return null; + } + + const inputAttrs = getInputAttrs(el); + + if (window.gon.features.personalAccessTokensScopedToProjects) { + return new Promise((resolve) => { + Promise.all([ + import('./components/projects_field.vue'), + import('vue-apollo'), + import('~/lib/graphql'), + ]) + .then( + ([ + { default: ProjectsField }, + { default: VueApollo }, + { default: createDefaultClient }, + ]) => { + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + Vue.use(VueApollo); + + resolve( + new Vue({ + el, + apolloProvider, + render(h) { + return h(ProjectsField, { + props: { + inputAttrs, + }, + }); + }, + }), + ); + }, + ) + .catch(() => { + createFlash({ + message: __( + 'An error occurred while loading the access tokens form, please try again.', + ), + }); + }); + }); + } + + return null; +}; diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js b/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js deleted file mode 100644 index ae73033079d..00000000000 --- a/app/assets/javascripts/admin/dev_ops_report/devops_adoption.js +++ /dev/null @@ -1,2 +0,0 @@ -// EE-specific feature. Find the implementation in the `ee/`-folder -export default () => {}; diff --git a/app/assets/javascripts/admin/users/tabs.js b/app/assets/javascripts/admin/users/tabs.js index 9ada77396c7..cbaab7df4e9 100644 --- a/app/assets/javascripts/admin/users/tabs.js +++ b/app/assets/javascripts/admin/users/tabs.js @@ -1,11 +1,20 @@ +import Api from '~/api'; import { historyPushState } from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; const COHORTS_PANE = 'cohorts'; +const COHORTS_PANE_TAB_CLICK_EVENT = 'i_analytics_cohorts'; const tabClickHandler = (e) => { const { hash } = e.currentTarget; - const tab = hash === `#${COHORTS_PANE}` ? COHORTS_PANE : null; + + let tab = null; + + if (hash === `#${COHORTS_PANE}`) { + tab = COHORTS_PANE; + Api.trackRedisHllUserEvent(COHORTS_PANE_TAB_CLICK_EVENT); + } + const newUrl = mergeUrlParams({ tab }, window.location.href); historyPushState(newUrl); }; diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index dd702c4a5d3..79a6bac3ba7 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -42,6 +42,7 @@ export default { "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.", ), unassigned: __('Unassigned'), + closed: __('closed'), }, fields: [ { @@ -75,7 +76,7 @@ export default { { key: 'issue', label: s__('AlertManagement|Incident'), - thClass: 'gl-w-12 gl-pointer-events-none', + thClass: 'gl-w-15p gl-pointer-events-none', tdClass, }, { @@ -221,8 +222,11 @@ export default { hasAssignees(assignees) { return Boolean(assignees.nodes?.length); }, - getIssueLink(item) { - return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid); + getIssueMeta({ issue: { iid, state } }) { + return { + state: state === 'closed' ? `(${this.$options.i18n.closed})` : '', + link: joinPaths('/', this.projectPath, '-', 'issues/incident', iid), + }; }, tbodyTrClass(item) { return { @@ -343,8 +347,14 @@ export default { </template> <template #cell(issue)="{ item }"> - <gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)"> - #{{ item.issueIid }} + <gl-link + v-if="item.issue" + v-gl-tooltip + :title="item.issue.title" + data-testid="issueField" + :href="getIssueMeta(item).link" + > + #{{ item.issue.iid }} {{ getIssueMeta(item).state }} </gl-link> <div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div> </template> diff --git a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue index 1135562834a..07b2e59671e 100644 --- a/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue +++ b/app/assets/javascripts/alerts_settings/components/alert_mapping_builder.vue @@ -7,15 +7,12 @@ import { GlSearchBoxByType, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; -import { cloneDeep } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import Vue from 'vue'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { s__, __ } from '~/locale'; -import { - getMappingData, - getPayloadFields, - transformForSave, -} from '../utils/mapping_transformations'; +import { mappingFields } from '../constants'; +import { getMappingData, transformForSave } from '../utils/mapping_transformations'; export const i18n = { columns: { @@ -33,6 +30,7 @@ export const i18n = { export default { i18n, + mappingFields, components: { GlIcon, GlFormInput, @@ -73,18 +71,15 @@ export default { }; }, computed: { - payloadFields() { - return getPayloadFields(this.parsedPayload); - }, mappingData() { - return getMappingData(this.gitlabFields, this.payloadFields, this.savedMapping); + return getMappingData(this.gitlabFields, this.parsedPayload, this.savedMapping); }, hasFallbackColumn() { return this.gitlabFields.some(({ numberOfFallbacks }) => Boolean(numberOfFallbacks)); }, }, methods: { - setMapping(gitlabKey, mappingKey, valueKey) { + setMapping(gitlabKey, mappingKey, valueKey = mappingFields.mapping) { const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey); const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } }; Vue.set(this.gitlabFields, fieldIndex, updatedField); @@ -100,11 +95,11 @@ export default { return fields.filter((field) => field.label.toLowerCase().includes(search)); }, isSelected(fieldValue, mapping) { - return fieldValue === mapping; + return isEqual(fieldValue, mapping); }, - selectedValue(name) { + selectedValue(mapping) { return ( - this.payloadFields.find((item) => item.name === name)?.label || + this.parsedPayload.find((item) => isEqual(item.path, mapping))?.label || this.$options.i18n.makeSelection ); }, @@ -150,7 +145,7 @@ export default { :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"> + <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle"> <gl-form-input aria-labelledby="gitlabFieldsHeader" disabled @@ -164,7 +159,7 @@ export default { </div> </div> - <div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle"> + <div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle"> <gl-dropdown :disabled="!gitlabField.mappingFields.length" aria-labelledby="parsedFieldsHeader" @@ -175,10 +170,10 @@ export default { <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)" + :key="`${mappingField.path}__mapping`" + :is-checked="isSelected(gitlabField.mapping, mappingField.path)" is-check-item - @click="setMapping(gitlabField.name, mappingField.name, 'mapping')" + @click="setMapping(gitlabField.name, mappingField.path)" > {{ mappingField.label }} </gl-dropdown-item> @@ -188,7 +183,7 @@ export default { </gl-dropdown> </div> - <div class="gl-display-table-cell gl-py-3 w-30p"> + <div class="gl-display-table-cell gl-py-3 gl-w-30p"> <gl-dropdown v-if="Boolean(gitlabField.numberOfFallbacks)" :disabled="!gitlabField.mappingFields.length" @@ -205,10 +200,12 @@ export default { gitlabField.fallbackSearchTerm, gitlabField.mappingFields, )" - :key="`${mappingField.name}__fallback`" - :is-checked="isSelected(gitlabField.fallback, mappingField.name)" + :key="`${mappingField.path}__fallback`" + :is-checked="isSelected(gitlabField.fallback, mappingField.path)" is-check-item - @click="setMapping(gitlabField.name, mappingField.name, 'fallback')" + @click=" + setMapping(gitlabField.name, mappingField.path, $options.mappingFields.fallback) + " > {{ mappingField.label }} </gl-dropdown-item> 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 6cfb4601192..a5e17d80f86 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue @@ -10,6 +10,7 @@ import { GlTooltipDirective, GlSprintf, } from '@gitlab/ui'; +import { capitalize } from 'lodash'; import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; import { @@ -77,6 +78,7 @@ export default { { key: 'type', label: __('Type'), + formatter: (value) => (value === typeSet.prometheus ? capitalize(value) : value), }, { key: 'actions', @@ -120,14 +122,17 @@ export default { const { category, action } = trackAlertIntegrationsViewsOptions; Tracking.event(category, action); }, - setIntegrationToDelete({ name, id }) { - this.integrationToDelete.id = id; - this.integrationToDelete.name = name; + setIntegrationToDelete(integration) { + this.integrationToDelete = integration; }, deleteIntegration() { - this.$emit('delete-integration', { id: this.integrationToDelete.id }); + const { id, type } = this.integrationToDelete; + this.$emit('delete-integration', { id, type }); this.integrationToDelete = { ...integrationToDeleteDefault }; }, + editIntegration({ id, type }) { + this.$emit('edit-integration', { id, type }); + }, }, }; </script> @@ -169,7 +174,7 @@ export default { <template #cell(actions)="{ item }"> <gl-button-group class="gl-ml-3"> - <gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" /> + <gl-button icon="settings" @click="editIntegration(item)" /> <gl-button v-gl-modal.deleteIntegration :disabled="item.type === $options.typeSet.prometheus" diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 18372c54b84..5d9513e5b53 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -1,7 +1,6 @@ <script> import { GlButton, - GlCollapse, GlForm, GlFormGroup, GlFormSelect, @@ -11,98 +10,39 @@ import { GlModal, GlModalDirective, GlToggle, + GlTabs, + GlTab, } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import * as Sentry from '@sentry/browser'; +import { isEmpty, omit } from 'lodash'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { integrationTypes, + integrationSteps, + createStepNumbers, + editStepNumbers, JSON_VALIDATE_DELAY, targetPrometheusUrlPlaceholder, typeSet, + viewCredentialsTabIndex, + i18n, } from '../constants'; import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; +import parseSamplePayloadQuery from '../graphql/queries/parse_sample_payload.query.graphql'; import MappingBuilder from './alert_mapping_builder.vue'; import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue'; -// 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 const 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'), - prometheus: s__('AlertSettings|Prometheus'), - }, - 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.', - ), - }, - }, -}; export default { - integrationTypes, placeholders: { prometheus: targetPrometheusUrlPlaceholder, }, JSON_VALIDATE_DELAY, typeSet, + integrationSteps, i18n, components: { ClipboardButton, GlButton, - GlCollapse, GlForm, GlFormGroup, GlFormInput, @@ -111,13 +51,14 @@ export default { GlFormSelect, GlModal, GlToggle, + GlTabs, + GlTab, AlertSettingsFormHelpBlock, MappingBuilder, }, directives: { GlModal: GlModalDirective, }, - mixins: [glFeatureFlagsMixin()], inject: { generic: { default: {}, @@ -128,6 +69,9 @@ export default { multiIntegrations: { default: false, }, + projectPath: { + default: '', + }, }, props: { loading: { @@ -151,26 +95,40 @@ export default { }, data() { return { - selectedIntegration: integrationTypes[0].value, + integrationTypesOptions: Object.values(integrationTypes), + selectedIntegration: integrationTypes.none.value, active: false, - formVisible: false, - integrationTestPayload: { + samplePayload: { json: null, error: null, }, - resetSamplePayloadConfirmed: false, - customMapping: null, + testPayload: { + json: null, + error: null, + }, + resetPayloadAndMappingConfirmed: false, mapping: [], parsingPayload: false, currentIntegration: null, + parsedPayload: [], + activeTabIndex: 0, }; }, computed: { isPrometheus() { return this.selectedIntegration === this.$options.typeSet.prometheus; }, - jsonIsValid() { - return this.integrationTestPayload.error === null; + isHttp() { + return this.selectedIntegration === this.$options.typeSet.http; + }, + isCreating() { + return !this.currentIntegration; + }, + isSampePayloadValid() { + return this.samplePayload.error === null; + }, + isTestPayloadValid() { + return this.testPayload.error === null; }, selectedIntegrationType() { switch (this.selectedIntegration) { @@ -197,90 +155,88 @@ export default { }, testAlertPayload() { return { - data: this.integrationTestPayload.json, + data: this.testPayload.json, endpoint: this.integrationForm.url, token: this.integrationForm.token, }; }, showMappingBuilder() { - return ( - this.multiIntegrations && - this.glFeatures.multipleHttpIntegrationsCustomMapping && - this.selectedIntegration === typeSet.http && - this.alertFields?.length - ); - }, - parsedSamplePayload() { - return this.customMapping?.samplePayload?.payloadAlerFields?.nodes; - }, - savedMapping() { - return this.customMapping?.storedMapping?.nodes; + return this.multiIntegrations && this.isHttp && this.alertFields?.length; }, hasSamplePayload() { - return Boolean(this.customMapping?.samplePayload); + return this.isValidNonEmptyJSON(this.currentIntegration?.payloadExample); }, canEditPayload() { - return this.hasSamplePayload && !this.resetSamplePayloadConfirmed; + return this.hasSamplePayload && !this.resetPayloadAndMappingConfirmed; + }, + canParseSamplePayload() { + return !this.active || !this.isSampePayloadValid || !this.samplePayload.json; }, isResetAuthKeyDisabled() { return !this.active && !this.integrationForm.token !== ''; }, isPayloadEditDisabled() { - return this.glFeatures.multipleHttpIntegrationsCustomMapping - ? !this.active || this.canEditPayload - : !this.active; - }, - isSubmitTestPayloadDisabled() { - return ( - !this.active || - Boolean(this.integrationTestPayload.error) || - this.integrationTestPayload.json === '' - ); + return !this.active || this.canEditPayload; }, isSelectDisabled() { return this.currentIntegration !== null || !this.canAddIntegration; }, + viewCredentialsHelpMsg() { + return this.isPrometheus + ? i18n.integrationFormSteps.setupCredentials.prometheusHelp + : i18n.integrationFormSteps.setupCredentials.help; + }, }, watch: { currentIntegration(val) { if (val === null) { - return this.reset(); + this.reset(); + return; + } + const { type, active, payloadExample, payloadAlertFields, payloadAttributeMappings } = val; + this.selectedIntegration = type; + this.active = active; + + if (type === typeSet.http && this.showMappingBuilder) { + this.parsedPayload = payloadAlertFields; + this.samplePayload.json = this.isValidNonEmptyJSON(payloadExample) ? payloadExample : null; + const mapping = payloadAttributeMappings.map((mappingItem) => + omit(mappingItem, '__typename'), + ); + this.updateMapping(mapping); } - this.selectedIntegration = val.type; - this.active = val.active; - if (val.type === typeSet.http && this.showMappingBuilder) this.getIntegrationMapping(val.id); - return this.integrationTypeSelect(); + this.activeTabIndex = viewCredentialsTabIndex; + this.$el.scrollIntoView({ block: 'center' }); }, }, methods: { - integrationTypeSelect() { - if (this.selectedIntegration === integrationTypes[0].value) { - this.formVisible = false; - } else { - this.formVisible = true; + isValidNonEmptyJSON(JSONString) { + if (JSONString) { + let parsed; + try { + parsed = JSON.parse(JSONString); + } catch (error) { + Sentry.captureException(error); + } + if (parsed) return !isEmpty(parsed); } + return false; }, - submitWithTestPayload() { - this.$emit('set-test-alert-payload', this.testAlertPayload); - this.submit(); + sendTestAlert() { + this.$emit('test-alert-payload', this.testAlertPayload); }, submit() { const { name, apiUrl } = this.integrationForm; - const customMappingVariables = this.glFeatures.multipleHttpIntegrationsCustomMapping - ? { - payloadAttributeMappings: this.mapping, - payloadExample: this.integrationTestPayload.json, - } - : {}; + const customMappingVariables = { + payloadAttributeMappings: this.mapping, + payloadExample: this.samplePayload.json || '{}', + }; const variables = this.selectedIntegration === typeSet.http - ? { - name, - active: this.active, - ...customMappingVariables, - } + ? { name, active: this.active, ...customMappingVariables } : { apiUrl, active: this.active }; + const integrationPayload = { type: this.selectedIntegration, variables }; if (this.currentIntegration) { @@ -291,19 +247,15 @@ export default { return this.$emit('create-new-integration', integrationPayload); }, reset() { - this.selectedIntegration = integrationTypes[0].value; - this.integrationTypeSelect(); - - if (this.currentIntegration) { - return this.$emit('clear-current-integration'); - } - - return this.resetFormValues(); + this.resetFormValues(); + this.resetPayloadAndMapping(); + this.$emit('clear-current-integration', { type: this.currentIntegration?.type }); }, resetFormValues() { + this.selectedIntegration = integrationTypes.none.value; this.integrationForm.name = ''; this.integrationForm.apiUrl = ''; - this.integrationTestPayload = { + this.samplePayload = { json: null, error: null, }; @@ -319,117 +271,135 @@ export default { variables: { id: this.currentIntegration.id }, }); }, - validateJson() { - this.integrationTestPayload.error = null; - if (this.integrationTestPayload.json === '') { + validateJson(isSamplePayload = true) { + const payload = isSamplePayload ? this.samplePayload : this.testPayload; + + payload.error = null; + if (payload.json === '') { return; } try { - JSON.parse(this.integrationTestPayload.json); + JSON.parse(payload.json); } catch (e) { - this.integrationTestPayload.error = JSON.stringify(e.message); + payload.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); + return this.$apollo + .query({ + query: parseSamplePayloadQuery, + variables: { projectPath: this.projectPath, payload: this.samplePayload.json }, + }) + .then( + ({ + data: { + project: { alertManagementPayloadFields }, + }, + }) => { + this.parsedPayload = alertManagementPayloadFields; + this.resetPayloadAndMappingConfirmed = false; + + this.$toast.show( + this.$options.i18n.integrationFormSteps.setSamplePayload.payloadParsedSucessMsg, + ); + }, + ) + .catch(({ message }) => { + this.samplePayload.error = message; }) .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; - }); - }, updateMapping(mapping) { this.mapping = mapping; }, + resetPayloadAndMapping() { + this.resetPayloadAndMappingConfirmed = true; + this.parsedPayload = []; + this.updateMapping([]); + }, + getLabelWithStepNumber(step, label) { + let stepNumber = editStepNumbers[step]; + + if (this.isCreating) { + stepNumber = createStepNumbers[step]; + } + + return stepNumber ? `${stepNumber}.${label}` : label; + }, }, }; </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="isSelectDisabled" - class="mw-100" - :options="$options.integrationTypes" - @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"> - <div> + <gl-tabs v-model="activeTabIndex"> + <gl-tab :title="$options.i18n.integrationTabs.configureDetails"> <gl-form-group - id="name-integration" - :label="$options.i18n.integrationFormSteps.step2.label" - label-for="name-integration" + v-if="isCreating" + id="integration-type" + :label=" + getLabelWithStepNumber( + $options.integrationSteps.selectType, + $options.i18n.integrationFormSteps.selectType.label, + ) + " + label-for="integration-type" > - <gl-form-input - v-model="integrationForm.name" - :disabled="isPrometheus" - type="text" - :placeholder=" - isPrometheus - ? $options.i18n.integrationFormSteps.step2.prometheus - : $options.i18n.integrationFormSteps.step2.placeholder - " + <gl-form-select + v-model="selectedIntegration" + :disabled="isSelectDisabled" + class="gl-max-w-full" + :options="integrationTypesOptions" /> - </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" + v-if="!canAddIntegration" + disabled="true" + class="gl-display-inline-block gl-my-4" + :message="$options.i18n.integrationFormSteps.selectType.enterprise" + link="https://about.gitlab.com/pricing" + data-testid="multi-integrations-not-supported" /> + </gl-form-group> + <div class="gl-mt-3"> + <gl-form-group + v-if="isHttp" + id="name-integration" + :label=" + getLabelWithStepNumber( + $options.integrationSteps.nameIntegration, + $options.i18n.integrationFormSteps.nameIntegration.label, + ) + " + label-for="name-integration" + > + <gl-form-input + v-model="integrationForm.name" + type="text" + :placeholder="$options.i18n.integrationFormSteps.nameIntegration.placeholder" + /> + </gl-form-group> <gl-toggle v-model="active" :is-loading="loading" - :label="__('Active')" + :label="$options.i18n.integrationFormSteps.nameIntegration.activeToggle" class="gl-my-4 gl-font-weight-normal" /> <div v-if="isPrometheus" class="gl-my-4"> <span class="gl-font-weight-bold"> - {{ $options.i18n.integrationFormSteps.prometheusFormUrl.label }} + {{ + getLabelWithStepNumber( + $options.integrationSteps.setPrometheusApiUrl, + $options.i18n.integrationFormSteps.prometheusFormUrl.label, + ) + }} </span> <gl-form-input @@ -444,16 +414,123 @@ export default { </span> </div> + <template v-if="showMappingBuilder"> + <gl-form-group + data-testid="sample-payload-section" + :label=" + getLabelWithStepNumber( + $options.integrationSteps.setSamplePayload, + $options.i18n.integrationFormSteps.setSamplePayload.label, + ) + " + label-for="sample-payload" + class="gl-mb-0!" + :invalid-feedback="samplePayload.error" + > + <alert-settings-form-help-block + :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelpHttp" + :link="generic.alertsUsageUrl" + /> + + <gl-form-textarea + id="sample-payload" + v-model.trim="samplePayload.json" + :disabled="isPayloadEditDisabled" + :state="isSampePayloadValid" + :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder" + class="gl-my-3" + :debounce="$options.JSON_VALIDATE_DELAY" + rows="6" + max-rows="10" + @input="validateJson" + /> + </gl-form-group> + + <gl-button + v-if="canEditPayload" + v-gl-modal.resetPayloadModal + data-testid="payload-action-btn" + :disabled="!active" + class="gl-mt-3" + > + {{ $options.i18n.integrationFormSteps.setSamplePayload.editPayload }} + </gl-button> + + <gl-button + v-else + data-testid="payload-action-btn" + :class="{ 'gl-mt-3': samplePayload.error }" + :disabled="canParseSamplePayload" + :loading="parsingPayload" + @click="parseMapping" + > + {{ $options.i18n.integrationFormSteps.setSamplePayload.parsePayload }} + </gl-button> + <gl-modal + modal-id="resetPayloadModal" + :title="$options.i18n.integrationFormSteps.setSamplePayload.resetHeader" + :ok-title="$options.i18n.integrationFormSteps.setSamplePayload.resetOk" + ok-variant="danger" + @ok="resetPayloadAndMapping" + > + {{ $options.i18n.integrationFormSteps.setSamplePayload.resetBody }} + </gl-modal> + + <gl-form-group + id="mapping-builder" + class="gl-mt-5" + :label=" + getLabelWithStepNumber( + $options.integrationSteps.customizeMapping, + $options.i18n.integrationFormSteps.mapFields.label, + ) + " + label-for="mapping-builder" + > + <span>{{ $options.i18n.integrationFormSteps.mapFields.intro }}</span> + <mapping-builder + :parsed-payload="parsedPayload" + :saved-mapping="mapping" + :alert-fields="alertFields" + @onMappingUpdate="updateMapping" + /> + </gl-form-group> + </template> + </div> + + <div class="gl-display-flex gl-justify-content-start gl-py-3"> + <gl-button + type="submit" + variant="confirm" + class="js-no-auto-disable" + data-testid="integration-form-submit" + > + {{ $options.i18n.saveIntegration }} + </gl-button> + + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ + $options.i18n.cancelAndClose + }}</gl-button> + </div> + </gl-tab> + + <gl-tab :title="$options.i18n.integrationTabs.viewCredentials" :disabled="isCreating"> + <alert-settings-form-help-block + :message="viewCredentialsHelpMsg" + link="https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html" + /> + + <gl-form-group id="integration-webhook"> <div class="gl-my-4"> <span class="gl-font-weight-bold"> - {{ s__('AlertSettings|Webhook URL') }} + {{ $options.i18n.integrationFormSteps.setupCredentials.webhookUrl }} </span> <gl-form-input-group id="url" readonly :value="integrationForm.url"> <template #append> <clipboard-button :text="integrationForm.url || ''" - :title="__('Copy')" + :title="$options.i18n.copy" class="gl-m-0!" /> </template> @@ -462,7 +539,7 @@ export default { <div class="gl-my-4"> <span class="gl-font-weight-bold"> - {{ $options.i18n.integrationFormSteps.step3.info }} + {{ $options.i18n.integrationFormSteps.setupCredentials.authorizationKey }} </span> <gl-form-input-group @@ -474,124 +551,67 @@ export default { <template #append> <clipboard-button :text="integrationForm.token || ''" - :title="__('Copy')" + :title="$options.i18n.copy" class="gl-m-0!" /> </template> </gl-form-input-group> - - <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled"> - {{ $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" + <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled" variant="danger"> + {{ $options.i18n.integrationFormSteps.setupCredentials.reset }} + </gl-button> + + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ + $options.i18n.cancelAndClose + }}</gl-button> + + <gl-modal + modal-id="authKeyModal" + :title="$options.i18n.integrationFormSteps.setupCredentials.reset" + :ok-title="$options.i18n.integrationFormSteps.setupCredentials.reset" + ok-variant="danger" + @ok="resetAuthKey" > + {{ $options.i18n.integrationFormSteps.restKeyInfo.label }} + </gl-modal> + </gl-tab> + + <gl-tab :title="$options.i18n.integrationTabs.sendTestAlert" :disabled="isCreating"> + <gl-form-group id="test-integration" :invalid-feedback="testPayload.error"> <alert-settings-form-help-block - :message=" - isPrometheus || !showMappingBuilder - ? $options.i18n.integrationFormSteps.step4.prometheusHelp - : $options.i18n.integrationFormSteps.step4.help - " + :message="$options.i18n.integrationFormSteps.setSamplePayload.testPayloadHelp" :link="generic.alertsUsageUrl" /> <gl-form-textarea id="test-payload" - v-model.trim="integrationTestPayload.json" - :disabled="isPayloadEditDisabled" - :state="jsonIsValid" - :placeholder="$options.i18n.integrationFormSteps.step4.placeholder" + v-model.trim="testPayload.json" + :state="isTestPayloadValid" + :placeholder="$options.i18n.integrationFormSteps.setSamplePayload.placeholder" class="gl-my-3" :debounce="$options.JSON_VALIDATE_DELAY" rows="6" max-rows="10" - @input="validateJson" + @input="validateJson(false)" /> </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 - :parsed-payload="parsedSamplePayload" - :saved-mapping="savedMapping" - :alert-fields="alertFields" - @onMappingUpdate="updateMapping" - /> - </gl-form-group> - </div> - <div class="gl-display-flex gl-justify-content-start gl-py-3"> <gl-button - type="submit" - variant="success" + :disabled="!isTestPayloadValid" + data-testid="send-test-alert" + variant="confirm" class="js-no-auto-disable" - data-testid="integration-form-submit" - >{{ s__('AlertSettings|Save integration') }} - </gl-button> - <gl-button - data-testid="integration-test-and-submit" - :disabled="isSubmitTestPayloadDisabled" - category="secondary" - variant="success" - class="gl-mx-3 js-no-auto-disable" - @click="submitWithTestPayload" - >{{ s__('AlertSettings|Save and test payload') }}</gl-button + @click="sendTestAlert" > - <gl-button type="reset" class="js-no-auto-disable">{{ __('Cancel') }}</gl-button> - </div> - </gl-collapse> + {{ $options.i18n.send }} + </gl-button> + + <gl-button type="reset" class="gl-ml-3 js-no-auto-disable">{{ + $options.i18n.cancelAndClose + }}</gl-button> + </gl-tab> + </gl-tabs> </gl-form> </template> diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue index 366f2209fb2..3ffb652e61b 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue @@ -1,22 +1,26 @@ <script> +import { GlButton } from '@gitlab/ui'; +import createHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql'; +import updateHttpIntegrationMutation from 'ee_else_ce/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql'; import createFlash, { FLASH_TYPES } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { s__ } from '~/locale'; import { typeSet } from '../constants'; -import createHttpIntegrationMutation from '../graphql/mutations/create_http_integration.mutation.graphql'; import createPrometheusIntegrationMutation from '../graphql/mutations/create_prometheus_integration.mutation.graphql'; import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql'; import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql'; import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql'; -import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql'; -import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql'; +import updateCurrentHttpIntegrationMutation from '../graphql/mutations/update_current_http_integration.mutation.graphql'; +import updateCurrentPrometheusIntegrationMutation from '../graphql/mutations/update_current_prometheus_integration.mutation.graphql'; import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql'; import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql'; +import getHttpIntegrationsQuery from '../graphql/queries/get_http_integrations.query.graphql'; import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql'; import service from '../services'; import { updateStoreAfterIntegrationDelete, updateStoreAfterIntegrationAdd, + updateStoreAfterHttpIntegrationAdd, } from '../utils/cache_updates'; import { DELETE_INTEGRATION_ERROR, @@ -28,20 +32,24 @@ import { import IntegrationsList from './alerts_integrations_list.vue'; import AlertSettingsForm from './alerts_settings_form.vue'; +export const i18n = { + changesSaved: s__( + 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.', + ), + integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'), + alertSent: s__( + 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.', + ), + addNewIntegration: s__('AlertSettings|Add new integration'), +}; + export default { typeSet, - i18n: { - changesSaved: s__( - 'AlertsIntegrations|The integration has been successfully saved. Alerts from this new integration should now appear on your alerts list.', - ), - integrationRemoved: s__('AlertsIntegrations|The integration has been successfully removed.'), - alertSent: s__( - 'AlertsIntegrations|The test alert has been successfully sent, and should now be visible on your alerts list.', - ), - }, + i18n, components: { IntegrationsList, AlertSettingsForm, + GlButton, }, inject: { generic: { @@ -84,6 +92,28 @@ export default { createFlash({ message: err }); }, }, + // TODO: we'll need to update the logic to request specific http integration by its id on edit + // when BE adds support for it https://gitlab.com/gitlab-org/gitlab/-/issues/321674 + // currently the request for ALL http integrations is made and on specific integration edit we search it in the list + httpIntegrations: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: getHttpIntegrationsQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update(data) { + const { alertManagementHttpIntegrations: { nodes: list = [] } = {} } = data.project || {}; + + return { + list, + }; + }, + error(err) { + createFlash({ message: err }); + }, + }, currentIntegration: { query: getCurrentIntegrationQuery, }, @@ -91,9 +121,10 @@ export default { data() { return { isUpdating: false, - testAlertPayload: null, integrations: {}, + httpIntegrations: {}, currentIntegration: null, + formVisible: false, }; }, computed: { @@ -105,22 +136,28 @@ export default { }, }, methods: { + isHttp(type) { + return type === typeSet.http; + }, createNewIntegration({ type, variables }) { const { projectPath } = this; + const isHttp = this.isHttp(type); this.isUpdating = true; this.$apollo .mutate({ - mutation: - type === this.$options.typeSet.http - ? createHttpIntegrationMutation - : createPrometheusIntegrationMutation, + mutation: isHttp ? createHttpIntegrationMutation : createPrometheusIntegrationMutation, variables: { ...variables, projectPath, }, update(store, { data }) { updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath }); + if (isHttp) { + updateStoreAfterHttpIntegrationAdd(store, getHttpIntegrationsQuery, data, { + projectPath, + }); + } }, }) .then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => { @@ -128,18 +165,9 @@ export default { if (error) { return createFlash({ message: error }); } + const { integration } = httpIntegrationCreate || prometheusIntegrationCreate; - if (this.testAlertPayload) { - const integration = - httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration; - - const payload = { - ...this.testAlertPayload, - endpoint: integration.url, - token: integration.token, - }; - return this.validateAlertPayload(payload); - } + this.editIntegration(integration); return createFlash({ message: this.$options.i18n.changesSaved, @@ -157,10 +185,9 @@ export default { this.isUpdating = true; this.$apollo .mutate({ - mutation: - type === this.$options.typeSet.http - ? updateHttpIntegrationMutation - : updatePrometheusIntegrationMutation, + mutation: this.isHttp(type) + ? updateHttpIntegrationMutation + : updatePrometheusIntegrationMutation, variables: { ...variables, id: this.currentIntegration.id, @@ -172,11 +199,7 @@ export default { return createFlash({ message: error }); } - if (this.testAlertPayload) { - return this.validateAlertPayload(); - } - - this.clearCurrentIntegration(); + this.clearCurrentIntegration({ type }); return createFlash({ message: this.$options.i18n.changesSaved, @@ -188,23 +211,19 @@ export default { }) .finally(() => { this.isUpdating = false; - this.testAlertPayload = null; }); }, resetToken({ type, variables }) { this.isUpdating = true; this.$apollo .mutate({ - mutation: - type === this.$options.typeSet.http - ? resetHttpTokenMutation - : resetPrometheusTokenMutation, + mutation: this.isHttp(type) ? resetHttpTokenMutation : resetPrometheusTokenMutation, variables, }) .then( ({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => { - const error = - httpIntegrationResetToken?.errors[0] || prometheusIntegrationResetToken?.errors[0]; + const [error] = + httpIntegrationResetToken?.errors || prometheusIntegrationResetToken?.errors; if (error) { return createFlash({ message: error }); } @@ -214,10 +233,10 @@ export default { prometheusIntegrationResetToken?.integration; this.$apollo.mutate({ - mutation: updateCurrentIntergrationMutation, - variables: { - ...integration, - }, + mutation: this.isHttp(type) + ? updateCurrentHttpIntegrationMutation + : updateCurrentPrometheusIntegrationMutation, + variables: integration, }); return createFlash({ @@ -233,33 +252,31 @@ export default { this.isUpdating = false; }); }, - editIntegration({ id }) { - const currentIntegration = this.integrations.list.find( - (integration) => integration.id === id, - ); + editIntegration({ id, type }) { + let currentIntegration = this.integrations.list.find((integration) => integration.id === id); + if (this.isHttp(type)) { + const httpIntegrationMappingData = this.httpIntegrations.list.find( + (integration) => integration.id === id, + ); + currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData }; + } + 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, - }, + mutation: this.isHttp(type) + ? updateCurrentHttpIntegrationMutation + : updateCurrentPrometheusIntegrationMutation, + variables: currentIntegration, }); + this.setFormVisibility(true); }, - deleteIntegration({ id }) { + deleteIntegration({ id, type }) { const { projectPath } = this; this.isUpdating = true; this.$apollo .mutate({ mutation: destroyHttpIntegrationMutation, - variables: { - id, - }, + variables: { id }, update(store, { data }) { updateStoreAfterIntegrationDelete(store, getIntegrationsQuery, data, { projectPath }); }, @@ -269,7 +286,7 @@ export default { if (error) { return createFlash({ message: error }); } - this.clearCurrentIntegration(); + this.clearCurrentIntegration({ type }); return createFlash({ message: this.$options.i18n.integrationRemoved, type: FLASH_TYPES.SUCCESS, @@ -282,18 +299,20 @@ export default { this.isUpdating = false; }); }, - clearCurrentIntegration() { - this.$apollo.mutate({ - mutation: updateCurrentIntergrationMutation, - variables: {}, - }); - }, - setTestAlertPayload(payload) { - this.testAlertPayload = payload; + clearCurrentIntegration({ type }) { + if (type) { + this.$apollo.mutate({ + mutation: this.isHttp(type) + ? updateCurrentHttpIntegrationMutation + : updateCurrentPrometheusIntegrationMutation, + variables: {}, + }); + } + this.setFormVisibility(false); }, - validateAlertPayload(payload) { + testAlertPayload(payload) { return service - .updateTestAlert(payload ?? this.testAlertPayload) + .updateTestAlert(payload) .then(() => { return createFlash({ message: this.$options.i18n.alertSent, @@ -304,6 +323,9 @@ export default { createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); }); }, + setFormVisibility(visible) { + this.formVisible = visible; + }, }, }; </script> @@ -316,7 +338,18 @@ export default { @edit-integration="editIntegration" @delete-integration="deleteIntegration" /> + <gl-button + v-if="canAddIntegration && !formVisible" + category="secondary" + variant="confirm" + data-testid="add-integration-btn" + class="gl-mt-3" + @click="setFormVisibility(true)" + > + {{ $options.i18n.addNewIntegration }} + </gl-button> <alert-settings-form + v-if="formVisible" :loading="isUpdating" :can-add-integration="canAddIntegration" :alert-fields="alertFields" @@ -324,7 +357,7 @@ export default { @update-integration="updateIntegration" @reset-token="resetToken" @clear-current-integration="clearCurrentIntegration" - @set-test-alert-payload="setTestAlertPayload" + @test-alert-payload="testAlertPayload" /> </div> </template> diff --git a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json b/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json deleted file mode 100644 index 80fbebf2a60..00000000000 --- a/app/assets/javascripts/alerts_settings/components/mocks/parsedMapping.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "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": [ - { - "path": ["dashboardId"], - "label": "Dashboard Id", - "type": "string" - }, - { - "path": ["evalMatches"], - "label": "Eval Matches", - "type": "array" - }, - { - "path": ["createdAt"], - "label": "Created At", - "type": "datetime" - }, - { - "path": ["imageUrl"], - "label": "Image Url", - "type": "string" - }, - { - "path": ["message"], - "label": "Message", - "type": "string" - }, - { - "path": ["orgId"], - "label": "Org Id", - "type": "string" - }, - { - "path": ["panelId"], - "label": "Panel Id", - "type": "string" - }, - { - "path": ["ruleId"], - "label": "Rule Id", - "type": "string" - }, - { - "path": ["ruleName"], - "label": "Rule Name", - "type": "string" - }, - { - "path": ["ruleUrl"], - "label": "Rule Url", - "type": "string" - }, - { - "path": ["state"], - "label": "State", - "type": "string" - }, - { - "path": ["title"], - "label": "Title", - "type": "string" - }, - { - "path": ["tags", "tag"], - "label": "Tags", - "type": "string" - } - ] - } - }, - "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 ecd7c921b2f..ce6cf61b5dd 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -1,50 +1,106 @@ -import { s__ } from '~/locale'; +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.', - ), - setupSection: s__( - "AlertSettings|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", - ), - errorMsg: s__('AlertSettings|There was an error updating the alert settings.'), - errorKeyMsg: s__( - 'AlertSettings|There was an error while trying to reset the key. Please refresh the page to try again.', - ), - restKeyInfo: s__( - 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.', - ), + integrationTabs: { + configureDetails: s__('AlertSettings|Configure details'), + viewCredentials: s__('AlertSettings|View credentials'), + sendTestAlert: s__('AlertSettings|Send test alert'), + }, + integrationFormSteps: { + selectType: { + label: s__('AlertSettings|Select integration type'), + enterprise: s__( + 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.', + ), + }, + nameIntegration: { + label: s__('AlertSettings|Name integration'), + placeholder: s__('AlertSettings|Enter integration name'), + activeToggle: __('Active'), + }, + setupCredentials: { + help: s__( + "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.", + ), + 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.', + ), + webhookUrl: s__('AlertSettings|Webhook URL'), + authorizationKey: s__('AlertSettings|Authorization key'), + reset: s__('AlertSettings|Reset Key'), + }, + setSamplePayload: { + label: s__('AlertSettings|Sample alert payload (optional)'), + testPayloadHelpHttp: s__( + 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional).', + ), + testPayloadHelp: s__( + 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This will allow you to send an alert to an active GitLab alerting point.', + ), + placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'), + resetHeader: s__('AlertSettings|Reset the mapping'), + resetBody: s__( + "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.", + ), + resetOk: s__('AlertSettings|Proceed with editing'), + editPayload: s__('AlertSettings|Edit payload'), + parsePayload: s__('AlertSettings|Parse payload for custom mapping'), + payloadParsedSucessMsg: s__( + 'AlertSettings|Sample payload has been parsed. You can now map the fields.', + ), + }, + mapFields: { + label: s__('AlertSettings|Customize alert payload mapping (optional)'), + intro: s__( + 'AlertSettings|If you intend to create a custom mapping, provide an example payload from your monitoring tool and click "parse payload fields" button to continue. The sample payload is required for completing the custom mapping; if you want to skip the mapping step, progress straight to saving your integration.', + ), + }, + 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.', + ), + }, + }, + saveIntegration: s__('AlertSettings|Save integration'), 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 our upcoming %{linkStart}integrations%{linkEnd}', - ), - resetKey: s__('AlertSettings|Reset key'), - copyToClipboard: s__('AlertSettings|Copy'), - apiBaseUrlLabel: s__('AlertSettings|API URL'), - authKeyLabel: s__('AlertSettings|Authorization key'), - urlLabel: s__('AlertSettings|Webhook URL'), - activeLabel: s__('AlertSettings|Active'), - apiBaseUrlHelpText: s__('AlertSettings|URL cannot be blank and must start with http or https'), - testAlertInfo: s__('AlertSettings|Test alert payload'), - alertJson: s__('AlertSettings|Alert test payload'), - alertJsonPlaceholder: s__('AlertSettings|Enter test alert JSON....'), - testAlertFailed: s__('AlertSettings|Test failed. Do you still want to save your changes anyway?'), - testAlertSuccess: s__( - 'AlertSettings|Test alert sent successfully. If you have made other changes, please save them now.', - ), - authKeyRest: s__( - 'AlertSettings|Authorization key has been successfully reset. Please save your changes now.', - ), - integration: s__('AlertSettings|Integration'), + cancelAndClose: __('Cancel and close'), + send: s__('AlertSettings|Send'), + copy: __('Copy'), }; -export const integrationTypes = [ - { value: '', text: s__('AlertSettings|Select integration type') }, - { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') }, - { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') }, -]; +export const integrationSteps = { + selectType: 'SELECT_TYPE', + nameIntegration: 'NAME_INTEGRATION', + setPrometheusApiUrl: 'SET_PROMETHEUS_API_URL', + setSamplePayload: 'SET_SAMPLE_PAYLOAD', + customizeMapping: 'CUSTOMIZE_MAPPING', +}; + +export const createStepNumbers = { + [integrationSteps.selectType]: 1, + [integrationSteps.nameIntegration]: 2, + [integrationSteps.setPrometheusApiUrl]: 2, + [integrationSteps.setSamplePayload]: 3, + [integrationSteps.customizeMapping]: 4, +}; + +export const editStepNumbers = { + [integrationSteps.selectType]: 1, + [integrationSteps.nameIntegration]: 1, + [integrationSteps.setPrometheusApiUrl]: null, + [integrationSteps.setSamplePayload]: 2, + [integrationSteps.customizeMapping]: 3, +}; + +export const integrationTypes = { + none: { value: '', text: s__('AlertSettings|Select integration type') }, + http: { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') }, + prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') }, +}; export const typeSet = { http: 'HTTP', @@ -57,14 +113,18 @@ export const JSON_VALIDATE_DELAY = 250; export const targetPrometheusUrlPlaceholder = 'http://prometheus.example.com/'; -export const sectionHash = 'js-alert-management-settings'; - -/* eslint-disable @gitlab/require-i18n-strings */ - /** * Tracks snowplow event when user views alerts integration list */ export const trackAlertIntegrationsViewsOptions = { + /* eslint-disable-next-line @gitlab/require-i18n-strings */ category: 'Alert Integrations', action: 'view_alert_integrations_list', }; + +export const mappingFields = { + mapping: 'mapping', + fallback: 'fallback', +}; + +export const viewCredentialsTabIndex = 1; diff --git a/app/assets/javascripts/alerts_settings/graphql.js b/app/assets/javascripts/alerts_settings/graphql.js index 5fd05169533..c6c19d26adb 100644 --- a/app/assets/javascripts/alerts_settings/graphql.js +++ b/app/assets/javascripts/alerts_settings/graphql.js @@ -10,7 +10,18 @@ const resolvers = { Mutation: { updateCurrentIntegration: ( _, - { id = null, name, active, token, type, url, apiUrl }, + { + id = null, + name, + active, + token, + type, + url, + apiUrl, + payloadExample, + payloadAttributeMappings, + payloadAlertFields, + }, { cache }, ) => { const sourceData = cache.readQuery({ query: getCurrentIntegrationQuery }); @@ -28,6 +39,9 @@ const resolvers = { type, url, apiUrl, + payloadExample, + payloadAttributeMappings, + payloadAlertFields, }; } }); diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql new file mode 100644 index 00000000000..742228e2928 --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_item.fragment.graphql @@ -0,0 +1,7 @@ +#import "./integration_item.fragment.graphql" +#import "ee_else_ce/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql" + +fragment HttpIntegrationItem on AlertManagementHttpIntegration { + ...IntegrationItem + ...HttpIntegrationPayloadData +} diff --git a/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql new file mode 100644 index 00000000000..df6ad0b712d --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql @@ -0,0 +1,3 @@ +fragment HttpIntegrationPayloadData on AlertManagementHttpIntegration { + id +} 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 index f3fc10b4bd4..babcdea935d 100644 --- 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 @@ -1,24 +1,10 @@ -#import "../fragments/integration_item.fragment.graphql" +#import "../fragments/http_integration_item.fragment.graphql" -mutation createHttpIntegration( - $projectPath: ID! - $name: String! - $active: Boolean! - $payloadExample: JsonString - $payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!] -) { - httpIntegrationCreate( - input: { - projectPath: $projectPath - name: $name - active: $active - payloadExample: $payloadExample - payloadAttributeMappings: $payloadAttributeMappings - } - ) { +mutation createHttpIntegration($projectPath: ID!, $name: String!, $active: Boolean!) { + httpIntegrationCreate(input: { projectPath: $projectPath, name: $name, active: $active }) { errors integration { - ...IntegrationItem + ...HttpIntegrationItem } } } 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 index 0a49c140e6a..a3a50651fd0 100644 --- 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 @@ -1,10 +1,10 @@ -#import "../fragments/integration_item.fragment.graphql" +#import "../fragments/http_integration_item.fragment.graphql" mutation destroyHttpIntegration($id: ID!) { httpIntegrationDestroy(input: { id: $id }) { errors integration { - ...IntegrationItem + ...HttpIntegrationItem } } } 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 index 178d1e13047..c0754d8e32b 100644 --- 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 @@ -1,10 +1,10 @@ -#import "../fragments/integration_item.fragment.graphql" +#import "../fragments/http_integration_item.fragment.graphql" mutation resetHttpIntegrationToken($id: ID!) { httpIntegrationResetToken(input: { id: $id }) { errors integration { - ...IntegrationItem + ...HttpIntegrationItem } } } diff --git a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql new file mode 100644 index 00000000000..5f3d305993c --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql @@ -0,0 +1,25 @@ +mutation updateCurrentHttpIntegration( + $id: String + $name: String + $active: Boolean + $token: String + $type: String + $url: String + $apiUrl: String + $payloadExample: JsonString + $payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!] + $payloadAlertFields: [AlertManagementPayloadAlertField!] +) { + updateCurrentIntegration( + id: $id + name: $name + active: $active + token: $token + type: $type + url: $url + apiUrl: $apiUrl + payloadExample: $payloadExample + payloadAttributeMappings: $payloadAttributeMappings + payloadAlertFields: $payloadAlertFields + ) @client +} 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_prometheus_integration.mutation.graphql index 3505241309e..5bd63820629 100644 --- a/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_intergration.mutation.graphql +++ b/app/assets/javascripts/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql @@ -1,4 +1,4 @@ -mutation updateCurrentIntegration( +mutation updateCurrentPrometheusIntegration( $id: String $name: String $active: Boolean @@ -6,6 +6,7 @@ mutation updateCurrentIntegration( $type: String $url: String $apiUrl: String + $samplePayload: String ) { updateCurrentIntegration( id: $id @@ -15,5 +16,6 @@ mutation updateCurrentIntegration( type: $type url: $url apiUrl: $apiUrl + samplePayload: $samplePayload ) @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 index bb5b334deeb..37df9ec25eb 100644 --- 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 @@ -1,10 +1,10 @@ -#import "../fragments/integration_item.fragment.graphql" +#import "../fragments/http_integration_item.fragment.graphql" mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) { httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) { errors integration { - ...IntegrationItem + ...HttpIntegrationItem } } } diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql new file mode 100644 index 00000000000..833a2d6c12f --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/queries/get_http_integrations.query.graphql @@ -0,0 +1,12 @@ +#import "ee_else_ce/alerts_settings/graphql/fragments/http_integration_payload_data.fragment.graphql" + +# TODO: this query need to accept http integration id to request a sepcific integration +query getHttpIntegrations($projectPath: ID!) { + project(fullPath: $projectPath) { + alertManagementHttpIntegrations { + nodes { + ...HttpIntegrationPayloadData + } + } + } +} diff --git a/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql new file mode 100644 index 00000000000..159b2661f0b --- /dev/null +++ b/app/assets/javascripts/alerts_settings/graphql/queries/parse_sample_payload.query.graphql @@ -0,0 +1,9 @@ +query parsePayloadFields($projectPath: ID!, $payload: String!) { + project(fullPath: $projectPath) { + alertManagementPayloadFields(payloadExample: $payload) { + path + label + type + } + } +} diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js index 8506b3fda01..321af9fedb6 100644 --- a/app/assets/javascripts/alerts_settings/index.js +++ b/app/assets/javascripts/alerts_settings/index.js @@ -63,10 +63,7 @@ export default (el) => { render(createElement) { return createElement('alert-settings-wrapper', { props: { - alertFields: - gon.features?.multipleHttpIntegrationsCustomMapping && parseBoolean(multiIntegrations) - ? JSON.parse(alertFields) - : null, + alertFields: parseBoolean(multiIntegrations) ? JSON.parse(alertFields) : null, }, }); }, diff --git a/app/assets/javascripts/alerts_settings/utils/cache_updates.js b/app/assets/javascripts/alerts_settings/utils/cache_updates.js index 758f3eb6dd4..c29160c1e39 100644 --- a/app/assets/javascripts/alerts_settings/utils/cache_updates.js +++ b/app/assets/javascripts/alerts_settings/utils/cache_updates.js @@ -60,6 +60,32 @@ const addIntegrationToStore = ( }); }; +const addHttpIntegrationToStore = (store, query, { httpIntegrationCreate }, variables) => { + const integration = httpIntegrationCreate?.integration; + if (!integration) { + return; + } + + const sourceData = store.readQuery({ + query, + variables, + }); + + const data = produce(sourceData, (draftData) => { + // eslint-disable-next-line no-param-reassign + draftData.project.alertManagementHttpIntegrations.nodes = [ + integration, + ...draftData.project.alertManagementHttpIntegrations.nodes, + ]; + }); + + store.writeQuery({ + query, + variables, + data, + }); +}; + const onError = (data, message) => { createFlash({ message }); throw new Error(data.errors); @@ -82,3 +108,11 @@ export const updateStoreAfterIntegrationAdd = (store, query, data, variables) => addIntegrationToStore(store, query, data, variables); } }; + +export const updateStoreAfterHttpIntegrationAdd = (store, query, data, variables) => { + if (hasErrors(data)) { + onError(data, ADD_INTEGRATION_ERROR); + } else { + addHttpIntegrationToStore(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 index 979d1ca3ccc..e380257f983 100644 --- a/app/assets/javascripts/alerts_settings/utils/error_messages.js +++ b/app/assets/javascripts/alerts_settings/utils/error_messages.js @@ -17,5 +17,5 @@ export const RESET_INTEGRATION_TOKEN_ERROR = s__( ); export const INTEGRATION_PAYLOAD_TEST_ERROR = s__( - 'AlertsIntegrations|Integration payload is invalid. You can still save your changes.', + 'AlertsIntegrations|Integration payload is invalid.', ); diff --git a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js index a86103540c0..5c4b9bcd505 100644 --- a/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js +++ b/app/assets/javascripts/alerts_settings/utils/mapping_transformations.js @@ -1,3 +1,4 @@ +import { isEqual } from 'lodash'; /** * Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any) * creates an object in a form convenient to build UI && interact with it @@ -10,16 +11,19 @@ export const getMappingData = (gitlabFields, payloadFields, savedMapping) => { return gitlabFields.map((gitlabField) => { // find fields from payload that match gitlab alert field by type - const mappingFields = payloadFields.filter(({ type }) => gitlabField.types.includes(type)); + const mappingFields = payloadFields.filter(({ type }) => + gitlabField.types.includes(type.toLowerCase()), + ); // find the mapping that was previously stored - const foundMapping = savedMapping.find(({ fieldName }) => fieldName === gitlabField.name); - - const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {}; + const foundMapping = savedMapping.find( + ({ fieldName }) => fieldName.toLowerCase() === gitlabField.name, + ); + const { path: mapping, fallbackPath: fallback } = foundMapping || {}; return { - mapping: payloadAlertPaths, - fallback: fallbackAlertPaths, + mapping, + fallback, searchTerm: '', fallbackSearchTerm: '', mappingFields, @@ -36,7 +40,7 @@ export const getMappingData = (gitlabFields, payloadFields, savedMapping) => { */ export const transformForSave = (mappingData) => { return mappingData.reduce((acc, field) => { - const mapped = field.mappingFields.find(({ name }) => name === field.mapping); + const mapped = field.mappingFields.find(({ path }) => isEqual(path, field.mapping)); if (mapped) { const { path, type, label } = mapped; acc.push({ @@ -49,13 +53,3 @@ export const transformForSave = (mappingData) => { return acc; }, []); }; - -/** - * Adds `name` prop to each provided by BE parsed payload field - * @param {Object} payload - parsed sample payload - * - * @return {Object} same as input with an extra `name` property which basically serves as a key to make a match - */ -export const getPayloadFields = (payload) => { - return payload.map((field) => ({ ...field, name: field.path.join('_') })); -}; diff --git a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue index c0ad814172d..c0ad814172d 100644 --- a/app/assets/javascripts/admin/dev_ops_report/components/usage_ping_disabled.vue +++ b/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue diff --git a/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js b/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js index 0cb8d9be0e4..0cb8d9be0e4 100644 --- a/app/assets/javascripts/admin/dev_ops_report/devops_score_empty_state.js +++ b/app/assets/javascripts/analytics/devops_report/devops_score_empty_state.js diff --git a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js deleted file mode 100644 index 6fba3c56cfe..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js +++ /dev/null @@ -1,87 +0,0 @@ -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/projects_and_groups_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue deleted file mode 100644 index 3ffec90fb68..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue +++ /dev/null @@ -1,224 +0,0 @@ -<script> -import { GlAlert } from '@gitlab/ui'; -import { GlLineChart } from '@gitlab/ui/dist/charts'; -import produce from 'immer'; -import { sortBy } from 'lodash'; -import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; -import { s__, __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; -import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; -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/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql deleted file mode 100644 index 40cef95c2e7..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql +++ /dev/null @@ -1,4 +0,0 @@ -fragment Count on InstanceStatisticsMeasurement { - count - recordedAt -} 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 deleted file mode 100644 index ec56d91ffaa..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/groups.query.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#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_statistics_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql deleted file mode 100644 index f14c2658674..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql +++ /dev/null @@ -1,34 +0,0 @@ -#import "../fragments/count.fragment.graphql" - -query getInstanceCounts { - projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) { - nodes { - ...Count - } - } - groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) { - nodes { - ...Count - } - } - users: instanceStatisticsMeasurements(identifier: USERS, first: 1) { - nodes { - ...Count - } - } - issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) { - nodes { - ...Count - } - } - mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) { - nodes { - ...Count - } - } - pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) { - nodes { - ...Count - } - } -} 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 deleted file mode 100644 index 0845b703435..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/projects.query.graphql +++ /dev/null @@ -1,13 +0,0 @@ -#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/components/app.vue b/app/assets/javascripts/analytics/usage_trends/components/app.vue index 3bf41eaa008..4c5ddd7f458 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/app.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/app.vue @@ -1,18 +1,16 @@ <script> import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; import ChartsConfig from './charts_config'; -import InstanceCounts from './instance_counts.vue'; -import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue'; -import ProjectsAndGroupsChart from './projects_and_groups_chart.vue'; +import UsageCounts from './usage_counts.vue'; +import UsageTrendsCountChart from './usage_trends_count_chart.vue'; import UsersChart from './users_chart.vue'; export default { - name: 'InstanceStatisticsApp', + name: 'UsageTrendsApp', components: { - InstanceCounts, - InstanceStatisticsCountChart, + UsageCounts, + UsageTrendsCountChart, UsersChart, - ProjectsAndGroupsChart, }, TOTAL_DAYS_TO_SHOW, START_DATE, @@ -23,18 +21,13 @@ export default { <template> <div> - <instance-counts /> + <usage-counts /> <users-chart :start-date="$options.START_DATE" :end-date="$options.TODAY" :total-data-points="$options.TOTAL_DAYS_TO_SHOW" /> - <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 + <usage-trends-count-chart v-for="chartOptions in $options.configs" :key="chartOptions.chartTitle" :queries="chartOptions.queries" diff --git a/app/assets/javascripts/analytics/usage_trends/components/charts_config.js b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js new file mode 100644 index 00000000000..014f823cdc4 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js @@ -0,0 +1,106 @@ +import { s__, __ } from '~/locale'; +import query from '../graphql/queries/usage_count.query.graphql'; + +const noDataMessage = s__('UsageTrends|No data available.'); + +export default [ + { + loadChartError: s__( + 'UsageTrends|Could not load the projects and groups chart. Please refresh the page to try again.', + ), + noDataMessage, + chartTitle: s__('UsageTrends|Total projects & groups'), + yAxisTitle: s__('UsageTrends|Total projects & groups'), + xAxisTitle: s__('UsageTrends|Month'), + queries: [ + { + query, + title: s__('UsageTrends|Total projects'), + identifier: 'PROJECTS', + loadError: s__('UsageTrends|There was an error fetching the projects. Please try again.'), + }, + { + query, + title: s__('UsageTrends|Total groups'), + identifier: 'GROUPS', + loadError: s__('UsageTrends|There was an error fetching the groups. Please try again.'), + }, + ], + }, + { + loadChartError: s__( + 'UsageTrends|Could not load the pipelines chart. Please refresh the page to try again.', + ), + noDataMessage, + chartTitle: s__('UsageTrends|Pipelines'), + yAxisTitle: s__('UsageTrends|Items'), + xAxisTitle: s__('UsageTrends|Month'), + queries: [ + { + query, + title: s__('UsageTrends|Pipelines total'), + identifier: 'PIPELINES', + loadError: s__( + 'UsageTrends|There was an error fetching the total pipelines. Please try again.', + ), + }, + { + query, + title: s__('UsageTrends|Pipelines succeeded'), + identifier: 'PIPELINES_SUCCEEDED', + loadError: s__( + 'UsageTrends|There was an error fetching the successful pipelines. Please try again.', + ), + }, + { + query, + title: s__('UsageTrends|Pipelines failed'), + identifier: 'PIPELINES_FAILED', + loadError: s__( + 'UsageTrends|There was an error fetching the failed pipelines. Please try again.', + ), + }, + { + query, + title: s__('UsageTrends|Pipelines canceled'), + identifier: 'PIPELINES_CANCELED', + loadError: s__( + 'UsageTrends|There was an error fetching the cancelled pipelines. Please try again.', + ), + }, + { + query, + title: s__('UsageTrends|Pipelines skipped'), + identifier: 'PIPELINES_SKIPPED', + loadError: s__( + 'UsageTrends|There was an error fetching the skipped pipelines. Please try again.', + ), + }, + ], + }, + { + loadChartError: s__( + 'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.', + ), + noDataMessage, + chartTitle: s__('UsageTrends|Issues & Merge Requests'), + yAxisTitle: s__('UsageTrends|Items'), + xAxisTitle: s__('UsageTrends|Month'), + queries: [ + { + query, + title: __('Issues'), + identifier: 'ISSUES', + loadError: s__('UsageTrends|There was an error fetching the issues. Please try again.'), + }, + { + query, + title: __('Merge requests'), + identifier: 'MERGE_REQUESTS', + loadError: s__( + 'UsageTrends|There was an error fetching the merge requests. Please try again.', + ), + }, + ], + }, +]; diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue index f3779ed62e9..0630cca93ae 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue @@ -1,15 +1,15 @@ <script> +import * as Sentry from '@sentry/browser'; import MetricCard from '~/analytics/shared/components/metric_card.vue'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; +import { number } from '~/lib/utils/unit_format'; import { s__ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; -import instanceStatisticsCountQuery from '../graphql/queries/instance_statistics_count.query.graphql'; +import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql'; const defaultPrecision = 0; export default { - name: 'InstanceCounts', + name: 'UsageCounts', components: { MetricCard, }, @@ -20,12 +20,11 @@ export default { }, apollo: { counts: { - query: instanceStatisticsCountQuery, + query: usageTrendsCountQuery, update(data) { return Object.entries(data).map(([key, obj]) => { const label = this.$options.i18n.labels[key]; - const formatter = getFormatter(SUPPORTED_FORMATS.number); - const value = obj.nodes?.length ? formatter(obj.nodes[0].count, defaultPrecision) : null; + const value = obj.nodes?.length ? number(obj.nodes[0].count, defaultPrecision) : null; return { key, @@ -42,14 +41,14 @@ export default { }, i18n: { labels: { - users: s__('InstanceStatistics|Users'), - projects: s__('InstanceStatistics|Projects'), - groups: s__('InstanceStatistics|Groups'), - issues: s__('InstanceStatistics|Issues'), - mergeRequests: s__('InstanceStatistics|Merge Requests'), - pipelines: s__('InstanceStatistics|Pipelines'), + users: s__('UsageTrends|Users'), + projects: s__('UsageTrends|Projects'), + groups: s__('UsageTrends|Groups'), + issues: s__('UsageTrends|Issues'), + mergeRequests: s__('UsageTrends|Merge Requests'), + pipelines: s__('UsageTrends|Pipelines'), }, - loadCountsError: s__('Could not load instance counts. Please refresh the page to try again.'), + loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'), }, }; </script> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue index e2defe0572d..8d7761694d1 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue @@ -1,21 +1,21 @@ <script> import { GlAlert } from '@gitlab/ui'; import { GlLineChart } from '@gitlab/ui/dist/charts'; +import * as Sentry from '@sentry/browser'; import { some, every } from 'lodash'; import { differenceInMonths, formatDateAsMonth, getDayDifference, } from '~/lib/utils/datetime_utility'; -import * as Sentry from '~/sentry/wrapper'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import { TODAY, START_DATE } from '../constants'; import { getAverageByMonth, getEarliestDate, generateDataKeys } from '../utils'; -const QUERY_DATA_KEY = 'instanceStatisticsMeasurements'; +const QUERY_DATA_KEY = 'usageTrendsMeasurements'; export default { - name: 'InstanceStatisticsCountChart', + name: 'UsageTrendsCountChart', components: { GlLineChart, GlAlert, diff --git a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue index 73940f028a1..09dfcddcb73 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue @@ -1,11 +1,11 @@ <script> import { GlAlert } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import * as Sentry from '@sentry/browser'; import produce from 'immer'; import { sortBy } from 'lodash'; import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import usersQuery from '../graphql/queries/users.query.graphql'; import { getAverageByMonth } from '../utils'; diff --git a/app/assets/javascripts/analytics/instance_statistics/constants.js b/app/assets/javascripts/analytics/usage_trends/constants.js index 846c0ef408b..846c0ef408b 100644 --- a/app/assets/javascripts/analytics/instance_statistics/constants.js +++ b/app/assets/javascripts/analytics/usage_trends/constants.js diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql new file mode 100644 index 00000000000..2bde5973600 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql @@ -0,0 +1,4 @@ +fragment Count on UsageTrendsMeasurement { + count + recordedAt +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql index dd22a16cd51..2a5546efb68 100644 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_count.query.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql @@ -2,7 +2,7 @@ #import "../fragments/count.fragment.graphql" query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) { - instanceStatisticsMeasurements(identifier: $identifier, first: $first, after: $after) { + usageTrendsMeasurements(identifier: $identifier, first: $first, after: $after) { nodes { ...Count } diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql new file mode 100644 index 00000000000..8cadcfae380 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql @@ -0,0 +1,34 @@ +#import "../fragments/count.fragment.graphql" + +query getInstanceCounts { + projects: usageTrendsMeasurements(identifier: PROJECTS, first: 1) { + nodes { + ...Count + } + } + groups: usageTrendsMeasurements(identifier: GROUPS, first: 1) { + nodes { + ...Count + } + } + users: usageTrendsMeasurements(identifier: USERS, first: 1) { + nodes { + ...Count + } + } + issues: usageTrendsMeasurements(identifier: ISSUES, first: 1) { + nodes { + ...Count + } + } + mergeRequests: usageTrendsMeasurements(identifier: MERGE_REQUESTS, first: 1) { + nodes { + ...Count + } + } + pipelines: usageTrendsMeasurements(identifier: PIPELINES, first: 1) { + nodes { + ...Count + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql index 6235e36eb89..7c02ac49a42 100644 --- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql @@ -2,7 +2,7 @@ #import "../fragments/count.fragment.graphql" query getUsersCount($first: Int, $after: String) { - users: instanceStatisticsMeasurements(identifier: USERS, first: $first, after: $after) { + users: usageTrendsMeasurements(identifier: USERS, first: $first, after: $after) { nodes { ...Count } diff --git a/app/assets/javascripts/analytics/instance_statistics/index.js b/app/assets/javascripts/analytics/usage_trends/index.js index 0d7dcf6ace8..d1880b09f15 100644 --- a/app/assets/javascripts/analytics/instance_statistics/index.js +++ b/app/assets/javascripts/analytics/usage_trends/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import InstanceStatisticsApp from './components/app.vue'; +import UsageTrendsApp from './components/app.vue'; Vue.use(VueApollo); @@ -10,7 +10,7 @@ const apolloProvider = new VueApollo({ }); export default () => { - const el = document.getElementById('js-instance-statistics-app'); + const el = document.getElementById('js-usage-trends-app'); if (!el) return false; @@ -18,7 +18,7 @@ export default () => { el, apolloProvider, render(h) { - return h(InstanceStatisticsApp); + return h(UsageTrendsApp); }, }); }; diff --git a/app/assets/javascripts/analytics/instance_statistics/utils.js b/app/assets/javascripts/analytics/usage_trends/utils.js index 396962ffad6..91907877ed6 100644 --- a/app/assets/javascripts/analytics/instance_statistics/utils.js +++ b/app/assets/javascripts/analytics/usage_trends/utils.js @@ -41,8 +41,8 @@ export function getAverageByMonth(items = [], options = {}) { } /** - * 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 } + * Takes an array of usage counts and returns the last item in the list + * @param {Array} arr array of usage counts in the form { count: Number, recordedAt: date String } * @return {String} the 'recordedAt' value of the earliest item */ export const getEarliestDate = (arr = []) => { @@ -54,7 +54,7 @@ export const getEarliestDate = (arr = []) => { * 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 + * see ./analytics/usage_trends/components/charts_config.js * @param {any} defaultValue value to set each identifier to * @return {Object} key value pair of the form { queryIdentifier: defaultValue } */ diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index c7e6b98a934..2b589b71163 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -24,12 +24,14 @@ const Api = { projectPackagesPath: '/api/:version/projects/:id/packages', projectPackagePath: '/api/:version/projects/:id/packages/:package_id', groupProjectsPath: '/api/:version/groups/:id/projects.json', + groupSharePath: '/api/:version/groups/:id/share', projectsPath: '/api/:version/projects.json', projectPath: '/api/:version/projects/:id', forkedProjectsPath: '/api/:version/projects/:id/forks', projectLabelsPath: '/:namespace_path/:project_path/-/labels', projectFileSchemaPath: '/:namespace_path/:project_path/-/schema/:ref/:filename', projectUsersPath: '/api/:version/projects/:id/users', + projectGroupsPath: '/api/:version/projects/:id/groups.json', projectInvitationsPath: '/api/:version/projects/:id/invitations', projectMembersPath: '/api/:version/projects/:id/members', projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests', @@ -39,6 +41,7 @@ const Api = { projectRunnersPath: '/api/:version/projects/:id/runners', projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches', projectSearchPath: '/api/:version/projects/:id/search', + projectSharePath: '/api/:version/projects/:id/share', projectMilestonesPath: '/api/:version/projects/:id/milestones', projectIssuePath: '/api/:version/projects/:id/issues/:issue_iid', mergeRequestsPath: '/api/:version/merge_requests', @@ -239,6 +242,20 @@ const Api = { .then(({ data }) => data); }, + projectGroups(id, options) { + const url = Api.buildUrl(this.projectGroupsPath).replace(':id', encodeURIComponent(id)); + + return axios + .get(url, { + params: { + ...options, + }, + }) + .then(({ data }) => { + return data; + }); + }, + addProjectMembersByUserId(id, data) { const url = Api.buildUrl(this.projectMembersPath).replace(':id', encodeURIComponent(id)); @@ -365,6 +382,16 @@ const Api = { }); }, + projectShareWithGroup(id, options = {}) { + const url = Api.buildUrl(Api.projectSharePath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, { + expires_at: options.expires_at, + group_access: options.group_access, + group_id: options.group_id, + }); + }, + projectMilestones(id, params = {}) { const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id)); @@ -426,6 +453,16 @@ const Api = { }); }, + groupShareWithGroup(id, options = {}) { + const url = Api.buildUrl(Api.groupSharePath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, { + expires_at: options.expires_at, + group_access: options.group_access, + group_id: options.group_id, + }); + }, + commit(id, sha, params = {}) { const url = Api.buildUrl(this.commitPath) .replace(':id', encodeURIComponent(id)) @@ -446,7 +483,7 @@ const Api = { applySuggestion(id, message = '') { const url = Api.buildUrl(Api.applySuggestionPath).replace(':id', encodeURIComponent(id)); - const params = gon.features?.suggestionsCustomCommit ? { commit_message: message } : false; + const params = { commit_message: message }; return axios.put(url, params); }, diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue index 0e589d98668..55642aa64db 100644 --- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue +++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue @@ -162,7 +162,7 @@ export default { :href="profileAccountPath" :disabled="proceedButtonDisabled" :title="$options.i18n.proceedButton" - variant="success" + variant="confirm" data-qa-selector="proceed_button" data-track-event="click_button" :data-track-label="`${$options.trackingLabelPrefix}proceed_button`" diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 6fb90551ed7..dbdc7e43d2d 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -5,12 +5,14 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; import { uniq } from 'lodash'; import * as Emoji from '~/emoji'; +import { scrollToElement } from '~/lib/utils/common_utils'; import { dispose, fixTitle } from '~/tooltips'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; import { __ } from './locale'; +window.axios = axios; const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd'; const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd'; @@ -495,12 +497,7 @@ export class AwardsHandler { } scrollToAwards() { - const options = { - scrollTop: $('.awards').offset().top - 110, - }; - - // eslint-disable-next-line no-jquery/no-animate - return $('body, html').animate(options, 200); + scrollToElement('.awards', { offset: -110 }); } addEmojiToFrequentlyUsedList(emoji) { diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue index c3512773457..9e5d70075f3 100644 --- a/app/assets/javascripts/badges/components/badge.vue +++ b/app/assets/javascripts/badges/components/badge.vue @@ -96,7 +96,7 @@ export default { v-gl-tooltip.hover :title="s__('Badges|Reload badge image')" category="tertiary" - variant="success" + variant="confirm" type="button" icon="retry" size="small" diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 20541ad8ccc..b65a8b4fa9c 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -225,7 +225,7 @@ export default { <gl-button :loading="isSaving" type="submit" - variant="success" + variant="confirm" category="primary" data-testid="saveEditing" > @@ -233,7 +233,7 @@ export default { </gl-button> </div> <div v-else class="form-group"> - <gl-button :loading="isSaving" type="submit" variant="success" category="primary"> + <gl-button :loading="isSaving" type="submit" variant="confirm" category="primary"> {{ s__('Badges|Add badge') }} </gl-button> </div> diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index 36fef06eeff..88be64d0a1a 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -1,3 +1,4 @@ +import { isEmpty } from 'lodash'; import { deprecatedCreateFlash as flash } from '~/flash'; import { scrollToElement } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -88,18 +89,23 @@ export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGet export const updateDraft = ( { commit, getters }, { note, noteText, resolveDiscussion, position, callback }, -) => - service - .update(getters.getNotesData.draftsPath, { - draftId: note.id, - note: noteText, - resolveDiscussion, - position: JSON.stringify(position), - }) +) => { + const params = { + draftId: note.id, + note: noteText, + resolveDiscussion, + }; + // Stringifying an empty object yields `{}` which breaks graphql queries + // https://gitlab.com/gitlab-org/gitlab/-/issues/298827 + if (!isEmpty(position)) params.position = JSON.stringify(position); + + return service + .update(getters.getNotesData.draftsPath, params) .then((res) => res.data) .then((data) => commit(types.RECEIVE_DRAFT_UPDATE_SUCCESS, data)) .then(callback) .catch(() => flash(__('An error occurred while updating the comment'))); +}; export const scrollToDraft = ({ dispatch, rootGetters }, draft) => { const discussion = draft.discussion_id && rootGetters.getDiscussion(draft.discussion_id); diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js index a31bcc2cb41..de248340738 100644 --- a/app/assets/javascripts/behaviors/copy_to_clipboard.js +++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js @@ -1,31 +1,27 @@ import Clipboard from 'clipboard'; import $ from 'jquery'; import { sprintf, __ } from '~/locale'; -import { fixTitle, show } from '~/tooltips'; +import { fixTitle, add, show, once } from '~/tooltips'; function showTooltip(target, title) { - const { originalTitle } = target.dataset; - const hideTooltip = () => { - target.removeEventListener('mouseout', hideTooltip); - setTimeout(() => { + const { title: originalTitle } = target.dataset; + + once('hidden', (tooltip) => { + if (tooltip.target === target) { target.setAttribute('title', originalTitle); fixTitle(target); - }, 100); - }; + } + }); target.setAttribute('title', title); - fixTitle(target); show(target); - - target.addEventListener('mouseout', hideTooltip); + setTimeout(() => target.blur(), 1000); } function genericSuccess(e) { // Clear the selection and blur the trigger so it loses its border e.clearSelection(); - $(e.trigger).blur(); - showTooltip(e.trigger, __('Copied')); } @@ -88,24 +84,8 @@ export default function initCopyToClipboard() { * @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')); + add([btnElement], { show: true }); 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/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index 4b63143c4ba..30424fee46a 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -30,7 +30,7 @@ $(() => { } $('body').on('click', '.js-toggle-button', function toggleButton(e) { - e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'open'); + e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'selected'); toggleContainer($(this).closest('.js-toggle-container')); const targetTag = e.currentTarget.tagName.toLowerCase(); diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index e72c5c90986..445602a8765 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -3,6 +3,7 @@ import Dropzone from 'dropzone'; import $ from 'jquery'; import { sprintf, __ } from '~/locale'; +import { trackUploadFileFormSubmitted } from '~/projects/upload_file_experiment'; import { HIDDEN_CLASS } from '../lib/utils/constants'; import csrf from '../lib/utils/csrf'; import { visitUrl } from '../lib/utils/url_utility'; @@ -83,6 +84,9 @@ export default class BlobFileDropzone { submitButton.on('click', (e) => { e.preventDefault(); e.stopPropagation(); + + trackUploadFileFormSubmitted(); + if (dropzone[0].dropzone.getQueuedFiles().length === 0) { // eslint-disable-next-line no-alert alert(__('Please select a file')); diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index b4cd0d5d875..4741152afce 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -62,6 +62,7 @@ export default class BlobViewer { this.switcher = document.querySelector('.js-blob-viewer-switcher'); this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn'); this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn'); + this.copySourceBtnTooltip = document.querySelector('.js-copy-blob-source-btn-tooltip'); this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]'); this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]'); @@ -109,23 +110,23 @@ export default class BlobViewer { toggleCopyButtonState() { if (!this.copySourceBtn) return; if (this.simpleViewer.getAttribute('data-loaded')) { - this.copySourceBtn.setAttribute('title', __('Copy file contents')); + this.copySourceBtnTooltip.setAttribute('title', __('Copy file contents')); this.copySourceBtn.classList.remove('disabled'); } else if (this.activeViewer === this.simpleViewer) { - this.copySourceBtn.setAttribute( + this.copySourceBtnTooltip.setAttribute( 'title', __('Wait for the file to load to copy its contents'), ); this.copySourceBtn.classList.add('disabled'); } else { - this.copySourceBtn.setAttribute( + this.copySourceBtnTooltip.setAttribute( 'title', __('Switch to the source to copy the file contents'), ); this.copySourceBtn.classList.add('disabled'); } - fixTitle($(this.copySourceBtn)); + fixTitle($(this.copySourceBtnTooltip)); } switchToViewer(name) { diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js index 173c82ef9b0..6d9b56b4bb8 100644 --- a/app/assets/javascripts/blob_edit/blob_bundle.js +++ b/app/assets/javascripts/blob_edit/blob_bundle.js @@ -4,6 +4,7 @@ import $ from 'jquery'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { disableButtonIfEmptyField, setCookie } from '~/lib/utils/common_utils'; +import { initUploadFileTrigger } from '~/projects/upload_file_experiment'; import Tracking from '~/tracking'; import BlobFileDropzone from '../blob/blob_file_dropzone'; import NewCommitForm from '../new_commit_form'; @@ -47,6 +48,7 @@ export const initUploadForm = () => { new NewCommitForm(uploadBlobForm); disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file'); + initUploadFileTrigger(); } }; diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 13ad820477f..2cd25f58770 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -36,11 +36,11 @@ export function formatIssue(issue) { } export function formatListIssues(listIssues) { - const issues = {}; - let listIssuesCount; + const boardItems = {}; + let listItemsCount; const listData = listIssues.nodes.reduce((map, list) => { - listIssuesCount = list.issues.count; + listItemsCount = list.issues.count; let sortedIssues = list.issues.edges.map((issueNode) => ({ ...issueNode.node, })); @@ -58,14 +58,14 @@ export function formatListIssues(listIssues) { assignees: i.assignees?.nodes || [], }; - issues[id] = listIssue; + boardItems[id] = listIssue; return id; }), }; }, {}); - return { listData, issues, listIssuesCount }; + return { listData, boardItems, listItemsCount }; } export function formatListsPageInfo(lists) { @@ -113,31 +113,31 @@ export function formatIssueInput(issueInput, boardConfig) { }; } -export function moveIssueListHelper(issue, fromList, toList) { - const updatedIssue = issue; +export function moveItemListHelper(item, fromList, toList) { + const updatedItem = item; if ( toList.listType === ListType.label && - !updatedIssue.labels.find((label) => label.id === toList.label.id) + !updatedItem.labels.find((label) => label.id === toList.label.id) ) { - updatedIssue.labels.push(toList.label); + updatedItem.labels.push(toList.label); } if (fromList?.label && fromList.listType === ListType.label) { - updatedIssue.labels = updatedIssue.labels.filter((label) => fromList.label.id !== label.id); + updatedItem.labels = updatedItem.labels.filter((label) => fromList.label.id !== label.id); } if ( toList.listType === ListType.assignee && - !updatedIssue.assignees.find((assignee) => assignee.id === toList.assignee.id) + !updatedItem.assignees.find((assignee) => assignee.id === toList.assignee.id) ) { - updatedIssue.assignees.push(toList.assignee); + updatedItem.assignees.push(toList.assignee); } if (fromList?.assignee && fromList.listType === ListType.assignee) { - updatedIssue.assignees = updatedIssue.assignees.filter( + updatedItem.assignees = updatedItem.assignees.filter( (assignee) => assignee.id !== fromList.assignee.id, ); } - return updatedIssue; + return updatedItem; } export function isListDraggable(list) { diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue new file mode 100644 index 00000000000..3c7c792b787 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_add_new_column.vue @@ -0,0 +1,143 @@ +<script> +import { + GlFormRadio, + GlFormRadioGroup, + GlLabel, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue'; +import { ListType } from '~/boards/constants'; +import boardsStore from '~/boards/stores/boards_store'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + BoardAddNewColumnForm, + GlFormRadio, + GlFormRadioGroup, + GlLabel, + }, + directives: { + GlTooltip, + }, + inject: ['scopedLabelsAvailable'], + data() { + return { + selectedId: null, + }; + }, + computed: { + ...mapState(['labels', 'labelsLoading']), + ...mapGetters(['getListByLabelId', 'shouldUseGraphQL']), + selectedLabel() { + if (!this.selectedId) { + return null; + } + return this.labels.find(({ id }) => id === this.selectedId); + }, + columnForSelected() { + return this.getListByLabelId(this.selectedId); + }, + }, + created() { + this.filterItems(); + }, + methods: { + ...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']), + highlight(listId) { + if (this.shouldUseGraphQL) { + this.highlightList(listId); + } else { + const list = boardsStore.state.lists.find(({ id }) => id === listId); + list.highlighted = true; + setTimeout(() => { + list.highlighted = false; + }, 2000); + } + }, + addList() { + if (!this.selectedLabel) { + return; + } + + this.setAddColumnFormVisibility(false); + + if (this.columnForSelected) { + const listId = this.columnForSelected.id; + this.highlight(listId); + return; + } + + if (this.shouldUseGraphQL) { + this.createList({ labelId: this.selectedId }); + } else { + const listObj = { + labelId: getIdFromGraphQLId(this.selectedId), + title: this.selectedLabel.title, + position: boardsStore.state.lists.length - 2, + list_type: ListType.label, + label: this.selectedLabel, + }; + + boardsStore.new(listObj); + } + }, + + filterItems(searchTerm) { + this.fetchLabels(searchTerm); + }, + + showScopedLabels(label) { + return this.scopedLabelsAvailable && isScopedLabel(label); + }, + }, +}; +</script> + +<template> + <board-add-new-column-form + :loading="labelsLoading" + :form-description="__('A label list displays issues with the selected label.')" + :search-label="__('Select label')" + :search-placeholder="__('Search labels')" + :selected-id="selectedId" + @filter-items="filterItems" + @add-list="addList" + > + <template slot="selected"> + <gl-label + v-if="selectedLabel" + v-gl-tooltip + :title="selectedLabel.title" + :description="selectedLabel.description" + :background-color="selectedLabel.color" + :scoped="showScopedLabels(selectedLabel)" + /> + </template> + + <template slot="items"> + <gl-form-radio-group + v-if="labels.length > 0" + v-model="selectedId" + class="gl-overflow-y-auto gl-px-5 gl-pt-3" + > + <label + v-for="label in labels" + :key="label.id" + class="gl-display-flex gl-flex-align-items-center gl-mb-5 gl-font-weight-normal" + > + <gl-form-radio :value="label.id" class="gl-mb-0" /> + <span + class="dropdown-label-box gl-top-0" + :style="{ + backgroundColor: label.color, + }" + ></span> + <span>{{ label.title }}</span> + </label> + </gl-form-radio-group> + </template> + </board-add-new-column-form> +</template> diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue new file mode 100644 index 00000000000..d85343a5390 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -0,0 +1,131 @@ +<script> +import { GlButton, GlFormGroup, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import { __ } from '~/locale'; + +export default { + i18n: { + add: __('Add to board'), + cancel: __('Cancel'), + newList: __('New list'), + noneSelected: __('None'), + noResults: __('No matching results'), + selected: __('Selected'), + }, + components: { + GlButton, + GlFormGroup, + GlSearchBoxByType, + GlSkeletonLoader, + }, + props: { + loading: { + type: Boolean, + required: true, + }, + formDescription: { + type: String, + required: true, + }, + searchLabel: { + type: String, + required: true, + }, + searchPlaceholder: { + type: String, + required: true, + }, + selectedId: { + type: [Number, String], + required: false, + default: null, + }, + }, + data() { + return { + searchValue: '', + }; + }, + methods: { + ...mapActions(['setAddColumnFormVisibility']), + }, +}; +</script> + +<template> + <div + class="board-add-new-list board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0" + data-testid="board-add-new-column" + data-qa-selector="board_add_new_list" + > + <div + class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-white" + > + <h3 + class="gl-font-base gl-px-5 gl-py-5 gl-m-0 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + data-testid="board-add-column-form-title" + > + {{ $options.i18n.newList }} + </h3> + + <div class="gl-display-flex gl-flex-direction-column gl-h-full gl-overflow-hidden"> + <slot name="select-list-type"> + <div class="gl-mb-5"></div> + </slot> + + <p class="gl-px-5">{{ formDescription }}</p> + + <div class="gl-px-5 gl-pb-4"> + <label class="gl-mb-2">{{ $options.i18n.selected }}</label> + <slot name="selected"> + <div class="gl-text-gray-500">{{ $options.i18n.noneSelected }}</div> + </slot> + </div> + + <gl-form-group + class="gl-mx-5 gl-mb-3" + :label="searchLabel" + label-for="board-available-column-entities" + > + <gl-search-box-by-type + id="board-available-column-entities" + v-model="searchValue" + debounce="250" + :placeholder="searchPlaceholder" + @input="$emit('filter-items', $event)" + /> + </gl-form-group> + + <div v-if="loading" class="gl-px-5"> + <gl-skeleton-loader :width="500" :height="172"> + <rect width="480" height="20" x="10" y="15" rx="4" /> + <rect width="380" height="20" x="10" y="50" rx="4" /> + <rect width="430" height="20" x="10" y="85" rx="4" /> + </gl-skeleton-loader> + </div> + + <slot v-else name="items"> + <p class="gl-mx-5">{{ $options.i18n.noResults }}</p> + </slot> + </div> + <div + class="gl-display-flex gl-p-3 gl-border-t-1 gl-border-t-solid gl-border-gray-100 gl-bg-gray-10 gl-rounded-bottom-left-base gl-rounded-bottom-right-base" + > + <gl-button + data-testid="cancelAddNewColumn" + class="gl-ml-auto gl-mr-3" + @click="setAddColumnFormVisibility(false)" + >{{ $options.i18n.cancel }}</gl-button + > + <gl-button + data-testid="addNewColumnButton" + :disabled="!selectedId" + variant="confirm" + class="gl-mr-4" + @click="$emit('add-list')" + >{{ $options.i18n.add }}</gl-button + > + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue index 85fca589279..7c08e33be7e 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_trigger.vue @@ -13,7 +13,7 @@ export default { </script> <template> - <span class="gl-ml-3 gl-display-flex gl-align-items-center"> + <span class="gl-ml-3 gl-display-flex gl-align-items-center" data-testid="boards-create-list"> <gl-button variant="confirm" @click="setAddColumnFormVisibility(true)" >{{ __('Create list') }} </gl-button> diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index e6009343626..aacea0b970c 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,14 +1,11 @@ <script> -import sidebarEventHub from '~/sidebar/event_hub'; -import eventHub from '../eventhub'; -import boardsStore from '../stores/boards_store'; -import BoardCardLayout from './board_card_layout.vue'; -import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import BoardCardInner from './board_card_inner.vue'; export default { - name: 'BoardsIssueCard', + name: 'BoardCard', components: { - BoardCardLayout: gon.features?.graphqlBoardLists ? BoardCardLayout : BoardCardLayoutDeprecated, + BoardCardInner, }, props: { list: { @@ -16,34 +13,46 @@ export default { default: () => ({}), required: false, }, - issue: { + item: { type: Object, default: () => ({}), required: false, }, + disabled: { + type: Boolean, + default: false, + required: false, + }, + index: { + type: Number, + default: 0, + required: false, + }, }, - methods: { - // These are methods instead of computed's, because boardsStore is not reactive. + computed: { + ...mapState(['selectedBoardItems', 'activeId']), + ...mapGetters(['isSwimlanesOn']), isActive() { - return this.getActiveId() === this.issue.id; + return this.item.id === this.activeId; }, - getActiveId() { - return boardsStore.detail?.issue?.id; + multiSelectVisible() { + return ( + !this.activeId && + this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.item.id) > -1 + ); }, - showIssue({ isMultiSelect }) { - // If no issues are opened, close all sidebars first - if (!this.getActiveId()) { - sidebarEventHub.$emit('sidebar.closeAll'); - } - if (this.isActive()) { - eventHub.$emit('clearDetailIssue', isMultiSelect); + }, + methods: { + ...mapActions(['toggleBoardItemMultiSelection', 'toggleBoardItem']), + toggleIssue(e) { + // Don't do anything if this happened on a no trigger element + if (e.target.classList.contains('js-no-trigger')) return; - if (isMultiSelect) { - eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); - } + const isMultiSelect = e.ctrlKey || e.metaKey; + if (isMultiSelect) { + this.toggleBoardItemMultiSelection(this.item); } else { - eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); - boardsStore.setListDetail(this.list); + this.toggleBoardItem({ boardItem: this.item }); } }, }, @@ -51,12 +60,22 @@ export default { </script> <template> - <board-card-layout + <li data-qa-selector="board_card" - :issue="issue" - :list="list" - :is-active="isActive()" - v-bind="$attrs" - @show="showIssue" - /> + :class="{ + 'multi-select': multiSelectVisible, + 'user-can-drag': !disabled && item.id, + 'is-disabled': disabled || !item.id, + 'is-active': isActive, + }" + :index="index" + :data-item-id="item.id" + :data-item-iid="item.iid" + :data-item-path="item.referencePath" + data-testid="board_card" + class="board-card gl-p-5 gl-rounded-base" + @mouseup="toggleIssue($event)" + > + <board-card-inner :list="list" :item="item" :update-filters="true" /> + </li> </template> diff --git a/app/assets/javascripts/boards/components/board_card_deprecated.vue b/app/assets/javascripts/boards/components/board_card_deprecated.vue new file mode 100644 index 00000000000..e12a2836f67 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_card_deprecated.vue @@ -0,0 +1,61 @@ +<script> +// This component is being replaced in favor of './board_card.vue' for GraphQL boards +import sidebarEventHub from '~/sidebar/event_hub'; +import eventHub from '../eventhub'; +import boardsStore from '../stores/boards_store'; +import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue'; + +export default { + components: { + BoardCardLayout: BoardCardLayoutDeprecated, + }, + props: { + list: { + type: Object, + default: () => ({}), + required: false, + }, + issue: { + type: Object, + default: () => ({}), + required: false, + }, + }, + methods: { + // These are methods instead of computed's, because boardsStore is not reactive. + isActive() { + return this.getActiveId() === this.issue.id; + }, + getActiveId() { + return boardsStore.detail?.issue?.id; + }, + showIssue({ isMultiSelect }) { + // If no issues are opened, close all sidebars first + if (!this.getActiveId()) { + sidebarEventHub.$emit('sidebar.closeAll'); + } + if (this.isActive()) { + eventHub.$emit('clearDetailIssue', isMultiSelect); + + if (isMultiSelect) { + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); + } + } else { + eventHub.$emit('newDetailIssue', this.issue, isMultiSelect); + boardsStore.setListDetail(this.list); + } + }, + }, +}; +</script> + +<template> + <board-card-layout + data-qa-selector="board_card" + :issue="issue" + :list="list" + :is-active="isActive()" + v-bind="$attrs" + @show="showIssue" + /> +</template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index e5ea30df767..d4d6b17a589 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -1,8 +1,8 @@ <script> import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { sortBy } from 'lodash'; -import { mapActions, mapState } from 'vuex'; -import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { updateHistory } from '~/lib/utils/url_utility'; import { sprintf, __, n__ } from '~/locale'; @@ -26,10 +26,10 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [issueCardInner], - inject: ['groupId', 'rootPath', 'scopedLabelsAvailable'], + mixins: [boardCardInner], + inject: ['rootPath', 'scopedLabelsAvailable'], props: { - issue: { + item: { type: Object, required: true, }, @@ -53,18 +53,19 @@ export default { }, computed: { ...mapState(['isShowingLabels']), + ...mapGetters(['isEpicBoard']), cappedAssignees() { // e.g. maxRender is 4, // Render up to all 4 assignees if there are only 4 assigness // Otherwise render up to the limitBeforeCounter - if (this.issue.assignees.length <= this.maxRender) { - return this.issue.assignees.slice(0, this.maxRender); + if (this.item.assignees.length <= this.maxRender) { + return this.item.assignees.slice(0, this.maxRender); } - return this.issue.assignees.slice(0, this.limitBeforeCounter); + return this.item.assignees.slice(0, this.limitBeforeCounter); }, numberOverLimit() { - return this.issue.assignees.length - this.limitBeforeCounter; + return this.item.assignees.length - this.limitBeforeCounter; }, assigneeCounterTooltip() { const { numberOverLimit, maxCounter } = this; @@ -79,31 +80,35 @@ export default { return `+${this.numberOverLimit}`; }, shouldRenderCounter() { - if (this.issue.assignees.length <= this.maxRender) { + if (this.item.assignees.length <= this.maxRender) { return false; } - return this.issue.assignees.length > this.numberOverLimit; + return this.item.assignees.length > this.numberOverLimit; }, - issueId() { - if (this.issue.iid) { - return `#${this.issue.iid}`; + itemPrefix() { + return this.isEpicBoard ? '&' : '#'; + }, + + itemId() { + if (this.item.iid) { + return `${this.itemPrefix}${this.item.iid}`; } return false; }, showLabelFooter() { - return this.isShowingLabels && this.issue.labels.find(this.showLabel); + return this.isShowingLabels && this.item.labels.find(this.showLabel); }, - issueReferencePath() { - const { referencePath, groupId } = this.issue; - return !groupId ? referencePath.split('#')[0] : null; + itemReferencePath() { + const { referencePath } = this.item; + return referencePath.split(this.itemPrefix)[0]; }, orderedLabels() { - return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title'); + return sortBy(this.item.labels.filter(this.isNonListLabel), 'title'); }, blockedLabel() { - if (this.issue.blockedByCount) { - return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount); + if (this.item.blockedByCount) { + return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.item.blockedByCount); } return __('Blocked issue'); }, @@ -160,7 +165,7 @@ export default { <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-if="item.blocked" v-gl-tooltip name="issue-block" :title="blockedLabel" @@ -169,7 +174,7 @@ export default { data-testid="issue-blocked-icon" /> <gl-icon - v-if="issue.confidential" + v-if="item.confidential" v-gl-tooltip name="eye-slash" :title="__('Confidential')" @@ -177,11 +182,11 @@ export default { :aria-label="__('Confidential')" /> <a - :href="issue.path || issue.webUrl || ''" - :title="issue.title" + :href="item.path || item.webUrl || ''" + :title="item.title" class="js-no-trigger" @mousemove.stop - >{{ issue.title }}</a + >{{ item.title }}</a > </h4> </div> @@ -205,29 +210,30 @@ export default { 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" + v-if="item.referencePath" class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3" + :class="{ 'gl-font-base': isEpicBoard }" > <tooltip-on-truncate - v-if="issueReferencePath" - :title="issueReferencePath" + v-if="itemReferencePath" + :title="itemReferencePath" placement="bottom" - class="board-issue-path gl-text-truncate gl-font-weight-bold" - >{{ issueReferencePath }}</tooltip-on-truncate + class="board-item-path gl-text-truncate gl-font-weight-bold" + >{{ itemReferencePath }}</tooltip-on-truncate > - #{{ issue.iid }} + {{ itemId }} </span> <span class="board-info-items gl-mt-3 gl-display-inline-block"> <issue-due-date - v-if="issue.dueDate" - :date="issue.dueDate" - :closed="issue.closed || Boolean(issue.closedAt)" + v-if="item.dueDate" + :date="item.dueDate" + :closed="item.closed || Boolean(item.closedAt)" /> - <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> + <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" /> <issue-card-weight - v-if="validIssueWeight" - :weight="issue.weight" - @click="filterByWeight(issue.weight)" + v-if="validIssueWeight(item)" + :weight="item.weight" + @click="filterByWeight(item.weight)" /> </span> </div> diff --git a/app/assets/javascripts/boards/components/board_card_layout.vue b/app/assets/javascripts/boards/components/board_card_layout.vue deleted file mode 100644 index 5e3c3702519..00000000000 --- a/app/assets/javascripts/boards/components/board_card_layout.vue +++ /dev/null @@ -1,98 +0,0 @@ -<script> -import { mapActions, mapGetters, mapState } from 'vuex'; -import { ISSUABLE } from '~/boards/constants'; -import IssueCardInner from './issue_card_inner.vue'; - -export default { - name: 'BoardCardLayout', - components: { - IssueCardInner, - }, - props: { - list: { - type: Object, - default: () => ({}), - required: false, - }, - issue: { - type: Object, - default: () => ({}), - required: false, - }, - disabled: { - type: Boolean, - default: false, - required: false, - }, - index: { - type: Number, - default: 0, - required: false, - }, - isActive: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - showDetail: false, - }; - }, - computed: { - ...mapState(['selectedBoardItems']), - ...mapGetters(['isSwimlanesOn']), - multiSelectVisible() { - return this.selectedBoardItems.findIndex((boardItem) => boardItem.id === this.issue.id) > -1; - }, - }, - methods: { - ...mapActions(['setActiveId', 'toggleBoardItemMultiSelection']), - mouseDown() { - this.showDetail = true; - }, - mouseMove() { - this.showDetail = false; - }, - showIssue(e) { - // Don't do anything if this happened on a no trigger element - if (e.target.classList.contains('js-no-trigger')) return; - - const isMultiSelect = e.ctrlKey || e.metaKey; - - if (!isMultiSelect) { - this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE }); - } else { - this.toggleBoardItemMultiSelection(this.issue); - } - - if (this.showDetail || isMultiSelect) { - this.showDetail = false; - } - }, - }, -}; -</script> - -<template> - <li - :class="{ - 'multi-select': multiSelectVisible, - 'user-can-drag': !disabled && issue.id, - 'is-disabled': disabled || !issue.id, - 'is-active': isActive, - }" - :index="index" - :data-issue-id="issue.id" - :data-issue-iid="issue.iid" - :data-issue-path="issue.referencePath" - data-testid="board_card" - class="board-card gl-p-5 gl-rounded-base" - @mousedown="mouseDown" - @mousemove="mouseMove" - @mouseup="showIssue($event)" - > - <issue-card-inner :list="list" :issue="issue" :update-filters="true" /> - </li> -</template> diff --git a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue index f9a726134a3..3381e4c3a7d 100644 --- a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue @@ -3,13 +3,12 @@ import { mapActions, mapGetters } from 'vuex'; import { ISSUABLE } from '~/boards/constants'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import boardsStore from '../stores/boards_store'; -import IssueCardInner from './issue_card_inner.vue'; import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue'; export default { name: 'BoardCardLayout', components: { - IssueCardInner: gon.features?.graphqlBoardLists ? IssueCardInner : IssueCardInnerDeprecated, + IssueCardInner: IssueCardInnerDeprecated, }, mixins: [glFeatureFlagMixin()], props: { diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 41b9ee795eb..c9e667d526c 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -32,12 +32,12 @@ export default { }, computed: { ...mapState(['filterParams', 'highlightedLists']), - ...mapGetters(['getIssuesByList']), + ...mapGetters(['getBoardItemsByList']), highlighted() { return this.highlightedLists.includes(this.list.id); }, - listIssues() { - return this.getIssuesByList(this.list.id); + listItems() { + return this.getBoardItemsByList(this.list.id); }, isListDraggable() { return isListDraggable(this.list); @@ -46,11 +46,20 @@ export default { watch: { filterParams: { handler() { - this.fetchIssuesForList({ listId: this.list.id }); + if (this.list.id) { + this.fetchItemsForList({ listId: this.list.id }); + } }, deep: true, immediate: true, }, + 'list.id': { + handler(id) { + if (id) { + this.fetchItemsForList({ listId: this.list.id }); + } + }, + }, highlighted: { handler(highlighted) { if (highlighted) { @@ -63,7 +72,7 @@ export default { }, }, methods: { - ...mapActions(['fetchIssuesForList']), + ...mapActions(['fetchItemsForList']), }, }; </script> @@ -87,7 +96,7 @@ export default { <board-list ref="board-list" :disabled="disabled" - :issues="listIssues" + :board-items="listItems" :list="list" :can-admin-list="canAdminList" /> diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 9b10e7d7db5..e9c4237d759 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -3,6 +3,7 @@ import { GlAlert } from '@gitlab/ui'; import { sortBy } from 'lodash'; import Draggable from 'vuedraggable'; import { mapState, mapGetters, mapActions } from 'vuex'; +import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue'; import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options'; import defaultSortableConfig from '~/sortable/sortable_config'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -11,7 +12,11 @@ import BoardColumnDeprecated from './board_column_deprecated.vue'; export default { components: { - BoardColumn: gon.features?.graphqlBoardLists ? BoardColumn : BoardColumnDeprecated, + BoardAddNewColumn, + BoardColumn: + gon.features?.graphqlBoardLists || gon.features?.epicBoards + ? BoardColumn + : BoardColumnDeprecated, BoardContentSidebar: () => import('ee_component/boards/components/board_content_sidebar.vue'), EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'), GlAlert, @@ -33,15 +38,18 @@ export default { }, }, computed: { - ...mapState(['boardLists', 'error']), - ...mapGetters(['isSwimlanesOn']), + ...mapState(['boardLists', 'error', 'addColumnForm']), + ...mapGetters(['isSwimlanesOn', 'isEpicBoard']), + addColumnFormVisible() { + return this.addColumnForm?.visible; + }, boardListsToUse() { - return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn + return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard ? sortBy([...Object.values(this.boardLists)], 'position') : this.lists; }, canDragColumns() { - return this.glFeatures.graphqlBoardLists && this.canAdminList; + return !this.isEpicBoard && this.glFeatures.graphqlBoardLists && this.canAdminList; }, boardColumnWrapper() { return this.canDragColumns ? Draggable : 'div'; @@ -62,12 +70,17 @@ export default { }, methods: { ...mapActions(['moveList']), + afterFormEnters() { + const el = this.canDragColumns ? this.$refs.list.$el : this.$refs.list; + el.scrollTo({ left: el.scrollWidth, behavior: 'smooth' }); + }, handleDragOnStart() { sortableStart(); }, handleDragOnEnd(params) { sortableEnd(); + if (this.isEpicBoard) return; const { item, newIndex, oldIndex, to } = params; @@ -100,13 +113,17 @@ export default { @end="handleDragOnEnd" > <board-column - v-for="list in boardListsToUse" - :key="list.id" + v-for="(list, index) in boardListsToUse" + :key="index" ref="board" :can-admin-list="canAdminList" :list="list" :disabled="disabled" /> + + <transition name="slide" @after-enter="afterFormEnters"> + <board-add-new-column v-if="addColumnFormVisible" /> + </transition> </component> <epics-swimlanes diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index f65f00bcccc..d8504dcfb0f 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,5 +1,6 @@ <script> import { GlModal } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; import { deprecatedCreateFlash as Flash } from '~/flash'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { getParameterByName } from '~/lib/utils/common_utils'; @@ -106,6 +107,7 @@ export default { }; }, computed: { + ...mapGetters(['isEpicBoard', 'isGroupBoard', 'isProjectBoard']), isNewForm() { return this.currentPage === formType.new; }, @@ -161,42 +163,49 @@ export default { currentMutation() { return this.board.id ? updateBoardMutation : createBoardMutation; }, - mutationVariables() { + baseMutationVariables() { const { board } = this; - /* eslint-disable @gitlab/require-i18n-strings */ - let baseMutationVariables = { + const variables = { name: board.name, hideBacklogList: board.hide_backlog_list, hideClosedList: board.hide_closed_list, }; - if (this.scopedIssueBoardFeatureEnabled) { - baseMutationVariables = { - ...baseMutationVariables, - weight: board.weight, - assigneeId: board.assignee?.id ? convertToGraphQLId('User', board.assignee.id) : null, - milestoneId: - board.milestone?.id || board.milestone?.id === 0 - ? convertToGraphQLId('Milestone', board.milestone.id) - : null, - labelIds: board.labels.map(fullLabelId), - iterationId: board.iteration_id - ? convertToGraphQLId('Iteration', board.iteration_id) - : null, - }; - } - /* eslint-enable @gitlab/require-i18n-strings */ return board.id ? { - ...baseMutationVariables, + ...variables, id: fullBoardId(board.id), } : { - ...baseMutationVariables, - projectPath: this.projectId ? this.fullPath : null, - groupPath: this.groupId ? this.fullPath : null, + ...variables, + projectPath: this.isProjectBoard ? this.fullPath : undefined, + groupPath: this.isGroupBoard ? this.fullPath : undefined, }; }, + boardScopeMutationVariables() { + /* eslint-disable @gitlab/require-i18n-strings */ + return { + weight: this.board.weight, + assigneeId: this.board.assignee?.id + ? convertToGraphQLId('User', this.board.assignee.id) + : null, + milestoneId: + this.board.milestone?.id || this.board.milestone?.id === 0 + ? convertToGraphQLId('Milestone', this.board.milestone.id) + : null, + labelIds: this.board.labels.map(fullLabelId), + iterationId: this.board.iteration_id + ? convertToGraphQLId('Iteration', this.board.iteration_id) + : null, + }; + /* eslint-enable @gitlab/require-i18n-strings */ + }, + mutationVariables() { + return { + ...this.baseMutationVariables, + ...(this.scopedIssueBoardFeatureEnabled ? this.boardScopeMutationVariables : {}), + }; + }, }, mounted() { this.resetFormState(); @@ -208,6 +217,16 @@ export default { setIteration(iterationId) { this.board.iteration_id = iterationId; }, + boardCreateResponse(data) { + return data.createBoard.board.webPath; + }, + boardUpdateResponse(data) { + const path = data.updateBoard.board.webPath; + const param = getParameterByName('group_by') + ? `?group_by=${getParameterByName('group_by')}` + : ''; + return `${path}${param}`; + }, async createOrUpdateBoard() { const response = await this.$apollo.mutate({ mutation: this.currentMutation, @@ -215,14 +234,10 @@ export default { }); if (!this.board.id) { - return response.data.createBoard.board.webPath; + return this.boardCreateResponse(response.data); } - const path = response.data.updateBoard.board.webPath; - const param = getParameterByName('group_by') - ? `?group_by=${getParameterByName('group_by')}` - : ''; - return `${path}${param}`; + return this.boardUpdateResponse(response.data); }, async submit() { if (this.board.name.length === 0) return; @@ -309,7 +324,7 @@ export default { /> <board-scope - v-if="scopedIssueBoardFeatureEnabled" + v-if="scopedIssueBoardFeatureEnabled && !isEpicBoard" :collapse-scope="isNewForm" :board="board" :can-admin-board="canAdminBoard" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 7495b1163be..8945ef7002e 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -12,8 +12,8 @@ import BoardNewIssue from './board_new_issue.vue'; export default { name: 'BoardList', i18n: { - loadingIssues: __('Loading issues'), - loadingMoreissues: __('Loading more issues'), + loading: __('Loading'), + loadingMoreboardItems: __('Loading more'), showingAllIssues: __('Showing all issues'), }, components: { @@ -30,7 +30,7 @@ export default { type: Object, required: true, }, - issues: { + boardItems: { type: Array, required: true, }, @@ -51,11 +51,11 @@ export default { ...mapState(['pageInfoByListId', 'listsFlags']), paginatedIssueText() { return sprintf(__('Showing %{pageSize} of %{total} issues'), { - pageSize: this.issues.length, + pageSize: this.boardItems.length, total: this.list.issuesCount, }); }, - issuesSizeExceedsMax() { + boardItemsSizeExceedsMax() { return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount; }, hasNextPage() { @@ -72,7 +72,7 @@ export default { return this.canAdminList ? this.$refs.list.$el : this.$refs.list; }, showingAllIssues() { - return this.issues.length === this.list.issuesCount; + return this.boardItems.length === this.list.issuesCount; }, treeRootWrapper() { return this.canAdminList ? Draggable : 'ul'; @@ -85,14 +85,14 @@ export default { tag: 'ul', 'ghost-class': 'board-card-drag-active', 'data-list-id': this.list.id, - value: this.issues, + value: this.boardItems, }; return this.canAdminList ? options : {}; }, }, watch: { - issues() { + boardItems() { this.$nextTick(() => { this.showCount = this.scrollHeight() > Math.ceil(this.listHeight()); }); @@ -112,7 +112,7 @@ export default { this.listRef.removeEventListener('scroll', this.onScroll); }, methods: { - ...mapActions(['fetchIssuesForList', 'moveIssue']), + ...mapActions(['fetchItemsForList', 'moveItem']), listHeight() { return this.listRef.getBoundingClientRect().height; }, @@ -126,7 +126,7 @@ export default { this.listRef.scrollTop = 0; }, loadNextPage() { - this.fetchIssuesForList({ listId: this.list.id, fetchNext: true }); + this.fetchItemsForList({ listId: this.list.id, fetchNext: true }); }, toggleForm() { this.showIssueForm = !this.showIssueForm; @@ -148,40 +148,40 @@ export default { handleDragOnEnd(params) { sortableEnd(); const { newIndex, oldIndex, from, to, item } = params; - const { issueId, issueIid, issuePath } = item.dataset; + const { itemId, itemIid, itemPath } = item.dataset; const { children } = to; let moveBeforeId; let moveAfterId; - const getIssueId = (el) => Number(el.dataset.issueId); + const getItemId = (el) => Number(el.dataset.itemId); - // If issue is being moved within the same list + // If item is being moved within the same list if (from === to) { if (newIndex > oldIndex && children.length > 1) { - // If issue is being moved down we look for the issue that ends up before - moveBeforeId = getIssueId(children[newIndex]); + // If item is being moved down we look for the item that ends up before + moveBeforeId = getItemId(children[newIndex]); } else if (newIndex < oldIndex && children.length > 1) { - // If issue is being moved up we look for the issue that ends up after - moveAfterId = getIssueId(children[newIndex]); + // If item is being moved up we look for the item that ends up after + moveAfterId = getItemId(children[newIndex]); } else { - // If issue remains in the same list at the same position we do nothing + // If item remains in the same list at the same position we do nothing return; } } else { - // We look for the issue that ends up before the moved issue if it exists + // We look for the item that ends up before the moved item if it exists if (children[newIndex - 1]) { - moveBeforeId = getIssueId(children[newIndex - 1]); + moveBeforeId = getItemId(children[newIndex - 1]); } - // We look for the issue that ends up after the moved issue if it exists + // We look for the item that ends up after the moved item if it exists if (children[newIndex]) { - moveAfterId = getIssueId(children[newIndex]); + moveAfterId = getItemId(children[newIndex]); } } - this.moveIssue({ - issueId, - issueIid, - issuePath, + this.moveItem({ + itemId, + itemIid, + itemPath, fromListId: from.dataset.listId, toListId: to.dataset.listId, moveBeforeId, @@ -201,7 +201,7 @@ export default { <div v-if="loading" class="gl-mt-4 gl-text-center" - :aria-label="$options.i18n.loadingIssues" + :aria-label="$options.i18n.loading" data-testid="board_list_loading" > <gl-loading-icon /> @@ -214,23 +214,27 @@ export default { v-bind="treeRootOptions" :data-board="list.id" :data-board-type="list.listType" - :class="{ 'bg-danger-100': issuesSizeExceedsMax }" + :class="{ 'bg-danger-100': boardItemsSizeExceedsMax }" class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list" data-testid="tree-root-wrapper" @start="handleDragOnStart" @end="handleDragOnEnd" > <board-card - v-for="(issue, index) in issues" + v-for="(item, index) in boardItems" ref="issue" - :key="issue.id" + :key="item.id" :index="index" :list="list" - :issue="issue" + :item="item" :disabled="disabled" /> <li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1"> - <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" /> + <gl-loading-icon + v-if="loadingMore" + :label="$options.i18n.loadingMoreboardItems" + data-testid="count-loading-icon" + /> <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span> <span v-else>{{ paginatedIssueText }}</span> </li> diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue index 9b4961d362d..d59fbcc1b31 100644 --- a/app/assets/javascripts/boards/components/board_list_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_list_deprecated.vue @@ -11,7 +11,7 @@ import { sortableEnd, } from '../mixins/sortable_default_options'; import boardsStore from '../stores/boards_store'; -import boardCard from './board_card.vue'; +import boardCard from './board_card_deprecated.vue'; import boardNewIssue from './board_new_issue_deprecated.vue'; // This component is being replaced in favor of './board_list.vue' for GraphQL boards diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index a933370427c..9054a34974f 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -8,9 +8,9 @@ import { GlSprintf, GlTooltipDirective, } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { isListDraggable } from '~/boards/boards_util'; -import { isScopedLabel } from '~/lib/utils/common_utils'; +import { isScopedLabel, parseBoolean } from '~/lib/utils/common_utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { n__, s__, __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; @@ -70,6 +70,7 @@ export default { }, computed: { ...mapState(['activeId']), + ...mapGetters(['isEpicBoard']), isLoggedIn() { return Boolean(this.currentUserId); }, @@ -97,11 +98,14 @@ export default { showListDetails() { return !this.list.collapsed || !this.isSwimlanesHeader; }, - issuesCount() { + itemsCount() { return this.list.issuesCount; }, - issuesTooltipLabel() { - return n__(`%d issue`, `%d issues`, this.issuesCount); + countIcon() { + return 'issues'; + }, + itemsTooltipLabel() { + return n__(`%d issue`, `%d issues`, this.itemsCount); }, chevronTooltip() { return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse; @@ -110,7 +114,7 @@ export default { return this.list.collapsed ? 'chevron-down' : 'chevron-right'; }, isNewIssueShown() { - return this.listType === ListType.backlog || this.showListHeaderButton; + return (this.listType === ListType.backlog || this.showListHeaderButton) && !this.isEpicBoard; }, isSettingsShown() { return ( @@ -131,8 +135,14 @@ export default { return !this.disabled && isListDraggable(this.list); }, }, + created() { + const localCollapsed = parseBoolean(localStorage.getItem(`${this.uniqueKey}.collapsed`)); + if ((!this.isLoggedIn || this.isEpicBoard) && localCollapsed) { + this.toggleListCollapsed({ listId: this.list.id, collapsed: true }); + } + }, methods: { - ...mapActions(['updateList', 'setActiveId']), + ...mapActions(['updateList', 'setActiveId', 'toggleListCollapsed']), openSidebarSettings() { if (this.activeId === inactiveId) { sidebarEventHub.$emit('sidebar.closeAll'); @@ -148,10 +158,10 @@ export default { eventHub.$emit(`toggle-issue-form-${this.list.id}`); }, toggleExpanded() { - // eslint-disable-next-line vue/no-mutating-props - this.list.collapsed = !this.list.collapsed; + const collapsed = !this.list.collapsed; + this.toggleListCollapsed({ listId: this.list.id, collapsed }); - if (!this.isLoggedIn) { + if (!this.isLoggedIn || this.isEpicBoard) { this.addToLocalStorage(); } else { this.updateListFunction(); @@ -163,7 +173,7 @@ export default { }, addToLocalStorage() { if (AccessorUtilities.isLocalStorageAccessSafe()) { - localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed); + localStorage.setItem(`${this.uniqueKey}.collapsed`, this.list.collapsed); } }, updateListFunction() { @@ -203,6 +213,7 @@ export default { class="board-title-caret no-drag gl-cursor-pointer" category="tertiary" size="small" + data-testid="board-title-caret" @click="toggleExpanded" /> <!-- EE start --> @@ -301,11 +312,11 @@ export default { <div v-if="list.maxIssueCount !== 0"> • <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')"> - <template #issuesSize>{{ issuesTooltipLabel }}</template> + <template #issuesSize>{{ itemsTooltipLabel }}</template> <template #maxIssueCount>{{ list.maxIssueCount }}</template> </gl-sprintf> </div> - <div v-else>• {{ issuesTooltipLabel }}</div> + <div v-else>• {{ itemsTooltipLabel }}</div> <div v-if="weightFeatureAvailable"> • <gl-sprintf :message="__('%{totalWeight} total weight')"> @@ -323,13 +334,13 @@ export default { }" > <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" /> + <gl-tooltip :target="() => $refs.itemCount" :title="itemsTooltipLabel" /> + <span ref="itemCount" class="issue-count-badge-count"> + <gl-icon class="gl-mr-2" :name="countIcon" /> + <issue-count :issues-size="itemsCount" :max-issue-count="list.maxIssueCount" /> </span> <!-- EE start --> - <template v-if="weightFeatureAvailable"> + <template v-if="weightFeatureAvailable && !isEpicBoard"> <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" /> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 1df154688c8..a81c28733cd 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapGetters, mapState } from 'vuex'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -32,8 +32,9 @@ export default { }, computed: { ...mapState(['selectedProject']), + ...mapGetters(['isGroupBoard']), disabled() { - if (this.groupId) { + if (this.isGroupBoard) { return this.title === '' || !this.selectedProject.name; } return this.title === ''; @@ -98,7 +99,7 @@ export default { name="issue_title" autocomplete="off" /> - <project-select v-if="groupId" :group-id="groupId" :list="list" /> + <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> <div class="clearfix gl-mt-3"> <gl-button ref="submitButton" diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue index eff87ff110e..16f23dfff0e 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; import { getMilestone } from 'ee_else_ce/boards/boards_util'; import ListIssue from 'ee_else_ce/boards/models/issue'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -31,8 +32,9 @@ export default { }; }, computed: { + ...mapGetters(['isGroupBoard']), disabled() { - if (this.groupId) { + if (this.isGroupBoard) { return this.title === '' || !this.selectedProject.name; } return this.title === ''; @@ -110,7 +112,7 @@ export default { name="issue_title" autocomplete="off" /> - <project-select v-if="groupId" :group-id="groupId" :list="list" /> + <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" /> <div class="clearfix gl-mt-3"> <gl-button ref="submitButton" diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 6d5a13be3ac..55bc91cbcff 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -20,7 +20,6 @@ import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue' import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; import eventHub from '~/sidebar/event_hub'; import boardsStore from '../stores/boards_store'; -import RemoveBtn from './sidebar/remove_issue.vue'; export default Vue.extend({ components: { @@ -29,7 +28,6 @@ export default Vue.extend({ GlLabel, SidebarEpicsSelect: () => import('ee_component/sidebar/components/sidebar_item_epics_select.vue'), - RemoveBtn, Subscriptions, TimeTracker, SidebarAssigneesWidget, @@ -107,8 +105,8 @@ export default Vue.extend({ closeSidebar() { this.detail.issue = {}; }, - setAssignees(data) { - boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes); + setAssignees(assignees) { + boardsStore.detail.issue.setAssignees(assignees); }, showScopedLabels(label) { return boardsStore.scopedLabels.enabled && isScopedLabel(label); diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 2a064aaa885..5124467136e 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -9,6 +9,9 @@ import { GlModalDirective, } from '@gitlab/ui'; import { throttle } from 'lodash'; +import { mapGetters, mapState } from 'vuex'; + +import BoardForm from 'ee_else_ce/boards/components/board_form.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import axios from '~/lib/utils/axios_utils'; @@ -18,8 +21,6 @@ import eventHub from '../eventhub'; import groupQuery from '../graphql/group_boards.query.graphql'; import projectQuery from '../graphql/project_boards.query.graphql'; -import BoardForm from './board_form.vue'; - const MIN_BOARDS_TO_VIEW_RECENT = 10; export default { @@ -109,8 +110,10 @@ export default { }; }, computed: { + ...mapState(['boardType']), + ...mapGetters(['isGroupBoard']), parentType() { - return this.groupId ? 'group' : 'project'; + return this.boardType; }, loading() { return this.loadingRecentBoards || Boolean(this.loadingBoards); @@ -123,6 +126,9 @@ export default { board() { return this.currentBoard; }, + showCreate() { + return this.multipleIssueBoardsAvailable; + }, showDelete() { return this.boards.length > 1; }, @@ -158,6 +164,18 @@ export default { cancel() { this.showPage(''); }, + boardUpdate(data) { + if (!data?.[this.parentType]) { + return []; + } + return data[this.parentType].boards.edges.map(({ node }) => ({ + id: getIdFromGraphQLId(node.id), + name: node.name, + })); + }, + boardQuery() { + return this.isGroupBoard ? groupQuery : projectQuery; + }, loadBoards(toggleDropdown = true) { if (toggleDropdown && this.boards.length > 0) { return; @@ -167,21 +185,14 @@ export default { variables() { return { fullPath: this.fullPath }; }, - query() { - return this.groupId ? groupQuery : projectQuery; - }, + query: this.boardQuery, loadingKey: 'loadingBoards', - update(data) { - if (!data?.[this.parentType]) { - return []; - } - return data[this.parentType].boards.edges.map(({ node }) => ({ - id: getIdFromGraphQLId(node.id), - name: node.name, - })); - }, + update: this.boardUpdate, }); + this.loadRecentBoards(); + }, + loadRecentBoards() { this.loadingRecentBoards = true; // Follow up to fetch recent boards using GraphQL // https://gitlab.com/gitlab-org/gitlab/-/issues/300985 @@ -322,7 +333,7 @@ export default { <gl-dropdown-divider /> <gl-dropdown-item - v-if="multipleIssueBoardsAvailable" + v-if="showCreate" v-gl-modal-directive="'board-config-modal'" data-qa-selector="create_new_board_button" @click.prevent="showPage('new')" diff --git a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue index 33ad46a0d29..85c7b27336b 100644 --- a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue +++ b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue @@ -9,6 +9,7 @@ import { GlModalDirective, } from '@gitlab/ui'; import { throttle } from 'lodash'; +import { mapGetters, mapState } from 'vuex'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -108,8 +109,10 @@ export default { }; }, computed: { + ...mapState(['boardType']), + ...mapGetters(['isGroupBoard']), parentType() { - return this.groupId ? 'group' : 'project'; + return this.boardType; }, loading() { return this.loadingRecentBoards || Boolean(this.loadingBoards); @@ -167,7 +170,7 @@ export default { return { fullPath: this.state.endpoints.fullPath }; }, query() { - return this.groupId ? groupQuery : projectQuery; + return this.isGroupBoard ? groupQuery : projectQuery; }, loadingKey: 'loadingBoards', update(data) { diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue new file mode 100644 index 00000000000..7ec99e51f5b --- /dev/null +++ b/app/assets/javascripts/boards/components/config_toggle.vue @@ -0,0 +1,64 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { formType } from '~/boards/constants'; +import eventHub from '~/boards/eventhub'; +import { s__, __ } from '~/locale'; + +export default { + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModalDirective, + }, + props: { + boardsStore: { + type: Object, + required: true, + }, + canAdminList: { + type: Boolean, + required: true, + }, + hasScope: { + type: Boolean, + required: true, + }, + }, + data() { + return { + state: this.boardsStore.state, + }; + }, + computed: { + buttonText() { + return this.canAdminList ? s__('Boards|Edit board') : s__('Boards|View scope'); + }, + tooltipTitle() { + return this.hasScope ? __("This board's scope is reduced") : ''; + }, + }, + methods: { + showPage() { + eventHub.$emit('showBoardModal', formType.edit); + return this.boardsStore.showPage(formType.edit); + }, + }, +}; +</script> + +<template> + <div class="gl-ml-3 gl-display-flex gl-align-items-center"> + <gl-button + v-gl-modal-directive="'board-config-modal'" + v-gl-tooltip + :title="tooltipTitle" + :class="{ 'dot-highlight': hasScope }" + data-qa-selector="boards_config_button" + @click.prevent="showPage" + > + {{ buttonText }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/filtered_search.vue b/app/assets/javascripts/boards/components/filtered_search.vue new file mode 100644 index 00000000000..8505ea39a6b --- /dev/null +++ b/app/assets/javascripts/boards/components/filtered_search.vue @@ -0,0 +1,54 @@ +<script> +import { mapActions } from 'vuex'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +export default { + i18n: { + search: __('Search'), + }, + components: { FilteredSearch }, + props: { + search: { + type: String, + required: false, + default: '', + }, + }, + computed: { + initialSearch() { + return [{ type: 'filtered-search-term', value: { data: this.search } }]; + }, + }, + methods: { + ...mapActions(['performSearch']), + handleSearch(filters) { + let itemValue = ''; + const [item] = filters; + + if (filters.length === 0) { + itemValue = ''; + } else { + itemValue = item?.value?.data; + } + + historyPushState(setUrlParams({ search: itemValue }, window.location.href)); + + this.performSearch(); + }, + }, +}; +</script> + +<template> + <filtered-search + class="gl-w-full" + namespace="" + :tokens="[]" + :search-input-placeholder="$options.i18n.search" + :initial-filter-value="initialSearch" + @onFilter="handleSearch" + /> +</template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue index 069cc2cda22..2652fac1818 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue @@ -2,7 +2,7 @@ import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { sortBy } from 'lodash'; import { mapState } from 'vuex'; -import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; +import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; @@ -24,7 +24,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [issueCardInner], + mixins: [boardCardInner], inject: ['groupId', 'rootPath'], props: { issue: { @@ -207,7 +207,7 @@ export default { /> <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" /> <issue-card-weight - v-if="validIssueWeight" + v-if="validIssueWeight(issue)" :weight="issue.weight" @click="filterByWeight(issue.weight)" /> diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 7e3f36c8a17..73ec008c2b6 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -86,7 +86,11 @@ export default { <template> <span> <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number"> - <gl-icon :class="{ 'text-danger': isPastDue }" class="board-card-info-icon" name="calendar" /> + <gl-icon + :class="{ 'text-danger': isPastDue }" + class="board-card-info-icon gl-mr-2" + name="calendar" + /> <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ body }}</time> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index 42d187b9b40..1ab7deebfaf 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -37,7 +37,7 @@ export default { <template> <span> <span ref="issueTimeEstimate" class="board-card-info card-number"> - <gl-icon name="hourglass" class="board-card-info-icon" /> + <gl-icon name="hourglass" class="board-card-info-icon gl-mr-2" /> <time class="board-card-info-text">{{ timeEstimate }}</time> </span> <gl-tooltip diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue index bf69f8140d5..e66cae0ce18 100644 --- a/app/assets/javascripts/boards/components/modal/list.vue +++ b/app/assets/javascripts/boards/components/modal/list.vue @@ -2,11 +2,11 @@ import { GlIcon } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import ModalStore from '../../stores/modal_store'; -import IssueCardInner from '../issue_card_inner.vue'; +import BoardCardInner from '../board_card_inner.vue'; export default { components: { - IssueCardInner, + BoardCardInner, GlIcon, }, props: { @@ -126,7 +126,7 @@ export default { class="board-card position-relative p-3 rounded" @click="toggleIssue($event, issue)" > - <issue-card-inner :issue="issue" /> + <board-card-inner :item="issue" /> <gl-icon v-if="issue.selected" :aria-label="'Issue #' + issue.id + ' selected'" diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue deleted file mode 100644 index 8d65f3240c8..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue +++ /dev/null @@ -1,88 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import axios from '~/lib/utils/axios_utils'; -import { deprecatedCreateFlash as Flash } from '../../../flash'; -import { __ } from '../../../locale'; -import boardsStore from '../../stores/boards_store'; - -export default { - components: { - GlButton, - }, - props: { - issue: { - type: Object, - required: true, - }, - list: { - type: Object, - required: true, - }, - }, - computed: { - updateUrl() { - return this.issue.path; - }, - }, - methods: { - removeIssue() { - const { issue } = this; - const lists = issue.getLists(); - const req = this.buildPatchRequest(issue, lists); - - const data = { - issue: this.seedPatchRequest(issue, req), - }; - - if (data.issue.label_ids.length === 0) { - data.issue.label_ids = ['']; - } - - // Post the remove data - axios.patch(this.updateUrl, data).catch(() => { - Flash(__('Failed to remove issue from board, please try again.')); - - lists.forEach((list) => { - list.addIssue(issue); - }); - }); - - // Remove from the frontend store - lists.forEach((list) => { - list.removeIssue(issue); - }); - - boardsStore.clearDetailIssue(); - }, - /** - * Build the default patch request. - */ - buildPatchRequest(issue, lists) { - const listLabelIds = lists.map((list) => list.label.id); - - const labelIds = issue.labels - .map((label) => label.id) - .filter((id) => !listLabelIds.includes(id)); - - return { - label_ids: labelIds, - }; - }, - /** - * Seed the given patch request. - * - * (This is overridden in EE) - */ - seedPatchRequest(issue, req) { - return req; - }, - }, -}; -</script> -<template> - <div class="block list"> - <gl-button variant="default" category="secondary" block="block" @click="removeIssue"> - {{ __('Remove from board') }} - </gl-button> - </div> -</template> diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js index 2d1ec238274..7f327c5764d 100644 --- a/app/assets/javascripts/boards/config_toggle.js +++ b/app/assets/javascripts/boards/config_toggle.js @@ -1 +1,24 @@ -export default () => {}; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ConfigToggle from './components/config_toggle.vue'; + +export default (boardsStore) => { + const el = document.querySelector('.js-board-config'); + + if (!el) { + return; + } + + gl.boardConfigToggle = new Vue({ + el, + render(h) { + return h(ConfigToggle, { + props: { + boardsStore, + canAdminList: parseBoolean(el.dataset.canAdminList), + hasScope: parseBoolean(el.dataset.hasScope), + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index 3ab89b2c9da..65ebfe7be6c 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -1,5 +1,10 @@ import { __ } from '~/locale'; +export const issuableTypes = { + issue: 'issue', + epic: 'epic', +}; + export const BoardType = { project: 'project', group: 'group', diff --git a/app/assets/javascripts/boards/filtered_search.js b/app/assets/javascripts/boards/filtered_search.js new file mode 100644 index 00000000000..182a2cf3724 --- /dev/null +++ b/app/assets/javascripts/boards/filtered_search.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import store from '~/boards/stores'; +import { queryToObject } from '~/lib/utils/url_utility'; +import FilteredSearch from './components/filtered_search.vue'; + +export default () => { + const queryParams = queryToObject(window.location.search); + const el = document.getElementById('js-board-filtered-search'); + + /* + When https://github.com/vuejs/vue-apollo/pull/1153 is merged and deployed + we can remove apolloProvider option from here. Currently without it its causing + an error + */ + + return new Vue({ + el, + store, + apolloProvider: {}, + render: (createElement) => + createElement(FilteredSearch, { + props: { search: queryParams.search }, + }), + }); +}; diff --git a/app/assets/javascripts/boards/graphql/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql index 42a94419a97..b19a24e8808 100644 --- a/app/assets/javascripts/boards/graphql/board_labels.query.graphql +++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql @@ -7,14 +7,14 @@ query BoardLabels( $isProject: Boolean = false ) { group(fullPath: $fullPath) @include(if: $isGroup) { - labels(searchTerm: $searchTerm) { + labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) { nodes { ...Label } } } project(fullPath: $fullPath) @include(if: $isProject) { - labels(searchTerm: $searchTerm) { + labels(searchTerm: $searchTerm, includeAncestorGroups: true) { nodes { ...Label } diff --git a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql index f78a21baa7f..3eb23f62940 100644 --- a/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql +++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql @@ -1,21 +1,7 @@ -#import "ee_else_ce/boards/graphql/board_list.fragment.graphql" +#import "./board_list.fragment.graphql" -mutation CreateBoardList( - $boardId: BoardID! - $backlog: Boolean - $labelId: LabelID - $milestoneId: MilestoneID - $assigneeId: UserID -) { - boardListCreate( - input: { - boardId: $boardId - backlog: $backlog - labelId: $labelId - milestoneId: $milestoneId - assigneeId: $assigneeId - } - ) { +mutation CreateBoardList($boardId: BoardID!, $backlog: Boolean, $labelId: LabelID) { + boardListCreate(input: { boardId: $boardId, backlog: $backlog, labelId: $labelId }) { list { ...BoardListFragment } diff --git a/app/assets/javascripts/boards/graphql/users_search.query.graphql b/app/assets/javascripts/boards/graphql/users_search.query.graphql deleted file mode 100644 index ca016495d79..00000000000 --- a/app/assets/javascripts/boards/graphql/users_search.query.graphql +++ /dev/null @@ -1,11 +0,0 @@ -query usersSearch($search: String!) { - users(search: $search) { - nodes { - username - name - webUrl - avatarUrl - id - } - } -} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 859295318ed..0627dbc73d2 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -6,7 +6,6 @@ import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; -import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; import { setWeightFetchingState, setEpicFetchingState, @@ -24,6 +23,7 @@ import '~/boards/models/milestone'; import '~/boards/models/project'; import '~/boards/filters/due_date_filters'; import BoardAddIssuesModal from '~/boards/components/modal/index.vue'; +import { issuableTypes } from '~/boards/constants'; import eventHub from '~/boards/eventhub'; import FilteredSearchBoards from '~/boards/filtered_search_boards'; import modalMixin from '~/boards/mixins/modal_mixins'; @@ -40,6 +40,7 @@ import { } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import sidebarEventHub from '~/sidebar/event_hub'; +import boardConfigToggle from './config_toggle'; import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; Vue.use(VueApollo); @@ -52,7 +53,6 @@ let issueBoardsApp; export default () => { const $boardApp = document.getElementById('board-app'); - // check for browser back and trigger a hard reload to circumvent browser caching. window.addEventListener('pageshow', (event) => { const isNavTypeBackForward = @@ -72,6 +72,14 @@ export default () => { boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours); } + if (gon?.features?.boardsFilteredSearch) { + import('~/boards/filtered_search') + .then(({ default: initFilteredSearch }) => { + initFilteredSearch(apolloProvider); + }) + .catch(() => {}); + } + // eslint-disable-next-line @gitlab/no-runtime-template-compiler issueBoardsApp = new Vue({ el: $boardApp, @@ -124,6 +132,7 @@ export default () => { fullPath: $boardApp.dataset.fullPath, boardType: this.parent, disabled: this.disabled, + issuableType: issuableTypes.issue, boardConfig: { milestoneId: parseInt($boardApp.dataset.boardMilestoneId, 10), milestoneTitle: $boardApp.dataset.boardMilestoneTitle || '', @@ -162,8 +171,15 @@ export default () => { eventHub.$off('initialBoardLoad', this.initialBoardLoad); }, mounted() { - this.filterManager = new FilteredSearchBoards(boardsStore.filter, true, boardsStore.cantEdit); - this.filterManager.setup(); + if (!gon.features?.boardsFilteredSearch) { + this.filterManager = new FilteredSearchBoards( + boardsStore.filter, + true, + boardsStore.cantEdit, + ); + + this.filterManager.setup(); + } this.performSearch(); @@ -349,7 +365,7 @@ export default () => { toggleFocusMode(ModalStore, boardsStore); toggleLabels(); - if (gon.features?.swimlanes) { + if (gon.licensed_features?.swimlanes) { toggleEpicsSwimlanes(); } diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/board_card_inner.js index 04e971b756d..a6f278f3bc9 100644 --- a/app/assets/javascripts/boards/mixins/issue_card_inner.js +++ b/app/assets/javascripts/boards/mixins/board_card_inner.js @@ -1,10 +1,8 @@ export default { - computed: { + methods: { validIssueWeight() { return false; }, - }, - methods: { filterByWeight() {}, }, }; diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index fa58af24ba2..7f655091cd0 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { mapGetters } from 'vuex'; -import BoardsSelector from '~/boards/components/boards_selector.vue'; +import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue'; import BoardsSelectorDeprecated from '~/boards/components/boards_selector_deprecated.vue'; import store from '~/boards/stores'; import createDefaultClient from '~/lib/graphql'; @@ -48,10 +48,10 @@ export default (params = {}) => { return { boardsSelectorProps }; }, computed: { - ...mapGetters(['shouldUseGraphQL']), + ...mapGetters(['shouldUseGraphQL', 'isEpicBoard']), }, render(createElement) { - if (this.shouldUseGraphQL) { + if (this.shouldUseGraphQL || this.isEpicBoard) { return createElement(BoardsSelector, { props: this.boardsSelectorProps, }); diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index a7cf1e9e647..03e597b4e7b 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,6 +1,13 @@ import { pick } from 'lodash'; +import createBoardListMutation from 'ee_else_ce/boards/graphql/board_list_create.mutation.graphql'; import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql'; -import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants'; +import { + BoardType, + ListType, + inactiveId, + flashAnimationDuration, + ISSUABLE, +} from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils'; @@ -15,7 +22,6 @@ import { transformNotFilters, } from '../boards_util'; import boardLabelsQuery from '../graphql/board_labels.query.graphql'; -import createBoardListMutation from '../graphql/board_list_create.mutation.graphql'; import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql'; import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql'; import groupProjectsQuery from '../graphql/group_projects.query.graphql'; @@ -79,7 +85,11 @@ export default { } }, - fetchLists: ({ commit, state, dispatch }) => { + fetchLists: ({ dispatch }) => { + dispatch('fetchIssueLists'); + }, + + fetchIssueLists: ({ commit, state, dispatch }) => { const { boardType, filterParams, fullPath, boardId } = state; const variables = { @@ -118,7 +128,11 @@ export default { }, flashAnimationDuration); }, - createList: ( + createList: ({ dispatch }, { backlog, labelId, milestoneId, assigneeId }) => { + dispatch('createIssueList', { backlog, labelId, milestoneId, assigneeId }); + }, + + createIssueList: ( { state, commit, dispatch, getters }, { backlog, labelId, milestoneId, assigneeId }, ) => { @@ -143,22 +157,25 @@ export default { }, }) .then(({ data }) => { - if (data?.boardListCreate?.errors.length) { - commit(types.CREATE_LIST_FAILURE); + if (data.boardListCreate?.errors.length) { + commit(types.CREATE_LIST_FAILURE, data.boardListCreate.errors[0]); } else { const list = data.boardListCreate?.list; dispatch('addList', list); dispatch('highlightList', list.id); } }) - .catch(() => commit(types.CREATE_LIST_FAILURE)); + .catch((e) => { + commit(types.CREATE_LIST_FAILURE); + throw e; + }); }, addList: ({ commit }, list) => { commit(types.RECEIVE_ADD_LIST_SUCCESS, updateListPosition(list)); }, - fetchLabels: ({ state, commit }, searchTerm) => { + fetchLabels: ({ state, commit, getters }, searchTerm) => { const { fullPath, boardType } = state; const variables = { @@ -168,15 +185,29 @@ export default { isProject: boardType === BoardType.project, }; + commit(types.RECEIVE_LABELS_REQUEST); + return gqlClient .query({ query: boardLabelsQuery, variables, }) .then(({ data }) => { - const labels = data[boardType]?.labels.nodes; + let labels = data[boardType]?.labels.nodes; + + if (!getters.shouldUseGraphQL && !getters.isEpicBoard) { + labels = labels.map((label) => ({ + ...label, + id: getIdFromGraphQLId(label.id), + })); + } + commit(types.RECEIVE_LABELS_SUCCESS, labels); return labels; + }) + .catch((e) => { + commit(types.RECEIVE_LABELS_FAILURE); + throw e; }); }, @@ -225,6 +256,10 @@ export default { }); }, + toggleListCollapsed: ({ commit }, { listId, collapsed }) => { + commit(types.TOGGLE_LIST_COLLAPSED, { listId, collapsed }); + }, + removeList: ({ state, commit }, listId) => { const listsBackup = { ...state.boardLists }; @@ -253,8 +288,8 @@ export default { }); }, - fetchIssuesForList: ({ state, commit }, { listId, fetchNext = false }) => { - commit(types.REQUEST_ISSUES_FOR_LIST, { listId, fetchNext }); + fetchItemsForList: ({ state, commit }, { listId, fetchNext = false }) => { + commit(types.REQUEST_ITEMS_FOR_LIST, { listId, fetchNext }); const { fullPath, boardId, boardType, filterParams } = state; @@ -279,28 +314,32 @@ export default { }) .then(({ data }) => { const { lists } = data[boardType]?.board; - const listIssues = formatListIssues(lists); + const listItems = formatListIssues(lists); const listPageInfo = formatListsPageInfo(lists); - commit(types.RECEIVE_ISSUES_FOR_LIST_SUCCESS, { listIssues, listPageInfo, listId }); + commit(types.RECEIVE_ITEMS_FOR_LIST_SUCCESS, { listItems, listPageInfo, listId }); }) - .catch(() => commit(types.RECEIVE_ISSUES_FOR_LIST_FAILURE, listId)); + .catch(() => commit(types.RECEIVE_ITEMS_FOR_LIST_FAILURE, listId)); }, resetIssues: ({ commit }) => { commit(types.RESET_ISSUES); }, + moveItem: ({ dispatch }) => { + dispatch('moveIssue'); + }, + moveIssue: ( { state, commit }, - { issueId, issueIid, issuePath, fromListId, toListId, moveBeforeId, moveAfterId }, + { itemId, itemIid, itemPath, fromListId, toListId, moveBeforeId, moveAfterId }, ) => { - const originalIssue = state.issues[issueId]; - const fromList = state.issuesByListId[fromListId]; - const originalIndex = fromList.indexOf(Number(issueId)); + const originalIssue = state.boardItems[itemId]; + const fromList = state.boardItemsByListId[fromListId]; + const originalIndex = fromList.indexOf(Number(itemId)); commit(types.MOVE_ISSUE, { originalIssue, fromListId, toListId, moveBeforeId, moveAfterId }); const { boardId } = state; - const [fullProjectPath] = issuePath.split(/[#]/); + const [fullProjectPath] = itemPath.split(/[#]/); gqlClient .mutate({ @@ -308,7 +347,7 @@ export default { variables: { projectPath: fullProjectPath, boardId: fullBoardId(boardId), - iid: issueIid, + iid: itemIid, fromListId: getIdFromGraphQLId(fromListId), toListId: getIdFromGraphQLId(toListId), moveBeforeId, @@ -317,7 +356,7 @@ export default { }) .then(({ data }) => { if (data?.issueMoveList?.errors.length) { - commit(types.MOVE_ISSUE_FAILURE, { originalIssue, fromListId, toListId, originalIndex }); + throw new Error(); } else { const issue = data.issueMoveList?.issue; commit(types.MOVE_ISSUE_SUCCESS, { issue }); @@ -532,10 +571,17 @@ export default { commit(types.SET_SELECTED_PROJECT, project); }, - toggleBoardItemMultiSelection: ({ commit, state }, boardItem) => { + toggleBoardItemMultiSelection: ({ commit, state, dispatch, getters }, boardItem) => { const { selectedBoardItems } = state; const index = selectedBoardItems.indexOf(boardItem); + // If user already selected an item (activeIssue) without using mult-select, + // include that item in the selection and unset state.ActiveId to hide the sidebar. + if (getters.activeIssue) { + commit(types.ADD_BOARD_ITEM_TO_SELECTION, getters.activeIssue); + dispatch('unsetActiveId'); + } + if (index === -1) { commit(types.ADD_BOARD_ITEM_TO_SELECTION, boardItem); } else { @@ -547,6 +593,20 @@ export default { commit(types.SET_ADD_COLUMN_FORM_VISIBLE, visible); }, + resetBoardItemMultiSelection: ({ commit }) => { + commit(types.RESET_BOARD_ITEM_SELECTION); + }, + + toggleBoardItem: ({ state, dispatch }, { boardItem, sidebarType = ISSUABLE }) => { + dispatch('resetBoardItemMultiSelection'); + + if (boardItem.id === state.activeId) { + dispatch('unsetActiveId'); + } else { + dispatch('setActiveId', { id: boardItem.id, sidebarType }); + } + }, + fetchBacklog: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js index cab97088bc6..c96f92106cc 100644 --- a/app/assets/javascripts/boards/stores/getters.js +++ b/app/assets/javascripts/boards/stores/getters.js @@ -1,20 +1,22 @@ import { find } from 'lodash'; -import { inactiveId } from '../constants'; +import { BoardType, inactiveId } from '../constants'; export default { + isGroupBoard: (state) => state.boardType === BoardType.group, + isProjectBoard: (state) => state.boardType === BoardType.project, isSidebarOpen: (state) => state.activeId !== inactiveId, isSwimlanesOn: () => false, - getIssueById: (state) => (id) => { - return state.issues[id] || {}; + getBoardItemById: (state) => (id) => { + return state.boardItems[id] || {}; }, - getIssuesByList: (state, getters) => (listId) => { - const listIssueIds = state.issuesByListId[listId] || []; - return listIssueIds.map((id) => getters.getIssueById(id)); + getBoardItemsByList: (state, getters) => (listId) => { + const listItemsIds = state.boardItemsByListId[listId] || []; + return listItemsIds.map((id) => getters.getBoardItemById(id)); }, activeIssue: (state) => { - return state.issues[state.activeId] || {}; + return state.boardItems[state.activeId] || {}; }, groupPathForActiveIssue: (_, getters) => { @@ -38,6 +40,10 @@ export default { return find(state.boardLists, (l) => l.title === title); }, + isEpicBoard: () => { + return false; + }, + shouldUseGraphQL: () => { return gon?.features?.graphqlBoardLists; }, diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index a89e961ae2d..e7c034fb087 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -2,7 +2,9 @@ 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_REQUEST = 'RECEIVE_LABELS_REQUEST'; export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS'; +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'; @@ -12,11 +14,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 TOGGLE_LIST_COLLAPSED = 'TOGGLE_LIST_COLLAPSED'; 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 REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST'; +export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE'; +export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS'; export const CREATE_ISSUE_FAILURE = 'CREATE_ISSUE_FAILURE'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; @@ -45,3 +48,4 @@ export const REMOVE_BOARD_ITEM_FROM_SELECTION = 'REMOVE_BOARD_ITEM_FROM_SELECTIO export const SET_ADD_COLUMN_FORM_VISIBLE = 'SET_ADD_COLUMN_FORM_VISIBLE'; export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS'; export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS'; +export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index 79c98c3d90c..75b60366b6a 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -2,7 +2,8 @@ import { pull, union } from 'lodash'; import Vue from 'vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__ } from '~/locale'; -import { formatIssue, moveIssueListHelper } from '../boards_util'; +import { formatIssue, moveItemListHelper } from '../boards_util'; +import { issuableTypes } from '../constants'; import * as mutationTypes from './mutation_types'; const notImplemented = () => { @@ -10,34 +11,42 @@ const notImplemented = () => { throw new Error('Not implemented!'); }; -export const removeIssueFromList = ({ state, listId, issueId }) => { - Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId)); +const updateListItemsCount = ({ state, listId, value }) => { const list = state.boardLists[listId]; - Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount - 1 }); + if (state.issuableType === issuableTypes.epic) { + Vue.set(state.boardLists, listId, { ...list, epicsCount: list.epicsCount + value }); + } else { + Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + value }); + } +}; + +export const removeItemFromList = ({ state, listId, itemId }) => { + Vue.set(state.boardItemsByListId, listId, pull(state.boardItemsByListId[listId], itemId)); + updateListItemsCount({ state, listId, value: -1 }); }; -export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => { - const listIssues = state.issuesByListId[listId]; +export const addItemToList = ({ state, listId, itemId, moveBeforeId, moveAfterId, atIndex }) => { + const listIssues = state.boardItemsByListId[listId]; let newIndex = atIndex || 0; if (moveBeforeId) { newIndex = listIssues.indexOf(moveBeforeId) + 1; } else if (moveAfterId) { newIndex = listIssues.indexOf(moveAfterId); } - listIssues.splice(newIndex, 0, issueId); - Vue.set(state.issuesByListId, listId, listIssues); - const list = state.boardLists[listId]; - Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 }); + listIssues.splice(newIndex, 0, itemId); + Vue.set(state.boardItemsByListId, listId, listIssues); + updateListItemsCount({ state, listId, value: 1 }); }; export default { [mutationTypes.SET_INITIAL_BOARD_DATA](state, data) { - const { boardType, disabled, boardId, fullPath, boardConfig } = data; + const { boardType, disabled, boardId, fullPath, boardConfig, issuableType } = data; state.boardId = boardId; state.fullPath = fullPath; state.boardType = boardType; state.disabled = disabled; state.boardConfig = boardConfig; + state.issuableType = issuableType; }, [mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => { @@ -59,12 +68,25 @@ export default { state.filterParams = filterParams; }, - [mutationTypes.CREATE_LIST_FAILURE]: (state) => { - state.error = s__('Boards|An error occurred while creating the list. Please try again.'); + [mutationTypes.CREATE_LIST_FAILURE]: ( + state, + error = s__('Boards|An error occurred while creating the list. Please try again.'), + ) => { + state.error = error; + }, + + [mutationTypes.RECEIVE_LABELS_REQUEST]: (state) => { + state.labelsLoading = true; }, [mutationTypes.RECEIVE_LABELS_SUCCESS]: (state, labels) => { state.labels = labels; + state.labelsLoading = false; + }, + + [mutationTypes.RECEIVE_LABELS_FAILURE]: (state) => { + state.error = s__('Boards|An error occurred while fetching labels. Please reload the page.'); + state.labelsLoading = false; }, [mutationTypes.GENERATE_DEFAULT_LISTS_FAILURE]: (state) => { @@ -94,6 +116,10 @@ export default { Vue.set(state, 'boardLists', backupList); }, + [mutationTypes.TOGGLE_LIST_COLLAPSED]: (state, { listId, collapsed }) => { + Vue.set(state.boardLists[listId], 'collapsed', collapsed); + }, + [mutationTypes.REMOVE_LIST]: (state, listId) => { Vue.delete(state.boardLists, listId); }, @@ -103,26 +129,23 @@ export default { state.boardLists = listsBackup; }, - [mutationTypes.REQUEST_ISSUES_FOR_LIST]: (state, { listId, fetchNext }) => { + [mutationTypes.REQUEST_ITEMS_FOR_LIST]: (state, { listId, fetchNext }) => { Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true }); }, - [mutationTypes.RECEIVE_ISSUES_FOR_LIST_SUCCESS]: ( - state, - { listIssues, listPageInfo, listId }, - ) => { - const { listData, issues } = listIssues; - Vue.set(state, 'issues', { ...state.issues, ...issues }); + [mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => { + const { listData, boardItems } = listItems; + Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems }); Vue.set( - state.issuesByListId, + state.boardItemsByListId, listId, - union(state.issuesByListId[listId] || [], listData[listId]), + union(state.boardItemsByListId[listId] || [], listData[listId]), ); Vue.set(state.pageInfoByListId, listId, listPageInfo[listId]); Vue.set(state.listsFlags, listId, { isLoading: false, isLoadingMore: false }); }, - [mutationTypes.RECEIVE_ISSUES_FOR_LIST_FAILURE]: (state, listId) => { + [mutationTypes.RECEIVE_ITEMS_FOR_LIST_FAILURE]: (state, listId) => { state.error = s__( 'Boards|An error occurred while fetching the board issues. Please reload the page.', ); @@ -130,18 +153,18 @@ export default { }, [mutationTypes.RESET_ISSUES]: (state) => { - Object.keys(state.issuesByListId).forEach((listId) => { - Vue.set(state.issuesByListId, listId, []); + Object.keys(state.boardItemsByListId).forEach((listId) => { + Vue.set(state.boardItemsByListId, listId, []); }); }, [mutationTypes.UPDATE_ISSUE_BY_ID]: (state, { issueId, prop, value }) => { - if (!state.issues[issueId]) { + if (!state.boardItems[issueId]) { /* eslint-disable-next-line @gitlab/require-i18n-strings */ throw new Error('No issue found.'); } - Vue.set(state.issues[issueId], prop, value); + Vue.set(state.boardItems[issueId], prop, value); }, [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) { @@ -167,16 +190,16 @@ export default { const fromList = state.boardLists[fromListId]; const toList = state.boardLists[toListId]; - const issue = moveIssueListHelper(originalIssue, fromList, toList); - Vue.set(state.issues, issue.id, issue); + const issue = moveItemListHelper(originalIssue, fromList, toList); + Vue.set(state.boardItems, issue.id, issue); - removeIssueFromList({ state, listId: fromListId, issueId: issue.id }); - addIssueToList({ state, listId: toListId, issueId: issue.id, moveBeforeId, moveAfterId }); + removeItemFromList({ state, listId: fromListId, itemId: issue.id }); + addItemToList({ state, listId: toListId, itemId: issue.id, moveBeforeId, moveAfterId }); }, [mutationTypes.MOVE_ISSUE_SUCCESS]: (state, { issue }) => { const issueId = getIdFromGraphQLId(issue.id); - Vue.set(state.issues, issueId, formatIssue({ ...issue, id: issueId })); + Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId })); }, [mutationTypes.MOVE_ISSUE_FAILURE]: ( @@ -184,12 +207,12 @@ export default { { originalIssue, fromListId, toListId, originalIndex }, ) => { state.error = s__('Boards|An error occurred while moving the issue. Please try again.'); - Vue.set(state.issues, originalIssue.id, originalIssue); - removeIssueFromList({ state, listId: toListId, issueId: originalIssue.id }); - addIssueToList({ + Vue.set(state.boardItems, originalIssue.id, originalIssue); + removeItemFromList({ state, listId: toListId, itemId: originalIssue.id }); + addItemToList({ state, listId: fromListId, - issueId: originalIssue.id, + itemId: originalIssue.id, atIndex: originalIndex, }); }, @@ -211,23 +234,23 @@ export default { }, [mutationTypes.ADD_ISSUE_TO_LIST]: (state, { list, issue, position }) => { - addIssueToList({ + addItemToList({ state, listId: list.id, - issueId: issue.id, + itemId: issue.id, atIndex: position, }); - Vue.set(state.issues, issue.id, issue); + Vue.set(state.boardItems, issue.id, 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 }); + removeItemFromList({ state, listId: list.id, itemId: issueId }); }, [mutationTypes.REMOVE_ISSUE_FROM_LIST]: (state, { list, issue }) => { - removeIssueFromList({ state, listId: list.id, issueId: issue.id }); - Vue.delete(state.issues, issue.id); + removeItemFromList({ state, listId: list.id, itemId: issue.id }); + Vue.delete(state.boardItems, issue.id); }, [mutationTypes.SET_CURRENT_PAGE]: () => { @@ -272,7 +295,7 @@ export default { }, [mutationTypes.SET_ADD_COLUMN_FORM_VISIBLE]: (state, visible) => { - state.addColumnFormVisible = visible; + Vue.set(state.addColumnForm, 'visible', visible); }, [mutationTypes.ADD_LIST_TO_HIGHLIGHTED_LISTS]: (state, listId) => { @@ -282,4 +305,8 @@ export default { [mutationTypes.REMOVE_LIST_FROM_HIGHLIGHTED_LISTS]: (state, listId) => { state.highlightedLists = state.highlightedLists.filter((id) => id !== listId); }, + + [mutationTypes.RESET_BOARD_ITEM_SELECTION]: (state) => { + state.selectedBoardItems = []; + }, }; diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index 91544d6c9c5..19ba2a5df83 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,19 +1,22 @@ -import { inactiveId } from '~/boards/constants'; +import { inactiveId, ListType } from '~/boards/constants'; export default () => ({ boardType: null, + issuableType: null, + fullPath: null, disabled: false, isShowingLabels: true, activeId: inactiveId, sidebarType: '', boardLists: {}, listsFlags: {}, - issuesByListId: {}, + boardItemsByListId: {}, isSettingAssignees: false, pageInfoByListId: {}, - issues: {}, + boardItems: {}, filterParams: {}, boardConfig: {}, + labelsLoading: false, labels: [], highlightedLists: [], selectedBoardItems: [], @@ -25,7 +28,10 @@ export default () => ({ }, selectedProject: {}, error: undefined, - addColumnFormVisible: false, + addColumnForm: { + visible: false, + columnType: ListType.label, + }, // TODO: remove after ce/ee split of board_content.vue isShowingEpicsSwimlanes: false, }); diff --git a/app/assets/javascripts/captcha/captcha_modal.vue b/app/assets/javascripts/captcha/captcha_modal.vue index e6c73bc9643..a98a52a3130 100644 --- a/app/assets/javascripts/captcha/captcha_modal.vue +++ b/app/assets/javascripts/captcha/captcha_modal.vue @@ -41,10 +41,17 @@ export default { } }, }, + mounted() { + // If this is true, we need to present the captcha modal to the user. + // When the modal is shown we will also initialize and render the form. + if (this.needsCaptchaResponse) { + this.$refs.modal.show(); + } + }, methods: { emitReceivedCaptchaResponse(captchaResponse) { - this.$emit('receivedCaptchaResponse', captchaResponse); this.$refs.modal.hide(); + this.$emit('receivedCaptchaResponse', captchaResponse); }, emitNullReceivedCaptchaResponse() { this.emitReceivedCaptchaResponse(null); @@ -103,6 +110,7 @@ export default { :action-cancel="{ text: __('Cancel') }" @shown="shown" @hide="hide" + @hidden="$emit('hidden')" > <div ref="captcha"></div> <p>{{ __('We want to be sure it is you, please confirm you are not a robot.') }}</p> diff --git a/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js new file mode 100644 index 00000000000..c9eac44eb28 --- /dev/null +++ b/app/assets/javascripts/captcha/captcha_modal_axios_interceptor.js @@ -0,0 +1,37 @@ +const supportedMethods = ['patch', 'post', 'put']; + +export function registerCaptchaModalInterceptor(axios) { + return axios.interceptors.response.use( + (response) => { + return response; + }, + (err) => { + if ( + supportedMethods.includes(err?.config?.method) && + err?.response?.data?.needs_captcha_response + ) { + const { data } = err.response; + const captchaSiteKey = data.captcha_site_key; + const spamLogId = data.spam_log_id; + // eslint-disable-next-line promise/no-promise-in-callback + return import('~/captcha/wait_for_captcha_to_be_solved') + .then(({ waitForCaptchaToBeSolved }) => waitForCaptchaToBeSolved(captchaSiteKey)) + .then((captchaResponse) => { + const errConfig = err.config; + const originalData = JSON.parse(errConfig.data); + return axios({ + method: errConfig.method, + url: errConfig.url, + data: { + ...originalData, + captcha_response: captchaResponse, + spam_log_id: spamLogId, + }, + }); + }); + } + + return Promise.reject(err); + }, + ); +} diff --git a/app/assets/javascripts/captcha/unsolved_captcha_error.js b/app/assets/javascripts/captcha/unsolved_captcha_error.js new file mode 100644 index 00000000000..1e5c2a4d852 --- /dev/null +++ b/app/assets/javascripts/captcha/unsolved_captcha_error.js @@ -0,0 +1,10 @@ +import { __ } from '~/locale'; + +class UnsolvedCaptchaError extends Error { + constructor(message) { + super(message || __('You must solve the CAPTCHA in order to submit')); + this.name = 'UnsolvedCaptchaError'; + } +} + +export default UnsolvedCaptchaError; diff --git a/app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js b/app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js new file mode 100644 index 00000000000..0fd0f571d3b --- /dev/null +++ b/app/assets/javascripts/captcha/wait_for_captcha_to_be_solved.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import CaptchaModal from '~/captcha/captcha_modal.vue'; +import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error'; + +/** + * Opens a Captcha Modal with provided captchaSiteKey. + * + * Returns a Promise which resolves if the captcha is solved correctly, and rejects + * if the captcha process is aborted. + * + * @param captchaSiteKey + * @returns {Promise} + */ +export function waitForCaptchaToBeSolved(captchaSiteKey) { + return new Promise((resolve, reject) => { + let captchaModalElement = document.createElement('div'); + + document.body.append(captchaModalElement); + + let captchaModalVueInstance = new Vue({ + el: captchaModalElement, + render: (createElement) => { + return createElement(CaptchaModal, { + props: { + captchaSiteKey, + needsCaptchaResponse: true, + }, + on: { + hidden: () => { + // Cleaning up the modal from the DOM + captchaModalVueInstance.$destroy(); + captchaModalVueInstance.$el.remove(); + captchaModalElement.remove(); + + captchaModalElement = null; + captchaModalVueInstance = null; + }, + receivedCaptchaResponse: (captchaResponse) => { + if (captchaResponse) { + resolve(captchaResponse); + } else { + // reject the promise with a custom exception, allowing consuming apps to + // adjust their error handling, if appropriate. + const error = new UnsolvedCaptchaError(); + reject(error); + } + }, + }, + }); + }, + }); + }); +} 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 104d6672015..ecb39f214ec 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 @@ -20,7 +20,7 @@ export default { }, data() { return { - searchTerm: this.value || '', + searchTerm: '', }; }, computed: { @@ -38,11 +38,6 @@ export default { ); }, }, - watch: { - value(newVal) { - this.searchTerm = newVal; - }, - }, methods: { selectEnvironment(selected) { this.$emit('selectEnvironment', selected); @@ -55,11 +50,14 @@ export default { isSelected(env) { return this.value === env; }, + clearSearch() { + this.searchTerm = ''; + }, }, }; </script> <template> - <gl-dropdown :text="value"> + <gl-dropdown :text="value" @show="clearSearch"> <gl-search-box-by-type v-model.trim="searchTerm" data-testid="ci-environment-search" /> <gl-dropdown-item v-for="environment in filteredResults" diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 47b2745af08..c9943052356 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -84,7 +84,7 @@ export default { </script> <template> - <div class="ci-variable-table"> + <div class="ci-variable-table" data-testid="ci-variable-table"> <gl-table :fields="fields" :items="variables" diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue index 369cb2fa0f3..aaad0009ef3 100644 --- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue +++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue @@ -54,15 +54,17 @@ export default { required: false, }, }, - data: () => ({ - currentServerSideSettings: { - host: null, - port: null, - protocol: null, - wafLogEnabled: null, - ciliumLogEnabled: null, - }, - }), + data() { + return { + currentServerSideSettings: { + host: null, + port: null, + protocol: null, + wafLogEnabled: null, + ciliumLogEnabled: null, + }, + }; + }, computed: { isSaving() { return [UPDATING].includes(this.status); diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue index 26767c32275..277d2c33b73 100644 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue @@ -53,11 +53,13 @@ export default { }), }, }, - data: () => ({ - modSecurityLogo, - initialValue: null, - initialMode: null, - }), + data() { + return { + modSecurityLogo, + initialValue: null, + initialMode: null, + }; + }, computed: { modSecurityEnabled: { get() { diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 45d3e0cbc23..40a86a1e58c 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -1,9 +1,9 @@ +import * as Sentry from '@sentry/browser'; import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import { MAX_REQUESTS } from '../constants'; import * as types from './mutation_types'; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js index 920ffde3e32..2e050c066f1 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -31,10 +31,8 @@ export default () => { return createElement(CommitPipelinesTable, { props: { endpoint: pipelineTableViewEl.dataset.endpoint, - helpPagePath: pipelineTableViewEl.dataset.helpPagePath, emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath, - autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath, }, }); }, diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 787152d00ef..ca4d8da2482 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -25,14 +25,6 @@ export default { type: String, required: true, }, - helpPagePath: { - type: String, - required: true, - }, - autoDevopsHelpPath: { - type: String, - required: true, - }, errorStateSvgPath: { type: String, required: true, @@ -200,7 +192,7 @@ export default { <gl-button v-if="canRenderPipelineButton" block - class="gl-mt-3 gl-mb-0 gl-md-display-none" + class="gl-mt-3 gl-mb-3 gl-md-display-none" variant="success" data-testid="run_pipeline_button_mobile" :loading="state.isRunningMergeRequestPipeline" @@ -212,7 +204,6 @@ export default { <pipelines-table-component :pipelines="state.pipelines" :update-graph-dropdown="updateGraphDropdown" - :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" > <template #table-header-actions> diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index f750c62103e..e5e23f2fb5e 100644 --- a/app/assets/javascripts/commons/bootstrap.js +++ b/app/assets/javascripts/commons/bootstrap.js @@ -6,8 +6,6 @@ import 'bootstrap/js/dist/button'; import 'bootstrap/js/dist/collapse'; import 'bootstrap/js/dist/modal'; import 'bootstrap/js/dist/dropdown'; -import 'bootstrap/js/dist/popover'; -import 'bootstrap/js/dist/tooltip'; import 'bootstrap/js/dist/tab'; // custom jQuery functions @@ -19,68 +17,3 @@ $.fn.extend({ return $(this).prop('disabled', false).removeClass('disabled'); }, }); - -/* - Starting with bootstrap 4.3.1, bootstrap sanitizes html used for tooltips / popovers. - This extends the default whitelists with more elements / attributes: - https://getbootstrap.com/docs/4.3/getting-started/javascript/#sanitizer - */ -const whitelist = $.fn.tooltip.Constructor.Default.whiteList; - -const inputAttributes = ['value', 'type']; - -const dataAttributes = [ - 'data-toggle', - 'data-placement', - 'data-container', - 'data-title', - 'data-class', - 'data-clipboard-text', - 'data-placement', -]; - -// Whitelisting data attributes -whitelist['*'] = [ - ...whitelist['*'], - ...dataAttributes, - 'title', - 'width height', - 'abbr', - 'datetime', - 'name', - 'width', - 'height', -]; - -// Whitelist missing elements: -whitelist.label = ['for']; -whitelist.button = [...inputAttributes]; -whitelist.input = [...inputAttributes]; - -whitelist.tt = []; -whitelist.samp = []; -whitelist.kbd = []; -whitelist.var = []; -whitelist.dfn = []; -whitelist.cite = []; -whitelist.big = []; -whitelist.address = []; -whitelist.dl = []; -whitelist.dt = []; -whitelist.dd = []; -whitelist.abbr = []; -whitelist.acronym = []; -whitelist.blockquote = []; -whitelist.del = []; -whitelist.ins = []; -whitelist['gl-emoji'] = [ - 'data-name', - 'data-unicode-version', - 'data-fallback-src', - 'data-fallback-sprite-class', -]; - -// Whitelisting SVG tags and attributes -whitelist.svg = ['viewBox']; -whitelist.use = ['xlink:href']; -whitelist.path = ['d']; diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js index 5b5a1507d38..23647d99656 100644 --- a/app/assets/javascripts/commons/vue.js +++ b/app/assets/javascripts/commons/vue.js @@ -6,3 +6,5 @@ if (process.env.NODE_ENV !== 'production') { } Vue.use(GlFeatureFlagsPlugin); + +Vue.config.ignoredElements = ['gl-emoji']; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js index 0f0db2090c1..1c698cc2796 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js @@ -1,8 +1,9 @@ export const DEFAULT_REGION = 'us-east-2'; export const KUBERNETES_VERSIONS = [ - { name: '1.14', value: '1.14' }, { name: '1.15', value: '1.15' }, - { name: '1.16', value: '1.16', default: true }, + { name: '1.16', value: '1.16' }, { name: '1.17', value: '1.17' }, + { name: '1.18', value: '1.18' }, + { name: '1.19', value: '1.19', default: true }, ]; diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue new file mode 100644 index 00000000000..df77d641e21 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/base.vue @@ -0,0 +1,288 @@ +<script> +import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import Cookies from 'js-cookie'; +import { deprecatedCreateFlash as Flash } from '~/flash'; +import { __ } from '~/locale'; +import banner from './banner.vue'; +import stageCodeComponent from './stage_code_component.vue'; +import stageComponent from './stage_component.vue'; +import stageNavItem from './stage_nav_item.vue'; +import stageReviewComponent from './stage_review_component.vue'; +import stageStagingComponent from './stage_staging_component.vue'; +import stageTestComponent from './stage_test_component.vue'; + +const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; + +export default { + name: 'CycleAnalytics', + components: { + GlIcon, + GlEmptyState, + GlLoadingIcon, + GlSprintf, + banner, + 'stage-issue-component': stageComponent, + 'stage-plan-component': stageComponent, + 'stage-code-component': stageCodeComponent, + 'stage-test-component': stageTestComponent, + 'stage-review-component': stageReviewComponent, + 'stage-staging-component': stageStagingComponent, + 'stage-production-component': stageComponent, + 'stage-nav-item': stageNavItem, + }, + props: { + noDataSvgPath: { + type: String, + required: true, + }, + noAccessSvgPath: { + type: String, + required: true, + }, + store: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + }, + data() { + return { + state: this.store.state, + isLoading: false, + isLoadingStage: false, + isEmptyStage: false, + hasError: true, + startDate: 30, + isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE), + }; + }, + computed: { + currentStage() { + return this.store.currentActiveStage(); + }, + }, + created() { + this.fetchCycleAnalyticsData(); + }, + methods: { + handleError() { + this.store.setErrorState(true); + return new Flash(__('There was an error while fetching value stream analytics data.')); + }, + handleDateSelect(startDate) { + this.startDate = startDate; + this.fetchCycleAnalyticsData({ startDate: this.startDate }); + }, + fetchCycleAnalyticsData(options) { + const fetchOptions = options || { startDate: this.startDate }; + + this.isLoading = true; + + this.service + .fetchCycleAnalyticsData(fetchOptions) + .then((response) => { + this.store.setCycleAnalyticsData(response); + this.selectDefaultStage(); + }) + .catch(() => { + this.handleError(); + }) + .finally(() => { + this.isLoading = false; + }); + }, + selectDefaultStage() { + const stage = this.state.stages[0]; + this.selectStage(stage); + }, + selectStage(stage) { + if (this.isLoadingStage) return; + if (this.currentStage === stage) return; + + if (!stage.isUserAllowed) { + this.store.setActiveStage(stage); + return; + } + + this.isLoadingStage = true; + this.store.setStageEvents([], stage); + this.store.setActiveStage(stage); + + this.service + .fetchStageData({ + stage, + startDate: this.startDate, + projectIds: this.selectedProjectIds, + }) + .then((response) => { + this.isEmptyStage = !response.events.length; + this.store.setStageEvents(response.events, stage); + }) + .catch(() => { + this.isEmptyStage = true; + }) + .finally(() => { + this.isLoadingStage = false; + }); + }, + dismissOverviewDialog() { + this.isOverviewDialogDismissed = true; + Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 }); + }, + }, + dayRangeOptions: [7, 30, 90], + i18n: { + dropdownText: __('Last %{days} days'), + }, +}; +</script> +<template> + <div class="cycle-analytics"> + <gl-loading-icon v-if="isLoading" size="lg" /> + <div v-else class="wrapper"> + <div class="card"> + <div class="card-header">{{ __('Recent Project Activity') }}</div> + <div class="d-flex justify-content-between"> + <div v-for="item in state.summary" :key="item.title" class="flex-grow text-center"> + <h3 class="header">{{ item.value }}</h3> + <p class="text">{{ item.title }}</p> + </div> + <div class="flex-grow align-self-center text-center"> + <div class="js-ca-dropdown dropdown inline"> + <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button"> + <span class="dropdown-label"> + <gl-sprintf :message="$options.i18n.dropdownText"> + <template #days>{{ startDate }}</template> + </gl-sprintf> + <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" /> + </span> + </button> + <ul class="dropdown-menu dropdown-menu-right"> + <li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`"> + <a href="#" @click.prevent="handleDateSelect(days)"> + <gl-sprintf :message="$options.i18n.dropdownText"> + <template #days>{{ days }}</template> + </gl-sprintf> + </a> + </li> + </ul> + </div> + </div> + </div> + </div> + <div class="stage-panel-container"> + <div class="card stage-panel"> + <div class="card-header border-bottom-0"> + <nav class="col-headers"> + <ul> + <li class="stage-header pl-5"> + <span class="stage-name font-weight-bold">{{ + s__('ProjectLifecycle|Stage') + }}</span> + <span + class="has-tooltip" + data-placement="top" + :title="__('The phase of the development lifecycle.')" + aria-hidden="true" + > + <gl-icon name="question-o" class="gl-text-gray-500" /> + </span> + </li> + <li class="median-header"> + <span class="stage-name font-weight-bold">{{ __('Median') }}</span> + <span + class="has-tooltip" + data-placement="top" + :title=" + __( + 'The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6.', + ) + " + aria-hidden="true" + > + <gl-icon name="question-o" class="gl-text-gray-500" /> + </span> + </li> + <li class="event-header pl-3"> + <span + v-if="currentStage && currentStage.legend" + class="stage-name font-weight-bold" + >{{ currentStage ? __(currentStage.legend) : __('Related Issues') }}</span + > + <span + class="has-tooltip" + data-placement="top" + :title=" + __('The collection of events added to the data gathered for that stage.') + " + aria-hidden="true" + > + <gl-icon name="question-o" class="gl-text-gray-500" /> + </span> + </li> + <li class="total-time-header pr-5 text-right"> + <span class="stage-name font-weight-bold">{{ __('Time') }}</span> + <span + class="has-tooltip" + data-placement="top" + :title="__('The time taken by each data entry gathered by that stage.')" + aria-hidden="true" + > + <gl-icon name="question-o" class="gl-text-gray-500" /> + </span> + </li> + </ul> + </nav> + </div> + + <div class="stage-panel-body"> + <nav class="stage-nav"> + <ul> + <stage-nav-item + v-for="stage in state.stages" + :key="stage.title" + :title="stage.title" + :is-user-allowed="stage.isUserAllowed" + :value="stage.value" + :is-active="stage.active" + @select="selectStage(stage)" + /> + </ul> + </nav> + <section class="stage-events overflow-auto"> + <gl-loading-icon v-show="isLoadingStage" size="lg" /> + <template v-if="currentStage && !currentStage.isUserAllowed"> + <gl-empty-state + class="js-empty-state" + :title="__('You need permission.')" + :svg-path="noAccessSvgPath" + :description="__('Want to see the data? Please ask an administrator for access.')" + /> + </template> + <template v-else> + <template v-if="currentStage && isEmptyStage && !isLoadingStage"> + <gl-empty-state + class="js-empty-state" + :description="currentStage.emptyStageText" + :svg-path="noDataSvgPath" + :title="__('We don\'t have enough data to show this stage.')" + /> + </template> + <template v-if="state.events.length && !isLoadingStage && !isEmptyStage"> + <component + :is="currentStage.component" + :stage="currentStage" + :items="state.events" + /> + </template> + </template> + </section> + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js deleted file mode 100644 index 847820c965f..00000000000 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ /dev/null @@ -1,156 +0,0 @@ -// This is a true violation of @gitlab/no-runtime-template-compiler, as it -// relies on app/views/projects/cycle_analytics/show.html.haml for its -// template. -/* eslint-disable @gitlab/no-runtime-template-compiler */ -import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; -import $ from 'jquery'; -import Cookies from 'js-cookie'; -import Vue from 'vue'; -import { __ } from '~/locale'; -import { deprecatedCreateFlash as Flash } from '../flash'; -import Translate from '../vue_shared/translate'; -import banner from './components/banner.vue'; -import stageCodeComponent from './components/stage_code_component.vue'; -import stageComponent from './components/stage_component.vue'; -import stageNavItem from './components/stage_nav_item.vue'; -import stageReviewComponent from './components/stage_review_component.vue'; -import stageStagingComponent from './components/stage_staging_component.vue'; -import stageTestComponent from './components/stage_test_component.vue'; -import CycleAnalyticsService from './cycle_analytics_service'; -import CycleAnalyticsStore from './cycle_analytics_store'; - -Vue.use(Translate); - -export default () => { - const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; - const cycleAnalyticsEl = document.querySelector('#cycle-analytics'); - - // eslint-disable-next-line no-new - new Vue({ - el: '#cycle-analytics', - name: 'CycleAnalytics', - components: { - GlEmptyState, - GlLoadingIcon, - banner, - 'stage-issue-component': stageComponent, - 'stage-plan-component': stageComponent, - 'stage-code-component': stageCodeComponent, - 'stage-test-component': stageTestComponent, - 'stage-review-component': stageReviewComponent, - 'stage-staging-component': stageStagingComponent, - 'stage-production-component': stageComponent, - 'stage-nav-item': stageNavItem, - }, - data() { - return { - store: CycleAnalyticsStore, - state: CycleAnalyticsStore.state, - isLoading: false, - isLoadingStage: false, - isEmptyStage: false, - hasError: false, - startDate: 30, - isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE), - service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath), - }; - }, - computed: { - currentStage() { - return this.store.currentActiveStage(); - }, - }, - created() { - // Conditional check placed here to prevent this method from being called on the - // new Value Stream Analytics page (i.e. the new page will be initialized blank and only - // after a group is selected the cycle analyitcs data will be fetched). Once the - // old (current) page has been removed this entire created method as well as the - // variable itself can be completely removed. - // Follow up issue: https://gitlab.com/gitlab-org/gitlab-foss/issues/64490 - if (cycleAnalyticsEl.dataset.requestPath) this.fetchCycleAnalyticsData(); - }, - methods: { - handleError() { - this.store.setErrorState(true); - return new Flash(__('There was an error while fetching value stream analytics data.')); - }, - initDropdown() { - const $dropdown = $('.js-ca-dropdown'); - const $label = $dropdown.find('.dropdown-label'); - - // eslint-disable-next-line @gitlab/no-global-event-off - $dropdown - .find('li a') - .off('click') - .on('click', (e) => { - e.preventDefault(); - const $target = $(e.currentTarget); - this.startDate = $target.data('value'); - - $label.text($target.text().trim()); - this.fetchCycleAnalyticsData({ startDate: this.startDate }); - }); - }, - fetchCycleAnalyticsData(options) { - const fetchOptions = options || { startDate: this.startDate }; - - this.isLoading = true; - - this.service - .fetchCycleAnalyticsData(fetchOptions) - .then((response) => { - this.store.setCycleAnalyticsData(response); - this.selectDefaultStage(); - this.initDropdown(); - this.isLoading = false; - }) - .catch(() => { - this.handleError(); - this.isLoading = false; - }); - }, - selectDefaultStage() { - const stage = this.state.stages[0]; - this.selectStage(stage); - }, - selectStage(stage) { - if (this.isLoadingStage) return; - if (this.currentStage === stage) return; - - if (!stage.isUserAllowed) { - this.store.setActiveStage(stage); - return; - } - - this.isLoadingStage = true; - this.store.setStageEvents([], stage); - this.store.setActiveStage(stage); - - this.service - .fetchStageData({ - stage, - startDate: this.startDate, - projectIds: this.selectedProjectIds, - }) - .then((response) => { - this.isEmptyStage = !response.events.length; - this.store.setStageEvents(response.events, stage); - this.isLoadingStage = false; - }) - .catch(() => { - this.isEmptyStage = true; - this.isLoadingStage = false; - }); - }, - dismissOverviewDialog() { - this.isOverviewDialogDismissed = true; - Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 }); - }, - createCycleAnalyticsService(requestPath) { - return new CycleAnalyticsService({ - requestPath, - }); - }, - }, - }); -}; diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js new file mode 100644 index 00000000000..42d6700fae1 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import Translate from '../vue_shared/translate'; +import CycleAnalytics from './components/base.vue'; +import CycleAnalyticsService from './cycle_analytics_service'; +import CycleAnalyticsStore from './cycle_analytics_store'; + +Vue.use(Translate); + +const createCycleAnalyticsService = (requestPath) => + new CycleAnalyticsService({ + requestPath, + }); + +export default () => { + const el = document.querySelector('#js-cycle-analytics'); + const { noAccessSvgPath, noDataSvgPath } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'CycleAnalytics', + render: (createElement) => + createElement(CycleAnalytics, { + props: { + noDataSvgPath, + noAccessSvgPath, + store: CycleAnalyticsStore, + service: createCycleAnalyticsService(el.dataset.requestPath), + }, + }), + }); +}; diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js index 162491312a8..a1dd12ff769 100644 --- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js +++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js @@ -29,6 +29,26 @@ const FILTER_INPUT = '.dropdown-input .dropdown-input-field:not(.dropdown-no-fil const NO_FILTER_INPUT = '.dropdown-input .dropdown-input-field.dropdown-no-filter'; +let mouseEventListenersAdded = false; +let mousedownTarget = null; +let mouseupTarget = null; + +function addGlobalMouseEventListeners() { + // Remember mousedown and mouseup locations. + // Required in the `hide.bs.dropdown` listener for + // dropdown close prevention in some cases. + document.addEventListener('mousedown', ({ target }) => { + mousedownTarget = target; + }); + document.addEventListener('mouseup', ({ target }) => { + mouseupTarget = target; + }); + document.addEventListener('click', () => { + mousedownTarget = null; + mouseupTarget = null; + }); +} + export class GitLabDropdown { constructor(el1, options) { let selector; @@ -36,9 +56,14 @@ export class GitLabDropdown { this.el = el1; this.options = options; this.updateLabel = this.updateLabel.bind(this); - this.hidden = this.hidden.bind(this); this.opened = this.opened.bind(this); + this.hide = this.hide.bind(this); + this.hidden = this.hidden.bind(this); this.shouldPropagate = this.shouldPropagate.bind(this); + if (!mouseEventListenersAdded) { + addGlobalMouseEventListeners(); + mouseEventListenersAdded = true; + } self = this; selector = $(this.el).data('target'); this.dropdown = selector != null ? $(selector) : $(this.el).parent(); @@ -132,6 +157,7 @@ export class GitLabDropdown { } // Event listeners this.dropdown.on('shown.bs.dropdown', this.opened); + this.dropdown.on('hide.bs.dropdown', this.hide); this.dropdown.on('hidden.bs.dropdown', this.hidden); $(this.el).on('update.label', this.updateLabel); this.dropdown.on('click', '.dropdown-menu, .dropdown-menu-close', this.shouldPropagate); @@ -334,6 +360,21 @@ export class GitLabDropdown { $menu.css('bottom', '100%'); } + hide(e) { + // Prevent dropdowns with a search from being closed when the + // mousedown event happened inside the dropdown box and only + // the mouseup event did not. + if (this.options.search && mousedownTarget) { + const isIn = (element, $possibleContainer) => Boolean($possibleContainer.has(element).length); + const $menu = this.dropdown.find('.dropdown-menu'); + const mousedownInsideDropdown = isIn(mousedownTarget, $menu); + const mouseupOutsideDropdown = !isIn(mouseupTarget, $menu); + if (mousedownInsideDropdown && mouseupOutsideDropdown) { + e.preventDefault(); + } + } + } + hidden(e) { this.resetRows(); this.removeArrowKeyEvent(); diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue index 273fa3f6be2..fbcce22ec1e 100644 --- a/app/assets/javascripts/design_management/components/delete_button.vue +++ b/app/assets/javascripts/design_management/components/delete_button.vue @@ -73,21 +73,19 @@ export default { </script> <template> - <div class="gl-display-flex gl-align-items-center gl-h-full"> + <div> <gl-modal :modal-id="modalId" :title="$options.modal.title" :action-primary="$options.modal.actionPrimary" :action-cancel="$options.modal.actionCancel" - @ok="$emit('deleteSelectedDesigns')" + @ok="$emit('delete-selected-designs')" > - <p> - {{ - s__( - 'DesignManagement|Archived designs will still be available in previous versions of the design collection.', - ) - }} - </p> + {{ + s__( + 'DesignManagement|Archived designs will still be available in previous versions of the design collection.', + ) + }} </gl-modal> <gl-button v-gl-modal-directive="modalId" diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue index 01f9cac456d..0178111f651 100644 --- a/app/assets/javascripts/design_management/components/design_destroyer.vue +++ b/app/assets/javascripts/design_management/components/design_destroyer.vue @@ -55,6 +55,7 @@ export default { iid, }" :update="updateStoreAfterDelete" + :tag="null" v-on="$listeners" > <slot v-bind="{ mutate, loading, error }"></slot> 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 33f0aa00cad..b1c37b0687f 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 @@ -253,12 +253,12 @@ export default { :class="{ 'gl-bg-blue-50': isDiscussionActive }" @error="$emit('update-note-error', $event)" /> - <li v-show="isReplyPlaceholderVisible" class="reply-wrapper"> + <li v-show="isReplyPlaceholderVisible" class="reply-wrapper discussion-reply-holder"> <reply-placeholder v-if="!isFormVisible" class="qa-discussion-reply" - :button-text="__('Reply...')" - @onClick="showForm" + :placeholder-text="__('Reply…')" + @focus="showForm" /> <apollo-mutation v-else diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 5eabe9ef1bc..2169c9111d2 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -138,6 +138,7 @@ export default { <gl-icon :name="icon.name" :size="18" + use-deprecated-sizes :class="icon.classes" data-qa-selector="design_status_icon" :data-qa-status="icon.name" diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index a3b0f06fb28..8abf1529f3c 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -130,7 +130,7 @@ export default { button-icon="archive" button-category="secondary" :title="s__('DesignManagement|Archive design')" - @deleteSelectedDesigns="$emit('delete')" + @delete-selected-designs="$emit('delete')" /> </header> </template> diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue index d7b287f663b..394ccb3c483 100644 --- a/app/assets/javascripts/design_management/components/upload/button.vue +++ b/app/assets/javascripts/design_management/components/upload/button.vue @@ -50,7 +50,7 @@ export default { type="file" name="design_file" :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype" - class="hide" + class="gl-display-none" multiple @change="onFileUploadChange" /> diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index c73c8fb6ca4..99ac38fc554 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -365,7 +365,8 @@ export default { v-if="isLatestVersion" variant="link" size="small" - class="gl-mr-4 js-select-all" + class="gl-mr-3" + data-testid="select-all-designs-button" @click="toggleDesignsSelection" >{{ selectAllButtonText }} </gl-button> @@ -385,7 +386,7 @@ export default { data-qa-selector="archive_button" :loading="loading" :has-selected-designs="hasSelectedDesigns" - @deleteSelectedDesigns="mutate()" + @delete-selected-designs="mutate()" > {{ s__('DesignManagement|Archive selected') }} </delete-button> diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 4323499ef1f..253e1e3b70e 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -23,9 +23,7 @@ import { ALERT_OVERFLOW_HIDDEN, ALERT_MERGE_CONFLICT, ALERT_COLLAPSED_FILES, - EVT_VIEW_FILE_BY_FILE, } from '../constants'; -import eventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; @@ -127,7 +125,7 @@ export default { required: false, default: '', }, - mrReviews: { + rehydratedMrReviews: { type: Object, required: false, default: () => ({}), @@ -166,6 +164,7 @@ export default { 'canMerge', 'hasConflicts', 'viewDiffsFileByFile', + 'mrReviews', ]), ...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']), ...mapGetters(['isNotesFetched', 'getNoteableData']), @@ -270,7 +269,7 @@ export default { showSuggestPopover: this.showSuggestPopover, viewDiffsFileByFile: fileByFile(this.fileByFileUserPreference), defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage, - mrReviews: this.mrReviews || {}, + mrReviews: this.rehydratedMrReviews, }); if (this.shouldShow) { @@ -332,16 +331,11 @@ export default { subscribeToEvents() { notesEventHub.$once('fetchDiffData', this.fetchData); notesEventHub.$on('refetchDiffData', this.refetchDiffData); - eventHub.$on(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener); }, unsubscribeFromEvents() { - eventHub.$off(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener); notesEventHub.$off('refetchDiffData', this.refetchDiffData); notesEventHub.$off('fetchDiffData', this.fetchData); }, - fileByFileListener({ setting } = {}) { - this.setFileByFile({ fileByFile: setting }); - }, navigateToDiffFileNumber(number) { this.navigateToDiffFileIndex(number - 1); }, @@ -520,7 +514,7 @@ export default { v-for="(file, index) in diffs" :key="file.newPath" :file="file" - :reviewed="fileReviews[index]" + :reviewed="fileReviews[file.id]" :is-first-file="index === 0" :is-last-file="index === diffFilesLength - 1" :help-page-path="helpPagePath" diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue index 9027d0c8aa4..3766c125325 100644 --- a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue +++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue @@ -35,8 +35,9 @@ export default { <slot v-if="hasForm" name="form"></slot> <template v-else-if="renderReplyPlaceholder"> <reply-placeholder - :button-text="__('Start a new discussion...')" - @onClick="$emit('showNewDiscussionForm')" + :placeholder-text="__('Start a new discussion…')" + :label-text="__('New discussion')" + @focus="$emit('showNewDiscussionForm')" /> </template> </template> diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index f77c8d7406b..ca4543f7002 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -150,6 +150,11 @@ export default { }, }, watch: { + 'file.id': { + handler: function fileIdHandler() { + this.manageViewedEffects(); + }, + }, 'file.file_hash': { handler: function hashChangeWatch(newHash, oldHash) { this.isCollapsed = isCollapsed(this.file); @@ -186,9 +191,7 @@ export default { this.postRender(); } - if (this.reviewed && !this.isCollapsed && this.showLocalFileReviews) { - this.handleToggle(); - } + this.manageViewedEffects(); }, beforeDestroy() { eventHub.$off(EVT_EXPAND_ALL_FILES, this.expandAllListener); @@ -200,6 +203,11 @@ export default { 'setRenderIt', 'setFileCollapsedByUser', ]), + manageViewedEffects() { + if (this.reviewed && !this.isCollapsed && this.showLocalFileReviews) { + this.handleToggle(); + } + }, expandAllListener() { if (this.isCollapsed) { this.handleToggle(); diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 1195a7f2565..1f50b3a38a6 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -339,14 +339,12 @@ export default { v-if="isReviewable && showLocalFileReviews" v-gl-tooltip.hover data-testid="fileReviewCheckbox" - class="gl-mb-0" + class="gl-mr-5 gl-display-flex gl-align-items-center" :title="$options.i18n.fileReviewTooltip" :checked="reviewed" @change="toggleReview" > - <span class="gl-line-height-20"> - {{ $options.i18n.fileReviewLabel }} - </span> + {{ $options.i18n.fileReviewLabel }} </gl-form-checkbox> <gl-button-group class="gl-pt-0!"> <gl-button diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue index 7d74e81257a..879922f86a2 100644 --- a/app/assets/javascripts/diffs/components/settings_dropdown.vue +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -1,9 +1,6 @@ <script> import { GlButtonGroup, GlButton, GlDropdown, GlFormCheckbox } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; - -import { EVT_VIEW_FILE_BY_FILE } from '../constants'; -import eventHub from '../event_hub'; import { SETTINGS_DROPDOWN } from '../i18n'; export default { @@ -24,9 +21,13 @@ export default { 'setParallelDiffViewType', 'setRenderTreeList', 'setShowWhitespace', + 'setFileByFile', ]), toggleFileByFile() { - eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting: !this.viewDiffsFileByFile }); + this.setFileByFile({ fileByFile: !this.viewDiffsFileByFile }); + }, + toggleWhitespace(updatedSetting) { + this.setShowWhitespace({ showWhitespace: updatedSetting, pushState: true }); }, }, }; @@ -82,26 +83,21 @@ export default { </gl-button> </gl-button-group> </div> - <div class="gl-mt-3 gl-px-3"> - <label class="gl-mb-0"> - <input - id="show-whitespace" - type="checkbox" - :checked="showWhitespace" - @change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })" - /> - {{ __('Show whitespace changes') }} - </label> - </div> - <div class="gl-mt-3 gl-px-3"> - <gl-form-checkbox - data-testid="file-by-file" - class="gl-mb-0" - :checked="viewDiffsFileByFile" - @input="toggleFileByFile" - > - {{ $options.i18n.fileByFile }} - </gl-form-checkbox> - </div> + <gl-form-checkbox + data-testid="show-whitespace" + class="gl-mt-3 gl-ml-3" + :checked="showWhitespace" + @input="toggleWhitespace" + > + {{ $options.i18n.whitespace }} + </gl-form-checkbox> + <gl-form-checkbox + data-testid="file-by-file" + class="gl-ml-3 gl-mb-0" + :checked="viewDiffsFileByFile" + @input="toggleFileByFile" + > + {{ $options.i18n.fileByFile }} + </gl-form-checkbox> </gl-dropdown> </template> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 7080348ee7d..0163f508fea 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -103,7 +103,6 @@ export const RENAMED_DIFF_TRANSITIONS = { // MR Diffs known events export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles'; -export const EVT_VIEW_FILE_BY_FILE = 'mr:diffs:preference:fileByFile'; 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'; diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js index 2a061876937..b2354af1eec 100644 --- a/app/assets/javascripts/diffs/i18n.js +++ b/app/assets/javascripts/diffs/i18n.js @@ -21,5 +21,6 @@ export const DIFF_FILE = { }; export const SETTINGS_DROPDOWN = { + whitespace: __('Show whitespace changes'), fileByFile: __('Show one file at a time'), }; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 68fe204d955..87e9af174e5 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -124,7 +124,7 @@ export default function initDiffsApp(store) { showSuggestPopover: this.showSuggestPopover, fileByFileUserPreference: this.viewDiffsFileByFile, defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage, - mrReviews: getReviewsForMergeRequest(mrPath), + rehydratedMrReviews: getReviewsForMergeRequest(mrPath), }, }); }, diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index 4b2dc2d45df..8796016def9 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -741,12 +741,7 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => { export const setFileByFile = ({ commit }, { fileByFile }) => { const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES; commit(types.SET_FILE_BY_FILE, fileByFile); - Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode); - - historyPushState( - mergeUrlParams({ [DIFF_FILE_BY_FILE_COOKIE_NAME]: fileViewMode }, window.location.href), - ); }; export function reviewFile({ commit, state }, { file, reviewed = true }) { diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 87b4f33c216..b37a75eb2a3 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -283,7 +283,7 @@ export function addContextLines(options) { * Trims the first char of the `richText` property when it's either a space or a diff symbol. * @param {Object} line * @returns {Object} - * @deprecated + * @deprecated Use `line.rich_text = line.rich_text ? line.rich_text.replace(/^[+ -]/, '') : undefined;` instead!. For more information, see https://gitlab.com/gitlab-org/gitlab/-/issues/299329 */ export function trimFirstCharOfLineContent(line = {}) { // eslint-disable-next-line no-param-reassign diff --git a/app/assets/javascripts/diffs/utils/file_reviews.js b/app/assets/javascripts/diffs/utils/file_reviews.js index 5fafc1714ae..7a4b1aa6b17 100644 --- a/app/assets/javascripts/diffs/utils/file_reviews.js +++ b/app/assets/javascripts/diffs/utils/file_reviews.js @@ -9,7 +9,12 @@ export function isFileReviewed(reviews, file) { } export function reviewStatuses(files, reviews) { - return files.map((file) => isFileReviewed(reviews, file)); + return files.reduce((flat, file) => { + return { + ...flat, + [file.id]: isFileReviewed(reviews, file), + }; + }, {}); } export function getReviewsForMergeRequest(mrPath) { diff --git a/app/assets/javascripts/diffs/utils/preferences.js b/app/assets/javascripts/diffs/utils/preferences.js index e440de3350a..6b4aaf45937 100644 --- a/app/assets/javascripts/diffs/utils/preferences.js +++ b/app/assets/javascripts/diffs/utils/preferences.js @@ -1,22 +1,13 @@ import Cookies from 'js-cookie'; -import { getParameterValues } from '~/lib/utils/url_utility'; - import { DIFF_FILE_BY_FILE_COOKIE_NAME, DIFF_VIEW_FILE_BY_FILE } from '../constants'; export function fileByFile(pref = false) { - const search = getParameterValues(DIFF_FILE_BY_FILE_COOKIE_NAME)?.[0]; const cookie = Cookies.get(DIFF_FILE_BY_FILE_COOKIE_NAME); - let viewFileByFile = pref; // use the cookie first, if it exists if (cookie) { - viewFileByFile = cookie === DIFF_VIEW_FILE_BY_FILE; - } - - // the search parameter of the URL should override, if it exists - if (search) { - viewFileByFile = search === DIFF_VIEW_FILE_BY_FILE; + return cookie === DIFF_VIEW_FILE_BY_FILE; } - return viewFileByFile; + return pref; } diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index d9e6a6c13e2..c991316dda2 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -16,6 +16,9 @@ export const EDITOR_READY_EVENT = 'editor-ready'; export const EDITOR_TYPE_CODE = 'vs.editor.ICodeEditor'; export const EDITOR_TYPE_DIFF = 'vs.editor.IDiffEditor'; +export const EDITOR_CODE_INSTANCE_FN = 'createInstance'; +export const EDITOR_DIFF_INSTANCE_FN = 'createDiffInstance'; + // // EXTENSIONS' CONSTANTS // diff --git a/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js b/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js new file mode 100644 index 00000000000..83b0386d470 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/editor_lite_webide_ext.js @@ -0,0 +1,164 @@ +import { debounce } from 'lodash'; +import { KeyCode, KeyMod, Range } from 'monaco-editor'; +import { EDITOR_TYPE_DIFF } from '~/editor/constants'; +import { EditorLiteExtension } from '~/editor/extensions/editor_lite_extension_base'; +import Disposable from '~/ide/lib/common/disposable'; +import { editorOptions } from '~/ide/lib/editor_options'; +import keymap from '~/ide/lib/keymap.json'; + +const isDiffEditorType = (instance) => { + return instance.getEditorType() === EDITOR_TYPE_DIFF; +}; + +export const UPDATE_DIMENSIONS_DELAY = 200; + +export class EditorWebIdeExtension extends EditorLiteExtension { + constructor({ instance, modelManager, ...options } = {}) { + super({ + instance, + ...options, + modelManager, + disposable: new Disposable(), + debouncedUpdate: debounce(() => { + instance.updateDimensions(); + }, UPDATE_DIMENSIONS_DELAY), + }); + + window.addEventListener('resize', instance.debouncedUpdate, false); + + instance.onDidDispose(() => { + window.removeEventListener('resize', instance.debouncedUpdate); + + // catch any potential errors with disposing the error + // this is mainly for tests caused by elements not existing + try { + instance.disposable.dispose(); + } catch (e) { + if (process.env.NODE_ENV !== 'test') { + // eslint-disable-next-line no-console + console.error(e); + } + } + }); + + EditorWebIdeExtension.addActions(instance); + } + + static addActions(instance) { + const { store } = instance; + const getKeyCode = (key) => { + const monacoKeyMod = key.indexOf('KEY_') === 0; + + return monacoKeyMod ? KeyCode[key] : KeyMod[key]; + }; + + keymap.forEach((command) => { + const { bindings, id, label, action } = command; + + const keybindings = bindings.map((binding) => { + const keys = binding.split('+'); + + // eslint-disable-next-line no-bitwise + return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]); + }); + + instance.addAction({ + id, + label, + keybindings, + run() { + store.dispatch(action.name, action.params); + return null; + }, + }); + }); + } + + createModel(file, head = null) { + return this.modelManager.addModel(file, head); + } + + attachModel(model) { + if (isDiffEditorType(this)) { + this.setModel({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + + return; + } + + this.setModel(model.getModel()); + + this.updateOptions( + editorOptions.reduce((acc, obj) => { + Object.keys(obj).forEach((key) => { + Object.assign(acc, { + [key]: obj[key](model), + }); + }); + return acc; + }, {}), + ); + } + + attachMergeRequestModel(model) { + this.setModel({ + original: model.getBaseModel(), + modified: model.getModel(), + }); + } + + updateDimensions() { + this.layout(); + this.updateDiffView(); + } + + setPos({ lineNumber, column }) { + this.revealPositionInCenter({ + lineNumber, + column, + }); + this.setPosition({ + lineNumber, + column, + }); + } + + onPositionChange(cb) { + if (!this.onDidChangeCursorPosition) { + return; + } + + this.disposable.add(this.onDidChangeCursorPosition((e) => cb(this, e))); + } + + updateDiffView() { + if (!isDiffEditorType(this)) { + return; + } + + this.updateOptions({ + renderSideBySide: EditorWebIdeExtension.renderSideBySide(this.getDomNode()), + }); + } + + replaceSelectedText(text) { + let selection = this.getSelection(); + const range = new Range( + selection.startLineNumber, + selection.startColumn, + selection.endLineNumber, + selection.endColumn, + ); + + this.executeEdits('', [{ range, text }]); + + selection = this.getSelection(); + this.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn }); + } + + static renderSideBySide(domElement) { + return domElement.offsetWidth >= 700; + } +} diff --git a/app/assets/javascripts/emoji/components/category.vue b/app/assets/javascripts/emoji/components/category.vue new file mode 100644 index 00000000000..a11122d5403 --- /dev/null +++ b/app/assets/javascripts/emoji/components/category.vue @@ -0,0 +1,61 @@ +<script> +import { GlIntersectionObserver } from '@gitlab/ui'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import EmojiGroup from './emoji_group.vue'; + +export default { + components: { + GlIntersectionObserver, + EmojiGroup, + }, + props: { + category: { + type: String, + required: true, + }, + emojis: { + type: Array, + required: true, + }, + }, + data() { + return { + renderGroup: false, + }; + }, + computed: { + categoryTitle() { + return capitalizeFirstCharacter(this.category); + }, + }, + methods: { + categoryAppeared() { + this.renderGroup = true; + this.$emit('appear', this.category); + }, + categoryDissappeared() { + this.renderGroup = false; + }, + }, +}; +</script> + +<template> + <gl-intersection-observer class="gl-px-5 gl-h-full" @appear="categoryAppeared"> + <div class="gl-top-0 gl-py-3 gl-w-full emoji-picker-category-header"> + <b>{{ categoryTitle }}</b> + </div> + <template v-if="emojis.length"> + <emoji-group + v-for="(emojiGroup, index) in emojis" + :key="index" + :emojis="emojiGroup" + :render-group="renderGroup" + :click-emoji="(emoji) => $emit('click', emoji)" + /> + </template> + <p v-else> + {{ s__('AwardEmoji|No emojis found.') }} + </p> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/emoji/components/emoji_group.vue b/app/assets/javascripts/emoji/components/emoji_group.vue new file mode 100644 index 00000000000..539cd6963b1 --- /dev/null +++ b/app/assets/javascripts/emoji/components/emoji_group.vue @@ -0,0 +1,35 @@ +<script> +export default { + props: { + emojis: { + type: Array, + required: true, + }, + renderGroup: { + type: Boolean, + required: true, + }, + clickEmoji: { + type: Function, + required: true, + }, + }, +}; +</script> + +<template functional> + <div class="gl-display-flex gl-flex-wrap gl-mb-2"> + <template v-if="props.renderGroup"> + <button + v-for="emoji in props.emojis" + :key="emoji" + type="button" + class="gl-border-0 gl-bg-transparent gl-px-0 gl-py-2 gl-text-center emoji-picker-emoji" + data-testid="emoji-button" + @click="props.clickEmoji(emoji)" + > + <gl-emoji :data-name="emoji" /> + </button> + </template> + </div> +</template> diff --git a/app/assets/javascripts/emoji/components/emoji_list.vue b/app/assets/javascripts/emoji/components/emoji_list.vue new file mode 100644 index 00000000000..0d73d751c6d --- /dev/null +++ b/app/assets/javascripts/emoji/components/emoji_list.vue @@ -0,0 +1,44 @@ +<script> +import { chunk } from 'lodash'; +import { searchEmoji } from '~/emoji'; +import { EMOJIS_PER_ROW } from '../constants'; +import { getEmojiCategories, generateCategoryHeight } from './utils'; + +export default { + props: { + searchValue: { + type: String, + required: true, + }, + }, + data() { + return { render: false }; + }, + computed: { + filteredCategories() { + if (this.searchValue !== '') { + const emojis = chunk( + searchEmoji(this.searchValue).map(({ emoji }) => emoji.name), + EMOJIS_PER_ROW, + ); + + return { + search: { emojis, height: generateCategoryHeight(emojis.length) }, + }; + } + + return this.categories; + }, + }, + async mounted() { + this.categories = await getEmojiCategories(); + this.render = true; + }, +}; +</script> + +<template> + <div v-if="render"> + <slot :filtered-categories="filteredCategories"></slot> + </div> +</template> diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue new file mode 100644 index 00000000000..7cd20d82329 --- /dev/null +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -0,0 +1,121 @@ +<script> +import { GlIcon, GlDropdown, GlSearchBoxByType } from '@gitlab/ui'; +import VirtualList from 'vue-virtual-scroll-list'; +import { CATEGORY_NAMES } from '~/emoji'; +import { CATEGORY_ICON_MAP } from '../constants'; +import Category from './category.vue'; +import EmojiList from './emoji_list.vue'; +import { getEmojiCategories } from './utils'; + +export default { + components: { + GlIcon, + GlDropdown, + GlSearchBoxByType, + VirtualList, + Category, + EmojiList, + }, + props: { + toggleClass: { + type: [Array, String, Object], + required: false, + default: () => [], + }, + }, + data() { + return { + currentCategory: null, + searchValue: '', + }; + }, + computed: { + categoryNames() { + return CATEGORY_NAMES.map((category) => ({ + name: category, + icon: CATEGORY_ICON_MAP[category], + })); + }, + }, + methods: { + categoryAppeared(category) { + this.currentCategory = category; + }, + async scrollToCategory(categoryName) { + const categories = await getEmojiCategories(); + const { top } = categories[categoryName]; + + this.$refs.virtualScoller.setScrollTop(top); + }, + selectEmoji(name) { + this.$emit('click', name); + this.$refs.dropdown.hide(); + }, + getBoundaryElement() { + return document.querySelector('.content-wrapper') || 'scrollParent'; + }, + onSearchInput() { + this.$refs.virtualScoller.setScrollTop(0); + this.$refs.virtualScoller.forceRender(); + }, + }, +}; +</script> + +<template> + <div class="emoji-picker"> + <gl-dropdown + ref="dropdown" + :toggle-class="toggleClass" + :boundary="getBoundaryElement()" + menu-class="dropdown-extended-height" + no-flip + right + lazy + > + <template #button-content><slot name="button-content"></slot></template> + <gl-search-box-by-type + v-model="searchValue" + class="gl-mx-5! gl-mb-2!" + autofocus + debounce="500" + @input="onSearchInput" + /> + <div + v-show="!searchValue" + class="gl-display-flex gl-mx-5 gl-border-b-solid gl-border-gray-100 gl-border-b-1" + > + <button + v-for="category in categoryNames" + :key="category.name" + :class="{ + 'gl-text-black-normal! emoji-picker-category-active': category.name === currentCategory, + }" + type="button" + class="gl-border-0 gl-border-b-2 gl-border-b-solid gl-flex-fill-1 gl-text-gray-300 gl-pt-3 gl-pb-3 gl-bg-transparent emoji-picker-category-tab" + @click="scrollToCategory(category.name)" + > + <gl-icon :name="category.icon" :size="12" /> + </button> + </div> + <emoji-list :search-value="searchValue"> + <template #default="{ filteredCategories }"> + <virtual-list ref="virtualScoller" :size="258" :remain="1" :bench="2" variable> + <div + v-for="(category, categoryKey) in filteredCategories" + :key="categoryKey" + :style="{ height: category.height + 'px' }" + > + <category + :category="categoryKey" + :emojis="category.emojis" + @appear="categoryAppeared" + @click="selectEmoji" + /> + </div> + </virtual-list> + </template> + </emoji-list> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/emoji/components/utils.js b/app/assets/javascripts/emoji/components/utils.js new file mode 100644 index 00000000000..b95b56a1d6f --- /dev/null +++ b/app/assets/javascripts/emoji/components/utils.js @@ -0,0 +1,27 @@ +import { chunk, memoize } from 'lodash'; +import { initEmojiMap, getEmojiCategoryMap } from '~/emoji'; +import { EMOJIS_PER_ROW, EMOJI_ROW_HEIGHT, CATEGORY_ROW_HEIGHT } from '../constants'; + +export const generateCategoryHeight = (emojisLength) => + emojisLength * EMOJI_ROW_HEIGHT + CATEGORY_ROW_HEIGHT; + +export const getEmojiCategories = memoize(async () => { + await initEmojiMap(); + + const categories = await getEmojiCategoryMap(); + let top = 0; + + return Object.freeze( + Object.keys(categories).reduce((acc, category) => { + const emojis = chunk(categories[category], EMOJIS_PER_ROW); + const height = generateCategoryHeight(emojis.length); + const newAcc = { + ...acc, + [category]: { emojis, height, top }, + }; + top += height; + + return newAcc; + }, {}), + ); +}); diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js new file mode 100644 index 00000000000..bf73d1ca5a9 --- /dev/null +++ b/app/assets/javascripts/emoji/constants.js @@ -0,0 +1,14 @@ +export const CATEGORY_ICON_MAP = { + activity: 'dumbbell', + people: 'smiley', + nature: 'nature', + food: 'food', + travel: 'car', + objects: 'object', + symbols: 'heart', + flags: 'flag', +}; + +export const EMOJIS_PER_ROW = 9; +export const EMOJI_ROW_HEIGHT = 34; +export const CATEGORY_ROW_HEIGHT = 37; diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index d022fcbeabe..d3b658a4020 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -2,6 +2,7 @@ import { escape, minBy } from 'lodash'; import emojiAliases from 'emojis/aliases.json'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; +import { CATEGORY_ICON_MAP } from './constants'; let emojiMap = null; let validEmojiNames = null; @@ -155,19 +156,14 @@ export function sortEmoji(items) { return [...items].sort((a, b) => a.score - b.score || a.fieldValue.localeCompare(b.fieldValue)); } +export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP); + let emojiCategoryMap; export function getEmojiCategoryMap() { if (!emojiCategoryMap) { - emojiCategoryMap = { - activity: [], - people: [], - nature: [], - food: [], - travel: [], - objects: [], - symbols: [], - flags: [], - }; + emojiCategoryMap = CATEGORY_NAMES.reduce((acc, category) => { + return { ...acc, [category]: [] }; + }, {}); Object.keys(emojiMap).forEach((name) => { const emoji = emojiMap[name]; if (emojiCategoryMap[emoji.c]) { diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index ed852895f10..602639f09a6 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -85,7 +85,7 @@ export default { this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', showLoader); this.service - .getFolderContent(folder.folder_path) + .getFolderContent(folder.folder_path, folder.state) .then((response) => this.store.setfolderContent(folder, response.data.environments)) .then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false)) .catch(() => { @@ -129,7 +129,7 @@ export default { :href="newEnvironmentPath" data-testid="new-environment" category="primary" - variant="success" + variant="confirm" >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button > </div> @@ -164,7 +164,7 @@ export default { :href="newEnvironmentPath" data-testid="new-environment" category="primary" - variant="success" + variant="confirm" >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button > </div> diff --git a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js index b62fe196a6f..a76c8e445ed 100644 --- a/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_pagination_api_mixin.js @@ -16,6 +16,7 @@ export default { let params = { scope, page: '1', + nested: true, }; params = this.onChangeWithFilter(params); @@ -27,6 +28,7 @@ export default { /* URLS parameters are strings, we need to parse to match types */ let params = { page: Number(page).toString(), + nested: true, }; if (this.scope) { diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index 122c8f84a2c..dbd82a3c985 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -21,7 +21,7 @@ export default class EnvironmentsService { return axios.delete(endpoint, {}); } - getFolderContent(folderUrl) { - return axios.get(`${folderUrl}.json?per_page=${this.folderResults}`); + getFolderContent(folderUrl, scope) { + return axios.get(`${folderUrl}.json?per_page=${this.folderResults}&scope=${scope}`); } } diff --git a/app/assets/javascripts/experimentation/constants.js b/app/assets/javascripts/experimentation/constants.js new file mode 100644 index 00000000000..b7e61d43b11 --- /dev/null +++ b/app/assets/javascripts/experimentation/constants.js @@ -0,0 +1 @@ +export const TRACKING_CONTEXT_SCHEMA = 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0'; diff --git a/app/assets/javascripts/experimentation/experiment_tracking.js b/app/assets/javascripts/experimentation/experiment_tracking.js new file mode 100644 index 00000000000..c721828036e --- /dev/null +++ b/app/assets/javascripts/experimentation/experiment_tracking.js @@ -0,0 +1,24 @@ +import Tracking from '~/tracking'; +import { TRACKING_CONTEXT_SCHEMA } from './constants'; +import { getExperimentData } from './utils'; + +export default class ExperimentTracking { + constructor(experimentName, trackingArgs = {}) { + this.trackingArgs = trackingArgs; + this.data = getExperimentData(experimentName); + } + + event(action) { + if (!this.data) { + return false; + } + + return Tracking.event(document.body.dataset.page, action, { + ...this.trackingArgs, + context: { + schema: TRACKING_CONTEXT_SCHEMA, + data: this.data, + }, + }); + } +} diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js new file mode 100644 index 00000000000..d3e7800f643 --- /dev/null +++ b/app/assets/javascripts/experimentation/utils.js @@ -0,0 +1,10 @@ +// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment +import { get } from 'lodash'; + +export function getExperimentData(experimentName) { + return get(window, ['gon', 'experiment', experimentName]); +} + +export function isExperimentVariant(experimentName, variantName) { + return getExperimentData(experimentName)?.variant === variantName; +} 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 5fcca11e695..77e40039b43 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 @@ -84,6 +84,11 @@ export default { cancelActionProps() { return { text: this.$options.translations.cancelActionLabel, + attributes: [ + { + category: 'secondary', + }, + ], }; }, canRegenerateInstanceId() { @@ -120,11 +125,11 @@ export default { <template> <gl-modal :modal-id="modalId" - :action-cancel="cancelActionProps" - :action-primary="regenerateInstanceIdActionProps" - @canceled="clearState" + :action-primary="cancelActionProps" + :action-secondary="regenerateInstanceIdActionProps" + @secondary.prevent="rotateToken" @hide="clearState" - @primary.prevent="rotateToken" + @primary="clearState" > <template #modal-title> {{ $options.translations.modalTitle }} 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 b1e60066e11..e7f4b51c964 100644 --- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue +++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue @@ -86,6 +86,8 @@ export default { data-track-event="click_button" data-track-label="feature_flag_toggle" class="gl-mr-4" + :label="__('Feature flag status')" + label-position="hidden" @change="toggleActive" /> <h3 class="page-title gl-m-0">{{ title }}</h3> diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index 348b71dc1c6..115d68de5c9 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -213,7 +213,7 @@ export default { <gl-button v-if="newUserListPath" :href="newUserListPath" - variant="success" + variant="confirm" category="secondary" class="gl-mb-3" data-testid="ff-new-list-button" @@ -224,7 +224,7 @@ export default { <gl-button v-if="hasNewPath" :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath" - variant="success" + variant="confirm" data-testid="ff-new-button" @click="onNewFeatureFlagCLick" > @@ -301,7 +301,7 @@ export default { <gl-button v-if="newUserListPath" :href="newUserListPath" - variant="success" + variant="confirm" category="secondary" class="gl-mb-0 gl-mr-4" data-testid="ff-new-list-button" @@ -312,7 +312,7 @@ export default { <gl-button v-if="hasNewPath" :href="featureFlagsLimitExceeded ? '' : newFeatureFlagPath" - variant="success" + variant="confirm" data-testid="ff-new-button" @click="onNewFeatureFlagCLick" > diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index d14af53746e..d26a6bc5f6b 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,5 +1,5 @@ +import * as Sentry from '@sentry/browser'; import { escape } from 'lodash'; -import * as Sentry from '~/sentry/wrapper'; import { spriteIcon } from './lib/utils/common_utils'; const FLASH_TYPES = { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index d209a971c39..c5ea4cc92fd 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -3,11 +3,12 @@ import '~/lib/utils/jquery_at_who'; import { escape, template } from 'lodash'; import * as Emoji from '~/emoji'; import axios from '~/lib/utils/axios_utils'; -import { s__ } from '~/locale'; +import { s__, __, sprintf } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import AjaxCache from './lib/utils/ajax_cache'; import { spriteIcon } from './lib/utils/common_utils'; +import { parsePikadayDate } from './lib/utils/datetime_utility'; import glRegexp from './lib/utils/regexp'; function sanitize(str) { @@ -266,6 +267,7 @@ class GfmAutoComplete { }, // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${username}', + limit: 10, searchKey: 'search', alwaysHighlightFirst: true, skipSpecialCharacterTest: true, @@ -311,6 +313,38 @@ class GfmAutoComplete { return data; }, + sorter(query, items) { + // Disable auto-selecting the loading icon + this.setting.highlightFirst = this.setting.alwaysHighlightFirst; + if (GfmAutoComplete.isLoading(items)) { + this.setting.highlightFirst = false; + return items; + } + + if (!query) { + return items; + } + + const lowercaseQuery = query.toLowerCase(); + const members = items.slice(); + const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members; + + return members.sort((a, b) => { + if (nameOrUsernameStartsWith(a, lowercaseQuery)) { + return -1; + } + if (nameOrUsernameStartsWith(b, lowercaseQuery)) { + return 1; + } + if (nameOrUsernameIncludes(a, lowercaseQuery)) { + return -1; + } + if (nameOrUsernameIncludes(b, lowercaseQuery)) { + return 1; + } + return 0; + }); + }, }, }); } @@ -359,7 +393,7 @@ class GfmAutoComplete { displayTpl(value) { let tmpl = GfmAutoComplete.Loading.template; if (value.title != null) { - tmpl = GfmAutoComplete.Milestones.templateFunction(value.title); + tmpl = GfmAutoComplete.Milestones.templateFunction(value.title, value.expired); } return tmpl; }, @@ -367,16 +401,39 @@ class GfmAutoComplete { callbacks: { ...this.getDefaultCallbacks(), beforeSave(milestones) { - return $.map(milestones, (m) => { + const parsedMilestones = $.map(milestones, (m) => { if (m.title == null) { return m; } + + const dueDate = m.due_date ? parsePikadayDate(m.due_date) : null; + const expired = dueDate ? Date.now() > dueDate.getTime() : false; + return { id: m.iid, title: sanitize(m.title), search: m.title, + expired, + dueDate, }; }); + + // Sort milestones by due date when present. + if (typeof parsedMilestones[0] === 'object') { + return parsedMilestones.sort((mA, mB) => { + // Move all expired milestones to the bottom. + if (mA.expired) return 1; + if (mB.expired) return -1; + + // Move milestones without due dates just above expired milestones. + if (!mA.dueDate) return 1; + if (!mB.dueDate) return -1; + + // Sort by due date in ascending order. + return mA.dueDate - mB.dueDate; + }); + } + return parsedMilestones; }, }, }); @@ -772,6 +829,14 @@ GfmAutoComplete.Members = { title, )}${availabilityStatus}</small> ${icon}</li>`; }, + nameOrUsernameStartsWith(member, query) { + // `member.search` is a name:username string like `MargeSimpson msimpson` + return member.search.split(' ').some((name) => name.toLowerCase().startsWith(query)); + }, + nameOrUsernameIncludes(member, query) { + // `member.search` is a name:username string like `MargeSimpson msimpson` + return member.search.toLowerCase().includes(query); + }, }; GfmAutoComplete.Labels = { templateFunction(color, title) { @@ -792,7 +857,12 @@ GfmAutoComplete.Issues = { }; // Milestones GfmAutoComplete.Milestones = { - templateFunction(title) { + templateFunction(title, expired) { + if (expired) { + return `<li>${sprintf(__('%{milestone} (expired)'), { + milestone: escape(title), + })}</li>`; + } return `<li>${escape(title)}</li>`; }, }; diff --git a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql index 62119177887..101633ef7a7 100644 --- a/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/alert.fragment.graphql @@ -5,7 +5,11 @@ fragment AlertListItem on AlertManagementAlert { status startedAt eventCount - issueIid + issue { + iid + state + title + } assignees { nodes { name diff --git a/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql new file mode 100644 index 00000000000..1d9497d65ce --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/project_user_members_search.query.graphql @@ -0,0 +1,14 @@ +query searchProjectMembers($fullPath: ID!, $search: String) { + project(fullPath: $fullPath) { + projectMembers(search: $search) { + nodes { + user { + id + name + username + avatarUrl + } + } + } + } +} diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql index b64ceb8e2c9..aaaaf3485ad 100644 --- a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql @@ -1,9 +1,13 @@ #import "../fragments/user.fragment.graphql" -query usersSearch($search: String!) { - users(search: $search) { - nodes { - ...User +query usersSearch($search: String!, $fullPath: ID!) { + workspace: project(fullPath: $fullPath) { + users: projectMembers(search: $search) { + nodes { + user { + ...User + } + } } } } diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js index 4715bbc94f6..e64e8009a5f 100644 --- a/app/assets/javascripts/graphql_shared/utils.js +++ b/app/assets/javascripts/graphql_shared/utils.js @@ -1,3 +1,5 @@ +import { isArray } from 'lodash'; + /** * Ids generated by GraphQL endpoints are usually in the format * gid://gitlab/Environments/123. This method extracts Id number @@ -52,3 +54,35 @@ export const convertToGraphQLId = (type, id) => { * @returns {Array} */ export const convertToGraphQLIds = (type, ids) => ids.map((id) => convertToGraphQLId(type, id)); + +/** + * Ids generated by GraphQL endpoints are usually in the format + * gid://gitlab/Groups/123. This method takes an array of + * GraphQL Ids and converts them to a number. + * + * @param {Array} ids An array of GraphQL IDs + * @returns {Array} + */ +export const convertFromGraphQLIds = (ids) => { + if (!isArray(ids)) { + throw new TypeError(`ids must be an array; got ${typeof ids}`); + } + + return ids.map((id) => getIdFromGraphQLId(id)); +}; + +/** + * Ids generated by GraphQL endpoints are usually in the format + * gid://gitlab/Groups/123. This method takes an array of nodes + * and converts the `id` properties from a GraphQL Id to a number. + * + * @param {Array} nodes An array of nodes with an `id` property + * @returns {Array} + */ +export const convertNodeIdsFromGraphQLIds = (nodes) => { + if (!isArray(nodes)) { + throw new TypeError(`nodes must be an array; got ${typeof nodes}`); + } + + return nodes.map((node) => (node.id ? { ...node, id: getIdFromGraphQLId(node.id) } : node)); +}; diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index d41fa0b2410..9d46fcec09b 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -1,29 +1,36 @@ <script> -/* eslint-disable vue/no-v-html */ -import { GlLoadingIcon, GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import { visitUrl } from '../../lib/utils/url_utility'; -import identicon from '../../vue_shared/components/identicon.vue'; +import { + GlLoadingIcon, + GlBadge, + GlIcon, + GlTooltipDirective, + GlSafeHtmlDirective, +} from '@gitlab/ui'; +import { visitUrl } from '~/lib/utils/url_utility'; +import identicon from '~/vue_shared/components/identicon.vue'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants'; import eventHub from '../event_hub'; import itemActions from './item_actions.vue'; import itemCaret from './item_caret.vue'; import itemStats from './item_stats.vue'; -import itemStatsValue from './item_stats_value.vue'; import itemTypeIcon from './item_type_icon.vue'; export default { directives: { GlTooltip: GlTooltipDirective, + SafeHtml: GlSafeHtmlDirective, }, components: { GlBadge, GlLoadingIcon, + GlIcon, + UserAccessRoleBadge, identicon, itemCaret, itemTypeIcon, itemStats, - itemStatsValue, itemActions, }, props: { @@ -91,6 +98,7 @@ export default { } }, }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, }; </script> @@ -140,28 +148,31 @@ export default { data-testid="group-name" :href="group.relativePath" :title="group.fullName" - class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!" + class="no-expand gl-mr-3 gl-mt-3 gl-text-gray-900!" :itemprop="microdata.nameItemprop" - >{{ + > + {{ // ending bracket must be by closing tag to prevent // link hover text-decoration from over-extending group.name - }}</a - > - <item-stats-value - :icon-name="visibilityIcon" + }} + </a> + <gl-icon + v-gl-tooltip.hover.bottom + class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-mt-3 gl-text-gray-500" + :name="visibilityIcon" :title="visibilityTooltip" - css-class="item-visibility d-inline-flex align-items-center gl-mt-3 gl-mr-2 text-secondary" + data-testid="group-visibility-icon" /> - <span v-if="group.permission" class="user-access-role gl-mt-3"> + <user-access-role-badge v-if="group.permission" class="gl-mt-3"> {{ group.permission }} - </span> + </user-access-role-badge> </div> <div v-if="group.description" class="description"> <span + v-safe-html:[$options.safeHtmlConfig]="group.description" :itemprop="microdata.descriptionItemprop" data-testid="group-description" - v-html="group.description" > </span> </div> diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue index 81c5e3ce85d..747cea6a46e 100644 --- a/app/assets/javascripts/groups/components/invite_members_banner.vue +++ b/app/assets/javascripts/groups/components/invite_members_banner.vue @@ -35,7 +35,9 @@ export default { this.track(this.$options.dismissEvent); }, trackOnShow() { - if (!this.isDismissed) this.track(this.$options.displayEvent); + this.$nextTick(() => { + if (!this.isDismissed) this.track(this.$options.displayEvent); + }); }, addTrackingAttributesToButton() { if (this.$refs.banner === undefined) return; diff --git a/app/assets/javascripts/groups/components/item_caret.vue b/app/assets/javascripts/groups/components/item_caret.vue index e23b0fa7413..9c379d7bf9b 100644 --- a/app/assets/javascripts/groups/components/item_caret.vue +++ b/app/assets/javascripts/groups/components/item_caret.vue @@ -21,5 +21,7 @@ export default { </script> <template> - <span class="folder-caret gl-mr-2"> <gl-icon :size="10" :name="iconClass" /> </span> + <span class="folder-caret gl-mr-2"> + <gl-icon :size="10" :name="iconClass" use-deprecated-sizes /> + </span> </template> diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index da2890f91fc..93fbbf07ae2 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -2,30 +2,8 @@ import $ from 'jquery'; import { escape } from 'lodash'; import { __ } from '~/locale'; import Api from './api'; -import axios from './lib/utils/axios_utils'; -import { normalizeHeaders } from './lib/utils/common_utils'; import { loadCSSFile } from './lib/utils/css_utils'; - -const fetchGroups = (params) => { - axios[params.type.toLowerCase()](params.url, { - params: params.data, - }) - .then((res) => { - const results = res.data || []; - const headers = normalizeHeaders(res.headers); - const currentPage = parseInt(headers['X-PAGE'], 10) || 0; - const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0; - const more = currentPage < totalPages; - - params.success({ - results, - pagination: { - more, - }, - }); - }) - .catch(params.error); -}; +import { select2AxiosTransport } from './lib/utils/select2_utils'; const groupsSelect = () => { loadCSSFile(gon.select2_css_path) @@ -51,9 +29,7 @@ const groupsSelect = () => { url: Api.buildUrl(groupsPath), dataType: 'json', quietMillis: 250, - transport(params) { - fetchGroups(params); - }, + transport: select2AxiosTransport, data(search, page) { return { search, @@ -63,8 +39,6 @@ const groupsSelect = () => { }; }, results(data, page) { - if (data.length) return { results: [] }; - const groups = data.length ? data : data.results || []; const more = data.pagination ? data.pagination.more : false; const results = groups.filter((group) => skipGroups.indexOf(group.id) === -1); diff --git a/app/assets/javascripts/helpers/cve_id_request_helper.js b/app/assets/javascripts/helpers/cve_id_request_helper.js new file mode 100644 index 00000000000..71d3fd4c4fe --- /dev/null +++ b/app/assets/javascripts/helpers/cve_id_request_helper.js @@ -0,0 +1,50 @@ +export function createCveIdRequestIssueBody(fullPath, iid) { + return `### Vulnerability Submission + +**NOTE:** Only maintainers of GitLab-hosted projects may request a CVE for +a vulnerability within their project. + +Project issue: ${fullPath}#${iid} + +#### Publishing Schedule + +After a CVE request is validated, a CVE identifier will be assigned. On what +schedule should the details of the CVE be published? + +* [ ] Publish immediately +* [ ] Wait to publish + +<!-- +Please fill out the yaml codeblock below +--> + +\`\`\`yaml +reporter: + name: "TODO" # "First Last" + email: "TODO" # "email@domain.tld" +vulnerability: + description: "TODO" # "[VULNTYPE] in [COMPONENT] in [VENDOR][PRODUCT] [VERSION] allows [ATTACKER] to [IMPACT] via [VECTOR]" + cwe: "TODO" # "CWE-22" # Path Traversal + product: + gitlab_path: "${fullPath}" + vendor: "TODO" # "Deluxe Sandwich Maker Company" + name: "TODO" # "Deluxe Sandwich Maker 2" + affected_versions: + - "TODO" # "1.2.3" + - "TODO" # ">1.3.0, <=1.3.9" + fixed_versions: + - "TODO" # "1.2.4" + - "TODO" # "1.3.10" + impact: "TODO" # "CVSS v3 string" # https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator + solution: "TODO" # "Upgrade to version 1.2.4 or 1.3.10" + credit: "TODO" + references: + - "TODO" # "https://some.domain.tld/a/reference" +\`\`\` + +CVSS scores can be computed by means of the [NVD CVSS Calculator](https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator). + +/relate ${fullPath}#${iid} +/label ~"devops::secure" ~"group::vulnerability research" ~"vulnerability research::cve" ~"advisory::queued" + `; +} diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue index 2fe435b92ab..35e2f99cb6a 100644 --- a/app/assets/javascripts/ide/components/branches/item.vue +++ b/app/assets/javascripts/ide/components/branches/item.vue @@ -34,7 +34,7 @@ export default { <template> <a :href="branchHref" class="btn-link d-flex align-items-center"> <span class="d-flex gl-mr-3 ide-search-list-current-icon"> - <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" /> + <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes /> </span> <span> <strong> {{ item.name }} </strong> diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue index 1ae7cf9339d..5e93b7c1bbb 100644 --- a/app/assets/javascripts/ide/components/branches/search_list.vue +++ b/app/assets/javascripts/ide/components/branches/search_list.vue @@ -57,7 +57,10 @@ export default { <template> <div> - <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block position-relative" @click.stop> + <label + class="dropdown-input gl-pt-3 gl-pb-5 gl-mb-0 gl-border-b-1 gl-border-b-solid gl-display-block" + @click.stop + > <input ref="searchInput" v-model="search" @@ -66,7 +69,7 @@ export default { class="form-control dropdown-input-field" @input="searchBranches" /> - <gl-icon :size="18" name="search" class="ml-3 input-icon" /> + <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes /> </label> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <gl-loading-icon diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue index bd4c4f18141..0803925104d 100644 --- a/app/assets/javascripts/ide/components/file_templates/bar.vue +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -49,7 +49,9 @@ export default { </script> <template> - <div class="d-flex align-items-center ide-file-templates qa-file-templates-bar"> + <div + class="d-flex align-items-center ide-file-templates qa-file-templates-bar gl-relative gl-z-index-1" + > <strong class="gl-mr-3"> {{ __('File templates') }} </strong> <dropdown :data="templateTypes" diff --git a/app/assets/javascripts/ide/components/merge_requests/item.vue b/app/assets/javascripts/ide/components/merge_requests/item.vue index 7aa9a4f864a..639937481f3 100644 --- a/app/assets/javascripts/ide/components/merge_requests/item.vue +++ b/app/assets/javascripts/ide/components/merge_requests/item.vue @@ -41,7 +41,7 @@ export default { <template> <a :href="mergeRequestHref" class="btn-link d-flex align-items-center"> <span class="d-flex gl-mr-3 ide-search-list-current-icon"> - <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" /> + <gl-icon v-if="isActive" :size="18" name="mobile-issue-close" use-deprecated-sizes /> </span> <span> <strong> {{ item.title }} </strong> diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue index 680e8841a1f..f7cfe80df5c 100644 --- a/app/assets/javascripts/ide/components/merge_requests/list.vue +++ b/app/assets/javascripts/ide/components/merge_requests/list.vue @@ -75,7 +75,10 @@ export default { <template> <div> - <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block" @click.stop> + <label + class="dropdown-input gl-pt-3 gl-pb-5 gl-mb-0 gl-border-b-1 gl-border-b-solid gl-display-block" + @click.stop + > <tokened-input v-model="search" :tokens="searchTokens" @@ -84,7 +87,7 @@ export default { @input="searchMergeRequests" @removeToken="setSearchType(null)" /> - <gl-icon :size="18" name="search" class="ml-3 input-icon" /> + <gl-icon :size="18" name="search" class="ml-3 input-icon" use-deprecated-sizes /> </label> <div class="dropdown-content ide-merge-requests-dropdown-content d-flex"> <gl-loading-icon @@ -102,7 +105,7 @@ export default { @click.stop="setSearchType(searchType)" > <span class="d-flex gl-mr-3 ide-search-list-current-icon"> - <gl-icon :size="18" name="search" /> + <gl-icon :size="18" name="search" use-deprecated-sizes /> </span> <span>{{ searchType.label }}</span> </button> diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue index 62bb4841760..98f0504298b 100644 --- a/app/assets/javascripts/ide/components/nav_form.vue +++ b/app/assets/javascripts/ide/components/nav_form.vue @@ -1,13 +1,12 @@ <script> -import Tab from '~/vue_shared/components/tabs/tab.vue'; -import Tabs from '~/vue_shared/components/tabs/tabs'; +import { GlTab, GlTabs } from '@gitlab/ui'; import BranchesSearchList from './branches/search_list.vue'; import MergeRequestSearchList from './merge_requests/list.vue'; export default { components: { - Tabs, - Tab, + GlTab, + GlTabs, BranchesSearchList, MergeRequestSearchList, }, @@ -23,20 +22,14 @@ export default { <template> <div class="ide-nav-form p-0"> - <tabs v-if="showMergeRequests" stop-propagation> - <tab active> - <template #title> - {{ __('Branches') }} - </template> + <gl-tabs v-if="showMergeRequests"> + <gl-tab :title="__('Branches')"> <branches-search-list /> - </tab> - <tab> - <template #title> - {{ __('Merge Requests') }} - </template> + </gl-tab> + <gl-tab :title="__('Merge Requests')"> <merge-request-search-list /> - </tab> - </tabs> + </gl-tab> + </gl-tabs> <branches-search-list v-else /> </div> </template> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 2526db0cd7b..907ac496982 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -32,7 +32,7 @@ export default { SafeHtml, }, computed: { - ...mapState(['pipelinesEmptyStateSvgPath', 'links']), + ...mapState(['pipelinesEmptyStateSvgPath']), ...mapGetters(['currentProject']), ...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']), ...mapState('pipelines', [ @@ -85,7 +85,6 @@ export default { </header> <empty-state v-if="!latestPipeline" - :help-page-path="links.ciHelpPagePath" :empty-state-svg-path="pipelinesEmptyStateSvgPath" :can-set-ci="true" class="mb-auto mt-auto" diff --git a/app/assets/javascripts/ide/components/preview/navigator.vue b/app/assets/javascripts/ide/components/preview/navigator.vue index 0c6cb041095..4d35e946d89 100644 --- a/app/assets/javascripts/ide/components/preview/navigator.vue +++ b/app/assets/javascripts/ide/components/preview/navigator.vue @@ -117,7 +117,7 @@ export default { class="ide-navigator-btn d-flex align-items-center d-transparent border-0 bg-transparent" @click="refresh" > - <gl-icon :size="18" name="retry" class="m-auto" /> + <gl-icon :size="18" name="retry" use-deprecated-sizes class="m-auto" /> </button> <div class="position-relative w-100 gl-ml-2"> <input diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 690060f5cb0..b57dcd4276c 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,6 +1,15 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; +import { + EDITOR_TYPE_DIFF, + EDITOR_CODE_INSTANCE_FN, + EDITOR_DIFF_INSTANCE_FN, +} from '~/editor/constants'; +import EditorLite from '~/editor/editor_lite'; +import { EditorWebIdeExtension } from '~/editor/extensions/editor_lite_webide_ext'; import { deprecatedCreateFlash as flash } from '~/flash'; +import ModelManager from '~/ide/lib/common/model_manager'; +import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options'; import { __ } from '~/locale'; import { WEBIDE_MARK_FILE_CLICKED, @@ -20,7 +29,6 @@ import { FILE_VIEW_MODE_PREVIEW, } from '../constants'; import eventHub from '../eventhub'; -import Editor from '../lib/editor'; import { getRulesWithTraversal } from '../lib/editorconfig/parser'; import mapRulesToMonaco from '../lib/editorconfig/rules_mapper'; import { getFileEditorOrDefault } from '../stores/modules/editor/utils'; @@ -46,6 +54,9 @@ export default { content: '', images: {}, rules: {}, + globalEditor: null, + modelManager: new ModelManager(), + isEditorLoading: true, }; }, computed: { @@ -132,6 +143,7 @@ export default { // Compare key to allow for files opened in review mode to be cached differently if (oldVal.key !== this.file.key) { + this.isEditorLoading = true; this.initEditor(); if (this.currentActivityView !== leftSidebarViews.edit.name) { @@ -149,6 +161,7 @@ export default { } }, viewer() { + this.isEditorLoading = false; if (!this.file.pending) { this.createEditorInstance(); } @@ -181,11 +194,11 @@ export default { }, }, beforeDestroy() { - this.editor.dispose(); + this.globalEditor.dispose(); }, mounted() { - if (!this.editor) { - this.editor = Editor.create(this.$store, this.editorOptions); + if (!this.globalEditor) { + this.globalEditor = new EditorLite(); } this.initEditor(); @@ -211,8 +224,6 @@ export default { return; } - this.editor.clearEditor(); - this.registerSchemaForFile(); Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()]) @@ -251,20 +262,45 @@ export default { return; } - this.editor.dispose(); + const isDiff = this.viewer !== viewerTypes.edit; + const shouldDisposeEditor = isDiff !== (this.editor?.getEditorType() === EDITOR_TYPE_DIFF); - this.$nextTick(() => { - if (this.viewer === viewerTypes.edit) { - this.editor.createInstance(this.$refs.editor); - } else { - this.editor.createDiffInstance(this.$refs.editor); + if (this.editor && !shouldDisposeEditor) { + this.setupEditor(); + } else { + if (this.editor && shouldDisposeEditor) { + this.editor.dispose(); } + const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions; + const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN; - this.setupEditor(); - }); + this.editor = this.globalEditor[method]({ + el: this.$refs.editor, + blobPath: this.file.path, + blobGlobalId: this.file.key, + blobContent: this.content || this.file.content, + ...instanceOptions, + ...this.editorOptions, + }); + + this.editor.use( + new EditorWebIdeExtension({ + instance: this.editor, + modelManager: this.modelManager, + store: this.$store, + file: this.file, + options: this.editorOptions, + }), + ); + + this.$nextTick(() => { + this.setupEditor(); + }); + } }, + setupEditor() { - if (!this.file || !this.editor.instance || this.file.loading) return; + if (!this.file || !this.editor || this.file.loading) return; const head = this.getStagedFile(this.file.path); @@ -279,6 +315,8 @@ export default { this.editor.attachModel(this.model); } + this.isEditorLoading = false; + this.model.updateOptions(this.rules); this.model.onChange((model) => { @@ -298,7 +336,7 @@ export default { }); }); - this.editor.setPosition({ + this.editor.setPos({ lineNumber: this.fileEditor.editorRow, column: this.fileEditor.editorColumn, }); @@ -308,6 +346,10 @@ export default { fileLanguage: this.model.language, }); + this.$nextTick(() => { + this.editor.updateDimensions(); + }); + this.$emit('editorSetup'); if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) { eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION); @@ -344,7 +386,7 @@ export default { }); }, onPaste(event) { - const editor = this.editor.instance; + const { editor } = this; const reImage = /^image\/(png|jpg|jpeg|gif)$/; const file = event.clipboardData.files[0]; @@ -395,6 +437,7 @@ export default { <a href="javascript:void(0);" role="button" + data-testid="edit-tab" @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })" > {{ __('Edit') }} @@ -404,6 +447,7 @@ export default { <a href="javascript:void(0);" role="button" + data-testid="preview-tab" @click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })" >{{ previewMode.previewTitle }}</a > @@ -414,6 +458,7 @@ export default { <div v-show="showEditor" ref="editor" + :key="`content-editor`" :class="{ 'is-readonly': isCommitModeActive, 'is-deleted': file.deleted, @@ -421,6 +466,8 @@ export default { }" class="multi-file-editor-holder" data-qa-selector="editor_container" + data-testid="editor-container" + :data-editor-loading="isEditorLoading" @focusout="triggerFilesChange" ></div> <content-viewer diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue index d28751c9571..64ec2cc67c7 100644 --- a/app/assets/javascripts/ide/components/repo_tab.vue +++ b/app/assets/javascripts/ide/components/repo_tab.vue @@ -1,5 +1,5 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTab } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import { __, sprintf } from '~/locale'; @@ -13,6 +13,7 @@ export default { FileIcon, GlIcon, ChangedFileIcon, + GlTab, }, props: { tab: { @@ -71,29 +72,30 @@ export default { </script> <template> - <li - :class="{ - active: tab.active, - disabled: tab.pending, - }" + <gl-tab + :active="tab.active" + :disabled="tab.pending" + :title="tab.name" @click="clickFile(tab)" @mouseover="mouseOverTab" @mouseout="mouseOutTab" > - <div :title="getUrlForPath(tab.path)" class="multi-file-tab"> - <file-icon :file-name="tab.name" :size="16" /> - {{ tab.name }} - <file-status-icon :file="tab" /> - </div> - <button - :aria-label="closeLabel" - :disabled="tab.pending" - type="button" - class="multi-file-tab-close" - @click.stop.prevent="closeFile(tab)" - > - <gl-icon v-if="!showChangedIcon" :size="12" name="close" /> - <changed-file-icon v-else :file="tab" /> - </button> - </li> + <template #title> + <div :title="getUrlForPath(tab.path)" class="multi-file-tab"> + <file-icon :file-name="tab.name" :size="16" /> + {{ tab.name }} + <file-status-icon :file="tab" /> + </div> + <button + :aria-label="closeLabel" + :disabled="tab.pending" + type="button" + class="multi-file-tab-close" + @click.stop.prevent="closeFile(tab)" + > + <gl-icon v-if="!showChangedIcon" :size="12" name="close" /> + <changed-file-icon v-else :file="tab" /> + </button> + </template> + </gl-tab> </template> diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue index c03694e3619..932040c7fa5 100644 --- a/app/assets/javascripts/ide/components/repo_tabs.vue +++ b/app/assets/javascripts/ide/components/repo_tabs.vue @@ -1,10 +1,12 @@ <script> +import { GlTabs } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import RepoTab from './repo_tab.vue'; export default { components: { RepoTab, + GlTabs, }, props: { activeFile: { @@ -42,8 +44,8 @@ export default { <template> <div class="multi-file-tabs"> - <ul ref="tabsScroller" class="list-unstyled gl-mb-0"> + <gl-tabs> <repo-tab v-for="tab in files" :key="tab.key" :tab="tab" /> - </ul> + </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/ide/components/shared/tokened_input.vue b/app/assets/javascripts/ide/components/shared/tokened_input.vue index e7a4c5487d1..ed0dab47947 100644 --- a/app/assets/javascripts/ide/components/shared/tokened_input.vue +++ b/app/assets/javascripts/ide/components/shared/tokened_input.vue @@ -81,7 +81,9 @@ export default { > <div class="value-container rounded"> <div class="value">{{ token.label }}</div> - <div class="remove-token inverted"><gl-icon :size="10" name="close" /></div> + <div class="remove-token inverted"> + <gl-icon :size="10" name="close" use-deprecated-sizes /> + </div> </div> </button> </div> diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 1b4b59ef62f..f4a0f324e4a 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -53,7 +53,6 @@ export function initIde(el, options = {}) { promotionSvgPath: el.dataset.promotionSvgPath, }); this.setLinks({ - ciHelpPagePath: el.dataset.ciHelpPagePath, webIDEHelpPagePath: el.dataset.webIdeHelpPagePath, }); this.setInitialData({ diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index 9e3347a657f..8df51ef7f9b 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -34,7 +34,7 @@ export default { </script> <template> - <div> + <div class="gl-display-flex gl-h-7 gl-align-items-center"> <gl-loading-icon v-if="mappedStatus.loadingIcon" :inline="true" diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js index ad33ca158d2..c2f398cb8a8 100644 --- a/app/assets/javascripts/import_entities/constants.js +++ b/app/assets/javascripts/import_entities/constants.js @@ -7,6 +7,7 @@ export const STATUSES = { FINISHED: 'finished', FAILED: 'failed', SCHEDULED: 'scheduled', + CREATED: 'created', STARTED: 'started', NONE: 'none', SCHEDULING: 'scheduling', @@ -23,6 +24,11 @@ const STATUS_MAP = { text: __('Failed'), textClass: 'text-danger', }, + [STATUSES.CREATED]: { + icon: 'pending', + text: __('Scheduled'), + textClass: 'text-warning', + }, [STATUSES.SCHEDULED]: { icon: 'pending', text: __('Scheduled'), diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 7c5f48dcafc..f337520b0db 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -1,13 +1,15 @@ <script> import { GlEmptyState, + GlDropdown, + GlDropdownItem, GlIcon, GlLink, GlLoadingIcon, GlSearchBoxByClick, GlSprintf, } from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { s__, __ } from '~/locale'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql'; import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql'; @@ -16,9 +18,14 @@ import availableNamespacesQuery from '../graphql/queries/available_namespaces.qu import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql'; import ImportTableRow from './import_table_row.vue'; +const PAGE_SIZES = [20, 50, 100]; +const DEFAULT_PAGE_SIZE = PAGE_SIZES[0]; + export default { components: { GlEmptyState, + GlDropdown, + GlDropdownItem, GlIcon, GlLink, GlLoadingIcon, @@ -33,12 +40,17 @@ export default { type: String, required: true, }, + groupPathRegex: { + type: RegExp, + required: true, + }, }, data() { return { filter: '', page: 1, + perPage: DEFAULT_PAGE_SIZE, }; }, @@ -46,13 +58,17 @@ export default { bulkImportSourceGroups: { query: bulkImportSourceGroupsQuery, variables() { - return { page: this.page, filter: this.filter }; + return { page: this.page, filter: this.filter, perPage: this.perPage }; }, }, availableNamespaces: availableNamespacesQuery, }, computed: { + humanizedTotal() { + return this.paginationInfo.total >= 1000 ? __('1000+') : this.paginationInfo.total; + }, + hasGroups() { return this.bulkImportSourceGroups?.nodes?.length > 0; }, @@ -113,14 +129,20 @@ export default { variables: { sourceGroupId }, }); }, + + setPageSize(size) { + this.perPage = size; + }, }, + + PAGE_SIZES, }; </script> <template> <div> <div - class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex gl-align-items-center" + class="gl-py-5 gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1 gl-display-flex" > <span> <gl-sprintf v-if="!$apollo.loading && hasGroups" :message="statusMessage"> @@ -147,12 +169,17 @@ export default { </div> <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" /> <template v-else> - <gl-empty-state v-if="hasEmptyFilter" :title="__('Sorry, your filter produced no results')" /> + <gl-empty-state + v-if="hasEmptyFilter" + :title="__('Sorry, your filter produced no results')" + :description="__('To widen your search, change or remove filters above.')" + /> <gl-empty-state v-else-if="!hasGroups" - :title="s__('BulkImport|No groups available for import')" + :title="s__('BulkImport|You have no groups to import')" + :description="s__('Check your source instance permissions.')" /> - <div v-else class="gl-display-flex gl-flex-direction-column gl-align-items-center"> + <template v-else> <table class="gl-w-full"> <thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1"> <th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th> @@ -160,12 +187,13 @@ export default { <th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th> <th class="gl-py-4 import-jobs-cta-col"></th> </thead> - <tbody> + <tbody class="gl-vertical-align-top"> <template v-for="group in bulkImportSourceGroups.nodes"> <import-table-row :key="group.id" :group="group" :available-namespaces="availableNamespaces" + :group-path-regex="groupPathRegex" @update-target-namespace="updateTargetNamespace(group.id, $event)" @update-new-name="updateNewName(group.id, $event)" @import-group="importGroup(group.id)" @@ -173,12 +201,50 @@ export default { </template> </tbody> </table> - <pagination-links - :change="setPage" - :page-info="bulkImportSourceGroups.pageInfo" - class="gl-mt-3" - /> - </div> + <div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center"> + <pagination-links + :change="setPage" + :page-info="bulkImportSourceGroups.pageInfo" + class="gl-m-0" + /> + <gl-dropdown category="tertiary" class="gl-ml-auto"> + <template #button-content> + <span class="font-weight-bold"> + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ perPage }} + </template> + </gl-sprintf> + </span> + <gl-icon class="gl-button-icon dropdown-chevron" name="chevron-down" /> + </template> + <gl-dropdown-item + v-for="size in $options.PAGE_SIZES" + :key="size" + @click="setPageSize(size)" + > + <gl-sprintf :message="__('%{count} items per page')"> + <template #count> + {{ size }} + </template> + </gl-sprintf> + </gl-dropdown-item> + </gl-dropdown> + <div class="gl-ml-2"> + <gl-sprintf :message="s__('BulkImport|Showing %{start}-%{end} of %{total}')"> + <template #start> + {{ paginationInfo.start }} + </template> + <template #end> + {{ paginationInfo.end }} + </template> + <template #total> + {{ humanizedTotal }} + </template> + </gl-sprintf> + </div> + </div> + </template> </template> </div> </template> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue index 1707ab10c89..aed879e75fb 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue @@ -1,15 +1,29 @@ <script> -import { GlButton, GlIcon, GlLink, GlFormInput } from '@gitlab/ui'; +import { + GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, + GlIcon, + GlLink, + GlFormInput, +} from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; -import Select2Select from '~/vue_shared/components/select2_select.vue'; import ImportStatus from '../../components/import_status.vue'; import { STATUSES } from '../../constants'; +import groupQuery from '../graphql/queries/group.query.graphql'; + +const DEBOUNCE_INTERVAL = 300; export default { components: { - Select2Select, ImportStatus, GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlDropdownSectionHeader, GlLink, GlIcon, GlFormInput, @@ -23,9 +37,41 @@ export default { type: Array, required: true, }, + groupPathRegex: { + type: RegExp, + required: true, + }, + }, + + apollo: { + existingGroup: { + query: groupQuery, + debounce: DEBOUNCE_INTERVAL, + variables() { + return { + fullPath: this.fullPath, + }; + }, + skip() { + return !this.isNameValid || this.isAlreadyImported; + }, + }, }, + computed: { - isDisabled() { + importTarget() { + return this.group.import_target; + }, + + isInvalid() { + return Boolean(!this.isNameValid || this.existingGroup); + }, + + isNameValid() { + return this.groupPathRegex.test(this.importTarget.new_name); + }, + + isAlreadyImported() { return this.group.status !== STATUSES.NONE; }, @@ -33,61 +79,89 @@ export default { return this.group.status === STATUSES.FINISHED; }, - select2Options() { - return { - data: this.availableNamespaces.map((namespace) => ({ - id: namespace.full_path, - text: namespace.full_path, - })), - }; - }, - }, - methods: { - getPath(group) { - return `${group.import_target.target_namespace}/${group.import_target.new_name}`; + fullPath() { + return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`; }, - getFullPath(group) { - return joinPaths(gon.relative_url_root || '/', this.getPath(group)); + absolutePath() { + return joinPaths(gon.relative_url_root || '/', this.fullPath); }, }, }; </script> <template> - <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1"> + <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid"> <td class="gl-p-4"> - <gl-link :href="group.web_url" target="_blank"> + <gl-link + :href="group.web_url" + target="_blank" + class="gl-display-flex gl-align-items-center gl-h-7" + > {{ group.full_path }} <gl-icon name="external-link" /> </gl-link> </td> <td class="gl-p-4"> - <gl-link v-if="isFinished" :href="getFullPath(group)">{{ getPath(group) }}</gl-link> + <gl-link + v-if="isFinished" + class="gl-display-flex gl-align-items-center gl-h-7" + :href="absolutePath" + > + {{ fullPath }} + </gl-link> <div v-else class="import-entities-target-select gl-display-flex gl-align-items-stretch" :class="{ - disabled: isDisabled, + disabled: isAlreadyImported, }" > - <select2-select - :disabled="isDisabled" - :options="select2Options" - :value="group.import_target.target_namespace" - @input="$emit('update-target-namespace', $event)" - /> + <gl-dropdown + :text="importTarget.target_namespace" + :disabled="isAlreadyImported" + toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!" + class="import-entities-namespace-dropdown gl-h-7 gl-flex-fill-1" + > + <gl-dropdown-item @click="$emit('update-target-namespace', '')">{{ + s__('BulkImport|No parent') + }}</gl-dropdown-item> + <template v-if="availableNamespaces.length"> + <gl-dropdown-divider /> + <gl-dropdown-section-header> + {{ s__('BulkImport|Existing groups') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="ns in availableNamespaces" + :key="ns.full_path" + @click="$emit('update-target-namespace', ns.full_path)" + > + {{ ns.full_path }} + </gl-dropdown-item> + </template> + </gl-dropdown> <div - class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" + class="import-entities-target-select-separator gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1" > / </div> - <gl-form-input - class="gl-rounded-top-left-none gl-rounded-bottom-left-none" - :disabled="isDisabled" - :value="group.import_target.new_name" - @input="$emit('update-new-name', $event)" - /> + <div class="gl-flex-fill-1"> + <gl-form-input + class="gl-rounded-top-left-none gl-rounded-bottom-left-none" + :class="{ 'is-invalid': isInvalid && !isAlreadyImported }" + :disabled="isAlreadyImported" + :value="importTarget.new_name" + @input="$emit('update-new-name', $event)" + /> + <p v-if="isInvalid" class="gl-text-red-500 gl-m-0 gl-mt-2"> + <template v-if="!isNameValid"> + {{ __('Please choose a group URL with no special characters.') }} + </template> + <template v-else-if="existingGroup"> + {{ s__('BulkImport|Name already exists.') }} + </template> + </p> + </div> </div> </td> <td class="gl-p-4 gl-white-space-nowrap"> @@ -95,7 +169,8 @@ export default { </td> <td class="gl-p-4"> <gl-button - v-if="!isDisabled" + v-if="!isAlreadyImported" + :disabled="isInvalid" variant="success" category="secondary" @click="$emit('import-group')" diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js index 8110934efc4..d444cc77aa7 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js @@ -15,52 +15,71 @@ export const clientTypenames = { BulkImportPageInfo: 'ClientBulkImportPageInfo', }; -export function createResolvers({ endpoints }) { +export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) { let statusPoller; + let sourceGroupManager; + const getGroupsManager = (client) => { + if (!sourceGroupManager) { + sourceGroupManager = new GroupsManager({ client, sourceUrl }); + } + return sourceGroupManager; + }; + return { Query: { async bulkImportSourceGroups(_, vars, { client }) { - const { - data: { availableNamespaces }, - } = await client.query({ query: availableNamespacesQuery }); - if (!statusPoller) { statusPoller = new StatusPoller({ - client, + groupManager: getGroupsManager(client), pollPath: endpoints.jobs, }); statusPoller.startPolling(); } - return axios - .get(endpoints.status, { + const groupsManager = getGroupsManager(client); + return Promise.all([ + axios.get(endpoints.status, { params: { page: vars.page, per_page: vars.perPage, filter: vars.filter, }, - }) - .then(({ headers, data }) => { + }), + client.query({ query: availableNamespacesQuery }), + ]).then( + ([ + { headers, data }, + { + data: { availableNamespaces }, + }, + ]) => { const pagination = parseIntPagination(normalizeHeaders(headers)); return { __typename: clientTypenames.BulkImportSourceGroupConnection, - nodes: data.importable_data.map((group) => ({ - __typename: clientTypenames.BulkImportSourceGroup, - ...group, - status: STATUSES.NONE, - import_target: { - new_name: group.full_path, - target_namespace: availableNamespaces[0].full_path, - }, - })), + nodes: data.importable_data.map((group) => { + const cachedImportState = groupsManager.getImportStateFromStorageByGroupId( + group.id, + ); + + return { + __typename: clientTypenames.BulkImportSourceGroup, + ...group, + status: cachedImportState?.status ?? STATUSES.NONE, + import_target: cachedImportState?.importTarget ?? { + new_name: group.full_path, + target_namespace: availableNamespaces[0]?.full_path ?? '', + }, + }; + }), pageInfo: { __typename: clientTypenames.BulkImportPageInfo, ...pagination, }, }; - }); + }, + ); }, availableNamespaces: () => @@ -73,21 +92,21 @@ export function createResolvers({ endpoints }) { }, Mutation: { setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) { - new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => { + getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => { // eslint-disable-next-line no-param-reassign sourceGroup.import_target.target_namespace = targetNamespace; }); }, setNewName(_, { newName, sourceGroupId }, { client }) { - new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => { + getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => { // eslint-disable-next-line no-param-reassign sourceGroup.import_target.new_name = newName; }); }, async importGroup(_, { sourceGroupId }, { client }) { - const groupManager = new SourceGroupsManager({ client }); + const groupManager = getGroupsManager(client); const group = groupManager.findById(sourceGroupId); groupManager.setImportStatus(group, STATUSES.SCHEDULING); try { @@ -101,13 +120,10 @@ export function createResolvers({ endpoints }) { }, ], }); - groupManager.setImportStatus(group, STATUSES.STARTED); - SourceGroupsManager.attachImportId(group, response.data.id); + groupManager.startImport({ group, importId: response.data.id }); } catch (e) { - createFlash({ - message: s__('BulkImport|Importing the group failed'), - }); - + const message = e?.response?.data?.error ?? s__('BulkImport|Importing the group failed'); + createFlash({ message }); groupManager.setImportStatus(group, STATUSES.NONE); throw e; } @@ -116,5 +132,5 @@ export function createResolvers({ endpoints }) { }; } -export const createApolloClient = ({ endpoints }) => - createDefaultClient(createResolvers({ endpoints }), { assumeImmutableResults: true }); +export const createApolloClient = ({ sourceUrl, endpoints }) => + createDefaultClient(createResolvers({ sourceUrl, endpoints }), { assumeImmutableResults: true }); diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql new file mode 100644 index 00000000000..52df3581ac4 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/group.query.graphql @@ -0,0 +1,5 @@ +query group($fullPath: ID!) { + existingGroup: group(fullPath: $fullPath) { + id + } +} diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js index 261e30edbbb..2c88d25358f 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js @@ -1,5 +1,7 @@ import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import produce from 'immer'; +import { debounce, merge } from 'lodash'; +import { STATUSES } from '../../../constants'; import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql'; function extractTypeConditionFromFragment(fragment) { @@ -13,15 +15,24 @@ function generateGroupId(id) { }); } +export const KEY = 'gl-bulk-imports-import-state'; +export const DEBOUNCE_INTERVAL = 200; + export class SourceGroupsManager { - static importMap = new Map(); + constructor({ client, sourceUrl, storage = window.localStorage }) { + this.client = client; + this.sourceUrl = sourceUrl; - static attachImportId(group, importId) { - SourceGroupsManager.importMap.set(importId, group.id); + this.storage = storage; + this.importStates = this.loadImportStatesFromStorage(); } - constructor({ client }) { - this.client = client; + loadImportStatesFromStorage() { + try { + return JSON.parse(this.storage.getItem(KEY)) ?? {}; + } catch { + return {}; + } } findById(id) { @@ -42,8 +53,48 @@ export class SourceGroupsManager { this.update(group, fn); } - findByImportId(importId) { - return this.findById(SourceGroupsManager.importMap.get(importId)); + saveImportState(importId, group) { + this.importStates[this.getStorageKey(importId)] = { + id: group.id, + importTarget: group.import_target, + status: group.status, + }; + this.saveImportStatesToStorage(); + } + + getImportStateFromStorage(importId) { + return this.importStates[this.getStorageKey(importId)]; + } + + getImportStateFromStorageByGroupId(groupId) { + const PREFIX = this.getStorageKey(''); + const [, importState] = + Object.entries(this.importStates).find( + ([key, group]) => key.startsWith(PREFIX) && group.id === groupId, + ) ?? []; + + return importState; + } + + getStorageKey(importId) { + return `${this.sourceUrl}|${importId}`; + } + + saveImportStatesToStorage = debounce(() => { + try { + // storage might be changed in other tab so fetch first + this.storage.setItem( + KEY, + JSON.stringify(merge({}, this.loadImportStatesFromStorage(), this.importStates)), + ); + } catch { + // empty catch intentional: storage might be unavailable or full + } + }, DEBOUNCE_INTERVAL); + + startImport({ group, importId }) { + this.setImportStatus(group, STATUSES.CREATED); + this.saveImportState(importId, group); } setImportStatus(group, status) { @@ -52,4 +103,22 @@ export class SourceGroupsManager { sourceGroup.status = status; }); } + + setImportStatusByImportId(importId, status) { + const importState = this.getImportStateFromStorage(importId); + if (!importState) { + return; + } + + if (importState.status !== status) { + importState.status = status; + } + + const group = this.findById(importState.id); + if (group?.id) { + this.setImportStatus(group, status); + } + + this.saveImportStatesToStorage(); + } } diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js index 63cd6b48fc4..b80a575afce 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js @@ -3,12 +3,9 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; import { s__ } from '~/locale'; -import { SourceGroupsManager } from './source_groups_manager'; export class StatusPoller { - constructor({ client, pollPath }) { - this.client = client; - + constructor({ groupManager, pollPath }) { this.eTagPoll = new Poll({ resource: { fetchJobs: () => axios.get(pollPath), @@ -29,7 +26,7 @@ export class StatusPoller { } }); - this.groupManager = new SourceGroupsManager({ client }); + this.groupManager = groupManager; } startPolling() { @@ -38,10 +35,7 @@ export class StatusPoller { async updateImportsStatuses(importStatuses) { importStatuses.forEach(({ id, status_name: statusName }) => { - const group = this.groupManager.findByImportId(id); - if (group.id) { - this.groupManager.setImportStatus(group, statusName); - } + this.groupManager.setImportStatusByImportId(id, statusName); }); } } diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js index cd837a840e4..cc60c8cbdb0 100644 --- a/app/assets/javascripts/import_entities/import_groups/index.js +++ b/app/assets/javascripts/import_entities/import_groups/index.js @@ -16,9 +16,11 @@ export function mountImportGroupsApp(mountElement) { createBulkImportPath, jobsPath, sourceUrl, + groupPathRegex, } = mountElement.dataset; const apolloProvider = new VueApollo({ defaultClient: createApolloClient({ + sourceUrl, endpoints: { status: statusPath, availableNamespaces: availableNamespacesPath, @@ -35,6 +37,7 @@ export function mountImportGroupsApp(mountElement) { return createElement(ImportTable, { props: { sourceUrl, + groupPathRegex: new RegExp(`^(${groupPathRegex})$`), }, }); }, diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js index fcac9c519c2..577d8ecb777 100644 --- a/app/assets/javascripts/incidents_settings/constants.js +++ b/app/assets/javascripts/incidents_settings/constants.js @@ -40,7 +40,7 @@ export const I18N_ALERT_SETTINGS_FORM = { label: __('Incident template (optional)'), }, sendEmail: { - label: __('Send a separate email notification to Developers.'), + label: __('Send a single email notification to Owners and Maintainers for new alerts.'), }, autoCloseIncidents: { label: __('Automatically close incidents when the associated Prometheus alert resolves.'), @@ -51,7 +51,7 @@ export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selec export const TAKING_INCIDENT_ACTION_DOCS_LINK = '/help/operations/metrics/alerts#trigger-actions-from-alerts'; export const ISSUE_TEMPLATES_DOCS_LINK = - '/help/user/project/description_templates#creating-issue-templates'; + '/help/user/project/description_templates#create-an-issue-template'; /* PagerDuty integration settings constants */ diff --git a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue index 8677f139723..1e33ceb7835 100644 --- a/app/assets/javascripts/integrations/edit/components/active_checkbox.vue +++ b/app/assets/javascripts/integrations/edit/components/active_checkbox.vue @@ -38,7 +38,7 @@ export default { <gl-form-checkbox v-model="activated" name="service[active]" - class="gl-display-block gl-line-height-0" + class="gl-display-block" :disabled="isInheriting" @change="onChange" > diff --git a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue index bcf4b036fd2..89f7e3b7a89 100644 --- a/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue +++ b/app/assets/javascripts/integrations/edit/components/confirmation_modal.vue @@ -13,7 +13,7 @@ export default { return { text: __('Save'), attributes: [ - { variant: 'success' }, + { variant: 'confirm' }, { category: 'primary' }, { disabled: this.isDisabled }, ], diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index 3ec0c23e55d..9ca660ea3ae 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -61,9 +61,6 @@ export default { this.customState.integrationLevel === integrationLevels.GROUP ); }, - showJiraIssuesFields() { - return this.isJira && this.glFeatures.jiraIssuesIntegration; - }, showReset() { return this.isInstanceOrGroupLevel && this.propsSource.resetPath; }, @@ -129,7 +126,7 @@ export default { v-bind="field" /> <jira-issues-fields - v-if="showJiraIssuesFields" + v-if="isJira" :key="`${currentKey}-jira-issues-fields`" v-bind="propsSource.jiraIssuesProps" /> @@ -138,7 +135,7 @@ export default { <gl-button v-gl-modal.confirmSaveIntegration category="primary" - variant="success" + variant="confirm" :loading="isSaving" :disabled="isDisabled" data-qa-selector="save_changes_button" @@ -150,7 +147,7 @@ export default { <gl-button v-else category="primary" - variant="success" + variant="confirm" type="submit" :loading="isSaving" :disabled="isDisabled" @@ -162,6 +159,8 @@ export default { <gl-button v-if="propsSource.canTest" + category="secondary" + variant="confirm" :loading="isTesting" :disabled="isDisabled" :href="propsSource.testPath" @@ -174,7 +173,7 @@ export default { <gl-button v-gl-modal.confirmResetIntegration category="secondary" - variant="default" + variant="confirm" :loading="isResetting" :disabled="isDisabled" data-testid="reset-button" @@ -184,9 +183,7 @@ export default { <reset-confirmation-modal @reset="onResetClick" /> </template> - <gl-button class="btn-cancel" :href="propsSource.cancelPath">{{ - __('Cancel') - }}</gl-button> + <gl-button :href="propsSource.cancelPath">{{ __('Cancel') }}</gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/invite_members/components/group_select.vue b/app/assets/javascripts/invite_members/components/group_select.vue new file mode 100644 index 00000000000..4a72e97db8c --- /dev/null +++ b/app/assets/javascripts/invite_members/components/group_select.vue @@ -0,0 +1,103 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownText, GlSearchBoxByType } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import Api from '~/api'; +import { s__ } from '~/locale'; +import { SEARCH_DELAY } from '../constants'; + +export default { + name: 'GroupSelect', + components: { + GlDropdown, + GlDropdownItem, + GlDropdownText, + GlSearchBoxByType, + }, + model: { + prop: 'selectedGroup', + }, + data() { + return { + isFetching: false, + groups: [], + selectedGroup: {}, + searchTerm: '', + }; + }, + computed: { + selectedGroupName() { + return this.selectedGroup.name || this.$options.i18n.dropdownText; + }, + isFetchResultEmpty() { + return this.groups.length === 0; + }, + }, + watch: { + searchTerm() { + this.retrieveGroups(); + }, + }, + mounted() { + this.retrieveGroups(); + }, + methods: { + retrieveGroups: debounce(function debouncedRetrieveGroups() { + this.isFetching = true; + return Api.groups(this.searchTerm, this.$options.defaultFetchOptions) + .then((response) => { + this.groups = response.map((group) => ({ + id: group.id, + name: group.full_name, + path: group.path, + })); + this.isFetching = false; + }) + .catch(() => { + this.isFetching = false; + }); + }, SEARCH_DELAY), + selectGroup(group) { + this.selectedGroup = group; + + this.$emit('input', this.selectedGroup); + }, + }, + i18n: { + dropdownText: s__('GroupSelect|Select a group'), + searchPlaceholder: s__('GroupSelect|Search groups'), + emptySearchResult: s__('GroupSelect|No matching results'), + }, + defaultFetchOptions: { + exclude_internal: true, + active: true, + }, +}; +</script> +<template> + <div> + <gl-dropdown + data-testid="group-select-dropdown" + :text="selectedGroupName" + block + menu-class="gl-w-full!" + > + <gl-search-box-by-type + v-model.trim="searchTerm" + :is-loading="isFetching" + :placeholder="$options.i18n.searchPlaceholder" + data-qa-selector="group_select_dropdown_search_field" + /> + <gl-dropdown-item + v-for="group in groups" + :key="group.id" + :name="group.name" + @click="selectGroup(group)" + > + {{ group.name }} + </gl-dropdown-item> + <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message"> + <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> + </gl-dropdown-text> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/invite_members/components/invite_group_trigger.vue b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue new file mode 100644 index 00000000000..c9de078319a --- /dev/null +++ b/app/assets/javascripts/invite_members/components/invite_group_trigger.vue @@ -0,0 +1,34 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlButton, + }, + props: { + displayText: { + type: String, + required: false, + default: s__('InviteMembers|Invite a group'), + }, + classes: { + type: String, + required: false, + default: '', + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal', { inviteeType: 'group' }); + }, + }, +}; +</script> + +<template> + <gl-button :class="classes" data-qa-selector="invite_a_group_button" @click="openModal"> + {{ displayText }} + </gl-button> +</template> 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 f5a65882fba..47f1405c980 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -11,9 +11,10 @@ import { } from '@gitlab/ui'; import { partition, isString } from 'lodash'; import Api from '~/api'; +import GroupSelect from '~/invite_members/components/group_select.vue'; import MembersTokenSelect from '~/invite_members/components/members_token_select.vue'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; -import { s__, __, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; import eventHub from '../event_hub'; export default { @@ -28,6 +29,7 @@ export default { GlButton, GlFormInput, MembersTokenSelect, + GroupSelect, }, props: { id: { @@ -47,7 +49,7 @@ export default { required: true, }, defaultAccessLevel: { - type: String, + type: Number, required: true, }, helpLink: { @@ -60,21 +62,21 @@ export default { visible: true, modalId: 'invite-members-modal', selectedAccessLevel: this.defaultAccessLevel, + inviteeType: 'members', newUsersToInvite: [], selectedDate: undefined, + groupToBeSharedWith: {}, }; }, computed: { - inviteToName() { - return this.name.toUpperCase(); - }, - inviteToType() { - return this.isProject ? __('project') : __('group'); + isInviteGroup() { + return this.inviteeType === 'group'; }, introText() { - return sprintf(s__("InviteMembersModal|You're inviting members to the %{name} %{type}"), { - name: this.inviteToName, - type: this.inviteToType, + const inviteTo = this.isProject ? 'toProject' : 'toGroup'; + + return sprintf(this.$options.labels[this.inviteeType][inviteTo].introText, { + name: this.name, }); }, toastOptions() { @@ -82,12 +84,12 @@ export default { onComplete: () => { this.selectedAccessLevel = this.defaultAccessLevel; this.newUsersToInvite = []; + this.groupToBeSharedWith = {}; }, }; }, basePostData() { return { - access_level: this.selectedAccessLevel, expires_at: this.selectedDate, format: 'json', }; @@ -97,9 +99,16 @@ export default { (key) => this.accessLevels[key] === Number(this.selectedAccessLevel), ); }, + inviteDisabled() { + return ( + this.newUsersToInvite.length === 0 && Object.keys(this.groupToBeSharedWith).length === 0 + ); + }, }, mounted() { - eventHub.$on('openModal', this.openModal); + eventHub.$on('openModal', (options) => { + this.openModal(options); + }); }, methods: { partitionNewUsersToInvite() { @@ -113,26 +122,42 @@ export default { usersToAddById.map((user) => user.id).join(','), ]; }, - openModal() { + openModal({ inviteeType }) { + this.inviteeType = inviteeType; + this.$root.$emit(BV_SHOW_MODAL, this.modalId); }, closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, sendInvite() { - this.submitForm(); + if (this.isInviteGroup) { + this.submitShareWithGroup(); + } else { + this.submitInviteMembers(); + } this.closeModal(); }, cancelInvite() { this.selectedAccessLevel = this.defaultAccessLevel; this.selectedDate = undefined; - this.newUsersToInvite = ''; + this.newUsersToInvite = []; + this.groupToBeSharedWith = {}; this.closeModal(); }, changeSelectedItem(item) { this.selectedAccessLevel = item; }, - submitForm() { + submitShareWithGroup() { + const apiShareWithGroup = this.isProject + ? Api.projectShareWithGroup.bind(Api) + : Api.groupShareWithGroup.bind(Api); + + apiShareWithGroup(this.id, this.shareWithGroupPostData(this.groupToBeSharedWith.id)) + .then(this.showToastMessageSuccess) + .catch(this.showToastMessageError); + }, + submitInviteMembers() { const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); const promises = []; @@ -155,10 +180,25 @@ export default { Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError); }, inviteByEmailPostData(usersToInviteByEmail) { - return { ...this.basePostData, email: usersToInviteByEmail }; + return { + ...this.basePostData, + email: usersToInviteByEmail, + access_level: this.selectedAccessLevel, + }; }, addByUserIdPostData(usersToAddById) { - return { ...this.basePostData, user_id: usersToAddById }; + return { + ...this.basePostData, + user_id: usersToAddById, + access_level: this.selectedAccessLevel, + }; + }, + shareWithGroupPostData(groupToBeSharedWith) { + return { + ...this.basePostData, + group_id: groupToBeSharedWith, + group_access: this.selectedAccessLevel, + }; }, showToastMessageSuccess() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); @@ -170,9 +210,36 @@ export default { }, }, labels: { - modalTitle: s__('InviteMembersModal|Invite team members'), - newUsersToInvite: s__('InviteMembersModal|GitLab member or Email address'), - userPlaceholder: s__('InviteMembersModal|Search for members to invite'), + members: { + modalTitle: s__('InviteMembersModal|Invite team members'), + searchField: s__('InviteMembersModal|GitLab member or Email address'), + placeHolder: s__('InviteMembersModal|Search for members to invite'), + toGroup: { + introText: s__( + "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} group.", + ), + }, + toProject: { + introText: s__( + "InviteMembersModal|You're inviting members to the %{strongStart}%{name}%{strongEnd} project.", + ), + }, + }, + group: { + modalTitle: s__('InviteMembersModal|Invite a group'), + searchField: s__('InviteMembersModal|Select a group to invite'), + placeHolder: s__('InviteMembersModal|Search for a group to invite'), + toGroup: { + introText: s__( + "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} group.", + ), + }, + toProject: { + introText: s__( + "InviteMembersModal|You're inviting a group to the %{strongStart}%{name}%{strongEnd} project.", + ), + }, + }, accessLevel: s__('InviteMembersModal|Choose a role permission'), accessExpireDate: s__('InviteMembersModal|Access expiration date (optional)'), toastMessageSuccessful: s__('InviteMembersModal|Members were successfully added'), @@ -189,31 +256,45 @@ export default { <gl-modal :modal-id="modalId" size="sm" - :title="$options.labels.modalTitle" + data-qa-selector="invite_members_modal_content" + :title="$options.labels[inviteeType].modalTitle" :header-close-label="$options.labels.headerCloseLabel" > - <div class="gl-ml-5 gl-mr-5"> - <div>{{ introText }}</div> + <div> + <p ref="introText"> + <gl-sprintf :message="introText"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> <label :id="$options.membersTokenSelectLabelId" class="gl-font-weight-bold gl-mt-5">{{ - $options.labels.newUsersToInvite + $options.labels[inviteeType].searchField }}</label> <div class="gl-mt-2"> <members-token-select + v-if="!isInviteGroup" v-model="newUsersToInvite" - :label="$options.labels.newUsersToInvite" :aria-labelledby="$options.membersTokenSelectLabelId" - :placeholder="$options.labels.userPlaceholder" + :placeholder="$options.labels[inviteeType].placeHolder" /> + <group-select v-if="isInviteGroup" v-model="groupToBeSharedWith" /> </div> - <label class="gl-font-weight-bold gl-mt-5">{{ $options.labels.accessLevel }}</label> + <label class="gl-font-weight-bold gl-mt-3">{{ $options.labels.accessLevel }}</label> <div class="gl-mt-2 gl-w-half gl-xs-w-full"> - <gl-dropdown class="gl-shadow-none gl-w-full" v-bind="$attrs" :text="selectedRoleName"> + <gl-dropdown + class="gl-shadow-none gl-w-full" + data-qa-selector="access_level_dropdown" + v-bind="$attrs" + :text="selectedRoleName" + > <template v-for="(key, item) in accessLevels"> <gl-dropdown-item :key="key" active-class="is-active" + is-check-item :is-checked="key === selectedAccessLevel" @click="changeSelectedItem(key)" > @@ -223,7 +304,7 @@ export default { </gl-dropdown> </div> - <div class="gl-mt-2"> + <div class="gl-mt-2 gl-w-half gl-xs-w-full"> <gl-sprintf :message="$options.labels.readMoreText"> <template #link="{ content }"> <gl-link :href="helpLink" target="_blank">{{ content }}</gl-link> @@ -231,7 +312,7 @@ export default { </gl-sprintf> </div> - <label class="gl-font-weight-bold gl-mt-5" for="expires_at">{{ + <label class="gl-font-weight-bold gl-mt-5 gl-display-block" for="expires_at">{{ $options.labels.accessExpireDate }}</label> <div class="gl-mt-2 gl-w-half gl-xs-w-full gl-display-inline-block"> @@ -253,15 +334,16 @@ export default { </div> <template #modal-footer> - <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-p-3"> + <div class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"> <gl-button ref="cancelButton" @click="cancelInvite"> {{ $options.labels.cancelButtonText }} </gl-button> <div class="gl-mr-3"></div> <gl-button ref="inviteButton" - :disabled="!newUsersToInvite" + :disabled="inviteDisabled" variant="success" + data-qa-selector="invite_button" @click="sendInvite" >{{ $options.labels.inviteButtonText }}</gl-button > diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue index eb97c458f88..666693e934f 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue @@ -1,13 +1,10 @@ <script> -import { GlLink, GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; export default { - components: { - GlLink, - GlIcon, - }, + components: { GlButton }, props: { displayText: { type: String, @@ -24,20 +21,28 @@ export default { required: false, default: '', }, + variant: { + type: String, + required: false, + default: undefined, + }, }, methods: { openModal() { - eventHub.$emit('openModal'); + eventHub.$emit('openModal', { inviteeType: 'members' }); }, }, }; </script> <template> - <gl-link :class="classes" @click="openModal"> - <div v-if="icon" class="nav-icon-container"> - <gl-icon :size="16" :name="icon" /> - </div> - <span class="nav-item-name"> {{ displayText }} </span> - </gl-link> + <gl-button + :class="classes" + :icon="icon" + :variant="variant" + data-qa-selector="invite_members_button" + @click="openModal" + > + {{ displayText }} + </gl-button> </template> diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 233a214013b..db6a7888786 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/u import { debounce } from 'lodash'; import { __ } from '~/locale'; import { getUsers } from '~/rest_api'; -import { USER_SEARCH_DELAY } from '../constants'; +import { SEARCH_DELAY } from '../constants'; export default { components: { @@ -67,7 +67,7 @@ export default { .catch(() => { this.loading = false; }); - }, USER_SEARCH_DELAY), + }, SEARCH_DELAY), handleInput() { this.$emit('input', this.selectedTokens); }, diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js index 1ff2125c292..2044dad896f 100644 --- a/app/assets/javascripts/invite_members/constants.js +++ b/app/assets/javascripts/invite_members/constants.js @@ -1 +1 @@ -export const USER_SEARCH_DELAY = 200; +export const SEARCH_DELAY = 200; diff --git a/app/assets/javascripts/invite_members/init_invite_group_trigger.js b/app/assets/javascripts/invite_members/init_invite_group_trigger.js new file mode 100644 index 00000000000..c01bb1bae28 --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_group_trigger.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import InviteGroupTrigger from '~/invite_members/components/invite_group_trigger.vue'; + +export default function initInviteGroupTrigger() { + const el = document.querySelector('.js-invite-group-trigger'); + + if (!el) { + return false; + } + + return new Vue({ + el, + render: (createElement) => + createElement(InviteGroupTrigger, { + props: { + ...el.dataset, + }, + }), + }); +} diff --git a/app/assets/javascripts/invite_members/init_invite_members_form.js b/app/assets/javascripts/invite_members/init_invite_members_form.js new file mode 100644 index 00000000000..5f8688755ba --- /dev/null +++ b/app/assets/javascripts/invite_members/init_invite_members_form.js @@ -0,0 +1,7 @@ +import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; + +// This is only used when `invite_members_group_modal` feature flag is disabled. +// This file can be removed when `invite_members_group_modal` feature flag is removed +export default () => { + disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); +}; 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 3de99dcc546..fc77bd53ba4 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -20,6 +20,7 @@ export default function initInviteMembersModal() { ...el.dataset, isProject: parseBoolean(el.dataset.isProject), accessLevels: JSON.parse(el.dataset.accessLevels), + defaultAccessLevel: parseInt(el.dataset.defaultAccessLevel, 10), }, }), }); diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue new file mode 100644 index 00000000000..78987a5c629 --- /dev/null +++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue @@ -0,0 +1,100 @@ +<script> +import { GlButton, GlModal, GlSprintf, GlIcon } from '@gitlab/ui'; +import { ISSUABLE_TYPE } from '../constants'; + +export default { + name: 'CsvExportModal', + components: { + GlButton, + GlModal, + GlSprintf, + GlIcon, + }, + inject: { + issuableType: { + default: '', + }, + issuableCount: { + default: 0, + }, + email: { + default: '', + }, + exportCsvPath: { + default: '', + }, + }, + props: { + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + // eslint-disable-next-line @gitlab/require-i18n-strings + issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests', + }; + }, + issueableType: ISSUABLE_TYPE, +}; +</script> + +<template> + <gl-modal :modal-id="modalId" body-class="gl-p-0!" data-qa-selector="export_issuable_modal"> + <template #modal-title> + <gl-sprintf :message="__('Export %{name}')"> + <template #name>{{ issuableName }}</template> + </gl-sprintf> + </template> + <div + v-if="issuableCount > -1" + data-testid="issuable-count-note" + class="gl-justify-content-start gl-align-items-center gl-p-4 gl-border-b-solid gl-border-1 gl-border-gray-50" + > + <gl-icon name="check" class="gl-color-green-400" /> + <strong class="gl-m-3"> + <gl-sprintf + v-if="issuableType === $options.issueableType.issues" + :message="n__('1 issue selected', '%d issues selected', issuableCount)" + > + <template #issuableCount>{{ issuableCount }}</template> + </gl-sprintf> + <gl-sprintf + v-else + :message="n__('1 merge request selected', '%d merge request selected', issuableCount)" + > + <template #issuableCount>{{ issuableCount }}</template> + </gl-sprintf> + </strong> + </div> + <div class="modal-text gl-px-4 gl-py-5"> + <gl-sprintf + :message=" + __( + `The CSV export will be created in the background. Once finished, it will be sent to %{strongStart}${email}%{strongEnd} in an attachment.`, + ) + " + > + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </div> + <template #modal-footer> + <gl-button + category="primary" + variant="confirm" + :href="exportCsvPath" + data-method="post" + :data-qa-selector="`export_${issuableType}_button`" + data-track-event="click_button" + :data-track-label="`export_${issuableType}_csv`" + > + <gl-sprintf :message="__('Export %{name}')"> + <template #name>{{ issuableName }}</template> + </gl-sprintf> + </gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue new file mode 100644 index 00000000000..d75fa1e8323 --- /dev/null +++ b/app/assets/javascripts/issuable/components/csv_import_export_buttons.vue @@ -0,0 +1,87 @@ +<script> +import { + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownItem, + GlTooltipDirective, + GlModalDirective, +} from '@gitlab/ui'; +import CsvExportModal from './csv_export_modal.vue'; +import CsvImportModal from './csv_import_modal.vue'; + +export default { + name: 'CsvImportExportButtons', + components: { + GlButtonGroup, + GlButton, + GlDropdown, + GlDropdownItem, + CsvExportModal, + CsvImportModal, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + inject: { + showExportButton: { + default: false, + }, + showImportButton: { + default: false, + }, + containerClass: { + default: '', + }, + canEdit: { + default: false, + }, + projectImportJiraPath: { + default: null, + }, + }, + computed: { + exportModalId() { + return `${this.issuableType}-export-modal`; + }, + importModalId() { + return `${this.issuableType}-import-modal`; + }, + }, +}; +</script> + +<template> + <div :class="containerClass"> + <gl-button-group> + <gl-button + v-if="showExportButton" + v-gl-tooltip.hover="__('Export as CSV')" + v-gl-modal="exportModalId" + icon="export" + data-qa-selector="export_as_csv_button" + data-testid="export-csv-button" + /> + <gl-dropdown + v-if="showImportButton" + v-gl-tooltip.hover="__('Import issues')" + data-testid="import-csv-dropdown" + icon="import" + > + <gl-dropdown-item v-gl-modal="importModalId" data-testid="import-csv-link">{{ + __('Import CSV') + }}</gl-dropdown-item> + <gl-dropdown-item + v-if="canEdit" + :href="projectImportJiraPath" + data-qa-selector="import_from_jira_link" + data-testid="import-from-jira-link" + >{{ __('Import from Jira') }}</gl-dropdown-item + > + </gl-dropdown> + </gl-button-group> + <csv-export-modal v-if="showExportButton" :modal-id="exportModalId" /> + <csv-import-modal v-if="showImportButton" :modal-id="importModalId" /> + </div> +</template> diff --git a/app/assets/javascripts/issuable/components/csv_import_modal.vue b/app/assets/javascripts/issuable/components/csv_import_modal.vue new file mode 100644 index 00000000000..77fc2f31583 --- /dev/null +++ b/app/assets/javascripts/issuable/components/csv_import_modal.vue @@ -0,0 +1,86 @@ +<script> +import { GlModal, GlSprintf, GlFormGroup, GlButton } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { ISSUABLE_TYPE } from '../constants'; + +export default { + name: 'CsvImportModal', + components: { + GlModal, + GlSprintf, + GlFormGroup, + GlButton, + }, + inject: { + issuableType: { + default: '', + }, + exportCsvPath: { + default: '', + }, + importCsvIssuesPath: { + default: '', + }, + maxAttachmentSize: { + default: 0, + }, + }, + props: { + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + // eslint-disable-next-line @gitlab/require-i18n-strings + issuableName: this.issuableType === ISSUABLE_TYPE.issues ? 'issues' : 'merge requests', + }; + }, + methods: { + submitForm() { + this.$refs.form.submit(); + }, + }, + csrf, +}; +</script> + +<template> + <gl-modal :modal-id="modalId" :title="__('Import issues')"> + <form + ref="form" + :action="importCsvIssuesPath" + enctype="multipart/form-data" + method="post" + data-testid="import-csv-form" + > + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <p> + {{ + __( + "Your issues will be imported in the background. Once finished, you'll get a confirmation email.", + ) + }} + </p> + <gl-form-group :label="__('Upload CSV file')" label-for="file"> + <input id="file" type="file" name="file" accept=".csv,text/csv" /> + </gl-form-group> + <p class="text-secondary"> + {{ + __( + 'It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.', + ) + }} + <gl-sprintf :message="__('The maximum file size allowed is %{size}.')" + ><template #size>{{ maxAttachmentSize }}</template></gl-sprintf + > + </p> + </form> + <template #modal-footer> + <gl-button category="primary" variant="confirm" @click="submitForm">{{ + __('Import issues') + }}</gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/issuable/constants.js b/app/assets/javascripts/issuable/constants.js new file mode 100644 index 00000000000..9344f4a7c9a --- /dev/null +++ b/app/assets/javascripts/issuable/constants.js @@ -0,0 +1,6 @@ +export const EVENT_ISSUABLE_VUE_APP_CHANGE = 'issuable_vue_app:change'; + +export const ISSUABLE_TYPE = { + issues: 'issues', + mergeRequests: 'merge-requests', +}; diff --git a/app/assets/javascripts/issuable/init_csv_import_export_buttons.js b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js new file mode 100644 index 00000000000..e8df44fa52b --- /dev/null +++ b/app/assets/javascripts/issuable/init_csv_import_export_buttons.js @@ -0,0 +1,43 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ImportExportButtons from './components/csv_import_export_buttons.vue'; + +export default () => { + const el = document.querySelector('.js-csv-import-export-buttons'); + + if (!el) return null; + + const { + showExportButton, + showImportButton, + issuableType, + issuableCount, + email, + exportCsvPath, + importCsvIssuesPath, + containerClass, + canEdit, + projectImportJiraPath, + maxAttachmentSize, + } = el.dataset; + + return new Vue({ + el, + provide: { + showExportButton: parseBoolean(showExportButton), + showImportButton: parseBoolean(showImportButton), + issuableType, + issuableCount, + email, + exportCsvPath, + importCsvIssuesPath, + containerClass, + canEdit: parseBoolean(canEdit), + projectImportJiraPath, + maxAttachmentSize, + }, + render(h) { + return h(ImportExportButtons); + }, + }); +}; diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 1b06dffbae7..153123a005f 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -5,6 +5,7 @@ import Autosave from './autosave'; import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select'; import { loadCSSFile } from './lib/utils/css_utils'; import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility'; +import { select2AxiosTransport } from './lib/utils/select2_utils'; import { queryToObject, objectToQuery } from './lib/utils/url_utility'; import UsersSelect from './users_select'; import ZenMode from './zen_mode'; @@ -199,15 +200,16 @@ export default class IssuableForm { search, }; }, - results(data) { + results({ results }) { return { // `data` keys are translated so we can't just access them with a string based key - results: data[Object.keys(data)[0]].map((name) => ({ + results: results[Object.keys(results)[0]].map((name) => ({ id: name, text: name, })), }; }, + transport: select2AxiosTransport, }, initSelection(el, callback) { const val = el.val(); diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue index 39852eba71a..f4d2312c70d 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_item.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue @@ -34,6 +34,11 @@ export default { type: Boolean, required: true, }, + labelFilterParam: { + type: String, + required: false, + default: 'label_name', + }, showCheckbox: { type: Boolean, required: true, @@ -105,9 +110,8 @@ export default { }, labelTarget(label) { if (this.enableLabelPermalinks) { - const key = encodeURIComponent('label_name[]'); const value = encodeURIComponent(this.labelTitle(label)); - return `?${key}=${value}`; + return `?${this.labelFilterParam}[]=${value}`; } return '#'; }, 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 708e175cdb2..be60f41caaf 100644 --- a/app/assets/javascripts/issuable_list/components/issuable_list_root.vue +++ b/app/assets/javascripts/issuable_list/components/issuable_list_root.vue @@ -122,6 +122,11 @@ export default { required: false, default: true, }, + labelFilterParam: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -180,7 +185,7 @@ export default { handler(params) { if (Object.keys(params).length) { updateHistory({ - url: setUrlParams(params, window.location.href, true), + url: setUrlParams(params, window.location.href, true, false, true), title: document.title, replace: true, }); @@ -258,6 +263,7 @@ export default { :issuable-symbol="issuableSymbol" :issuable="issuable" :enable-label-permalinks="enableLabelPermalinks" + :label-filter-param="labelFilterParam" :show-checkbox="showBulkEditSidebar" :checked="issuableChecked(issuable)" @checked-input="handleIssuableCheckedInput(issuable, $event)" diff --git a/app/assets/javascripts/issuable_show/components/issuable_discussion.vue b/app/assets/javascripts/issuable_show/components/issuable_discussion.vue new file mode 100644 index 00000000000..5858af6cc51 --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_discussion.vue @@ -0,0 +1,15 @@ +<script> +export default { + name: 'IssuableDiscussion', +}; +</script> + +<template> + <section class="issuable-discussion"> + <div> + <ul class="notes main-notes-list timeline"> + <slot name="discussion"></slot> + </ul> + </div> + </section> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue index 240f35b74c8..881b565ab46 100644 --- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue +++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue @@ -2,6 +2,7 @@ import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; import IssuableBody from './issuable_body.vue'; +import IssuableDiscussion from './issuable_discussion.vue'; import IssuableHeader from './issuable_header.vue'; export default { @@ -9,6 +10,7 @@ export default { IssuableSidebar, IssuableHeader, IssuableBody, + IssuableDiscussion, }, props: { issuable: { @@ -89,6 +91,7 @@ export default { <slot name="header-actions"></slot> </template> </issuable-header> + <issuable-body :issuable="issuable" :status-badge-class="statusBadgeClass" @@ -111,6 +114,13 @@ export default { <slot name="edit-form-actions" v-bind="actionsProps"></slot> </template> </issuable-body> + + <issuable-discussion> + <template #discussion> + <slot name="discussion"></slot> + </template> + </issuable-discussion> + <issuable-sidebar @sidebar-toggle="$emit('sidebar-toggle', $event)"> <template #right-sidebar-items="sidebarProps"> <slot name="right-sidebar-items" v-bind="sidebarProps"></slot> diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 3f25682ab8b..f6eff8133a7 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { joinPaths } from '~/lib/utils/url_utility'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; import { deprecatedCreateFlash as flash } from './flash'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from './issuable/constants'; import axios from './lib/utils/axios_utils'; import { addDelimiter } from './lib/utils/text_utility'; import { __ } from './locale'; @@ -23,9 +24,13 @@ export default class Issue { } // Listen to state changes in the Vue app - document.addEventListener('issuable_vue_app:change', (event) => { + this.issuableVueAppChangeHandler = (event) => this.updateTopState(event.detail.isClosed, event.detail.data); - }); + document.addEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler); + } + + dispose() { + document.removeEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler); } /** diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index e70c18040b3..9b978483cc6 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -5,7 +5,6 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; import Poll from '~/lib/utils/poll'; import { visitUrl } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; -import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor'; import { IssuableStatus, IssuableStatusText, IssuableType } from '../constants'; import eventHub from '../event_hub'; import Service from '../services/index'; @@ -25,7 +24,6 @@ export default { formComponent, PinnedLinks, }, - mixins: [recaptchaModalImplementor], props: { endpoint: { required: true, @@ -250,6 +248,7 @@ export default { }, }, created() { + this.flashContainer = null; this.service = new Service(this.endpoint); this.poll = new Poll({ resource: this.service, @@ -289,7 +288,7 @@ export default { methods: { handleBeforeUnloadEvent(e) { const event = e; - if (this.showForm && this.issueChanged && !this.showRecaptcha) { + if (this.showForm && this.issueChanged) { event.returnValue = __('Are you sure you want to lose your issue information?'); } return undefined; @@ -307,7 +306,7 @@ export default { }); }, - updateAndShowForm(templates = []) { + updateAndShowForm(templates = {}) { if (!this.showForm) { this.showForm = true; this.store.setFormState({ @@ -347,10 +346,10 @@ export default { }, updateIssuable() { + this.clearFlash(); return this.service .updateIssuable(this.store.formState) .then((res) => res.data) - .then((data) => this.checkForSpam(data)) .then((data) => { if (!window.location.pathname.includes(data.web_url)) { visitUrl(data.web_url); @@ -361,28 +360,22 @@ export default { eventHub.$emit('close.form'); }) .catch((error = {}) => { - const { name, response = {} } = error; + const { message, response = {} } = error; - if (name === 'SpamError') { - this.openRecaptcha(); - } else { - let errMsg = this.defaultErrorMessage; + this.store.setFormState({ + updateLoading: false, + }); - if (response.data && response.data.errors) { - errMsg += `. ${response.data.errors.join(' ')}`; - } + let errMsg = this.defaultErrorMessage; - createFlash(errMsg); + if (response.data && response.data.errors) { + errMsg += `. ${response.data.errors.join(' ')}`; + } else if (message) { + errMsg += `. ${message}`; } - }); - }, - - closeRecaptchaModal() { - this.store.setFormState({ - updateLoading: false, - }); - this.closeRecaptcha(); + this.flashContainer = createFlash(errMsg); + }); }, deleteIssuable(payload) { @@ -409,6 +402,13 @@ export default { showStickyHeader() { this.isStickyHeaderShowing = true; }, + + clearFlash() { + if (this.flashContainer) { + this.flashContainer.style.display = 'none'; + this.flashContainer = null; + } + }, }, }; </script> @@ -430,13 +430,6 @@ export default { :enable-autocomplete="enableAutocomplete" :issuable-type="issuableType" /> - - <recaptcha-modal - v-show="showRecaptcha" - ref="recaptchaModal" - :html="recaptchaHTML" - @close="closeRecaptchaModal" - /> </div> <div v-else> <title-component diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 5416d3bebd0..68bc6fe4c0e 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -4,7 +4,6 @@ import $ from 'jquery'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; import TaskList from '../../task_list'; -import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; import animateMixin from '../mixins/animate'; export default { @@ -12,7 +11,7 @@ export default { SafeHtml, }, - mixins: [animateMixin, recaptchaModalImplementor], + mixins: [animateMixin], props: { canUpdate: { @@ -87,21 +86,11 @@ export default { fieldName: 'description', lockVersion: this.lockVersion, selector: '.detail-page-description', - onSuccess: this.taskListUpdateSuccess.bind(this), onError: this.taskListUpdateError.bind(this), }); } }, - taskListUpdateSuccess(data) { - try { - this.checkForSpam(data); - this.closeRecaptcha(); - } catch (error) { - if (error && error.name === 'SpamError') this.openRecaptcha(); - } - }, - taskListUpdateError() { createFlash( sprintf( @@ -165,7 +154,5 @@ export default { > </textarea> <!-- eslint-enable vue/no-mutating-props --> - - <recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" /> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue index dd378c40b46..20c759cfbbd 100644 --- a/app/assets/javascripts/issue_show/components/edit_actions.vue +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -45,15 +45,23 @@ export default { shouldShowDeleteButton() { return this.canDestroy && this.showDeleteButton; }, + deleteIssuableButtonText() { + return sprintf(__('Delete %{issuableType}'), { + issuableType: issuableTypes[this.issuableType], + }); + }, }, methods: { closeForm() { eventHub.$emit('close.form'); }, deleteIssuable() { - const confirmMessage = sprintf(__('%{issuableType} will be removed! Are you sure?'), { - issuableType: issuableTypes[this.issuableType], - }); + const confirmMessage = + this.issuableType === 'epic' + ? __('Delete this epic and all descendants?') + : sprintf(__('%{issuableType} will be removed! Are you sure?'), { + issuableType: issuableTypes[this.issuableType], + }); // eslint-disable-next-line no-alert if (window.confirm(confirmMessage)) { this.deleteLoading = true; @@ -90,7 +98,7 @@ export default { class="float-right gl-mr-3 qa-delete-button" @click="deleteIssuable" > - {{ __('Delete') }} + {{ deleteIssuableButtonText }} </gl-button> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue index dbec6f15cab..570bc7df3cf 100644 --- a/app/assets/javascripts/issue_show/components/fields/description_template.vue +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -13,9 +13,9 @@ export default { required: true, }, issuableTemplates: { - type: Array, + type: [Object, Array], required: false, - default: () => [], + default: () => {}, }, projectPath: { type: String, diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index b7425448052..76ea489fb86 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -26,9 +26,9 @@ export default { required: true, }, issuableTemplates: { - type: Array, + type: [Object, Array], required: false, - default: () => [], + default: () => {}, }, issuableType: { type: String, @@ -72,7 +72,7 @@ export default { }, computed: { hasIssuableTemplates() { - return this.issuableTemplates.length; + return Object.values(Object(this.issuableTemplates)).length; }, showLockedWarning() { return this.formState.lockedWarningVisible && !this.formState.updateLoading; diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue index 9c3988d0469..2f2c4c6e341 100644 --- a/app/assets/javascripts/issue_show/components/header_actions.vue +++ b/app/assets/javascripts/issue_show/components/header_actions.vue @@ -2,6 +2,7 @@ import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui'; import { mapActions, mapGetters, mapState } from 'vuex'; import createFlash, { FLASH_TYPES } from '~/flash'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import { IssuableType } from '~/issuable_show/constants'; import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; @@ -148,7 +149,7 @@ export default { }; // Dispatch event which updates open/close state, shared among the issue show page - document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload)); + document.dispatchEvent(new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, payload)); }) .catch(() => createFlash({ message: __('Error occurred while updating the issue status') })) .finally(() => { diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index b1deeaae0fc..08b04ebfdaf 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -1,9 +1,11 @@ +import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_interceptor'; import axios from '../../lib/utils/axios_utils'; export default class Service { constructor(endpoint) { this.endpoint = `${endpoint}.json`; this.realtimeEndpoint = `${endpoint}/realtime_changes`; + registerCaptchaModalInterceptor(axios); } getData() { diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 06bbd406e3a..a50913d3455 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -11,7 +11,7 @@ export default class Store { lockedWarningVisible: false, updateLoading: false, lock_version: 0, - issuableTemplates: [], + issuableTemplates: {}, }; } diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js index 19d1e0eebcb..f1e6bd2419a 100644 --- a/app/assets/javascripts/issue_show/utils/parse_data.js +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -1,5 +1,5 @@ +import * as Sentry from '@sentry/browser'; import { sanitize } from '~/lib/dompurify'; -import * as Sentry from '~/sentry/wrapper'; // We currently load + parse the data from the issue app and related merge request let cachedParsedData; diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue index b7af6e098e1..60b01a6d37f 100644 --- a/app/assets/javascripts/issues_list/components/issuable.vue +++ b/app/assets/javascripts/issues_list/components/issuable.vue @@ -25,16 +25,16 @@ import { newDateAsLocaleTime, } from '~/lib/utils/datetime_utility'; import { convertToCamelCase } from '~/lib/utils/text_utility'; -import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { mergeUrlParams, setUrlFragment, isExternal } from '~/lib/utils/url_utility'; import { sprintf, __ } from '~/locale'; import initUserPopovers from '~/user_popovers'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; export default { i18n: { - openedAgo: __('opened %{timeAgoString} by %{user}'), - openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'), - openedAgoServiceDesk: __('opened %{timeAgoString} by %{email} via %{user}'), + openedAgo: __('created %{timeAgoString} by %{user}'), + openedAgoJira: __('created %{timeAgoString} by %{user} in Jira'), + openedAgoServiceDesk: __('created %{timeAgoString} by %{email} via %{user}'), }, components: { IssueAssignees, @@ -102,8 +102,14 @@ export default { isJiraIssue() { return this.issuable.external_tracker === 'jira'; }, + webUrl() { + return this.issuable.gitlab_web_url || this.issuable.web_url; + }, + isIssuableUrlExternal() { + return isExternal(this.webUrl); + }, linkTarget() { - return this.isJiraIssue ? '_blank' : null; + return this.isIssuableUrlExternal ? '_blank' : null; }, issueCreatedToday() { return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1; @@ -188,7 +194,7 @@ export default { value: this.issuable.blocking_issues_count, title: __('Blocking issues'), dataTestId: 'blocking-issues', - href: `${this.issuable.web_url}#related-issues`, + href: setUrlFragment(this.webUrl, 'related-issues'), icon: 'issue-block', }, { @@ -197,7 +203,7 @@ export default { value: this.issuable.user_notes_count, title: __('Comments'), dataTestId: 'notes-count', - href: `${this.issuable.web_url}#notes`, + href: setUrlFragment(this.webUrl, 'notes'), class: { 'no-comments': !this.issuable.user_notes_count, 'issuable-comments': true }, icon: 'comments', }, @@ -252,7 +258,7 @@ export default { :class="{ today: issueCreatedToday, closed: isClosed }" :data-id="issuable.id" :data-labels="labelIdsString" - :data-url="issuable.web_url" + :data-url="webUrl" data-qa-selector="issue_container" :data-qa-issue-title="issuable.title" > @@ -284,13 +290,14 @@ export default { :aria-label="$options.confidentialTooltipText" /> <gl-link - :href="issuable.web_url" + :href="webUrl" :target="linkTarget" data-testid="issuable-title" data-qa-selector="issue_link" - >{{ issuable.title - }}<gl-icon - v-if="isJiraIssue" + > + {{ issuable.title }} + <gl-icon + v-if="isIssuableUrlExternal" name="external-link" class="gl-vertical-align-text-bottom gl-ml-2" /> diff --git a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue index 7396cfe27b3..ba0ca57523a 100644 --- a/app/assets/javascripts/issues_list/components/jira_issues_list_root.vue +++ b/app/assets/javascripts/issues_list/components/jira_issues_import_status_app.vue @@ -11,7 +11,7 @@ import { n__ } from '~/locale'; import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql'; export default { - name: 'JiraIssuesList', + name: 'JiraIssuesImportStatus', components: { GlAlert, GlLabel, @@ -89,13 +89,13 @@ export default { </script> <template> - <div class="issuable-list-root"> + <div class="gl-my-5"> <gl-alert v-if="jiraImport.shouldShowInProgressAlert" @dismiss="hideInProgressAlert"> {{ __('Import in progress. Refresh page to see newly added issues.') }} </gl-alert> <gl-alert - v-if="jiraImport.shouldShowFinishedAlert" + v-else-if="jiraImport.shouldShowFinishedAlert" variant="success" @dismiss="hideFinishedAlert" > diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js index 5c3910955bc..295d4464866 100644 --- a/app/assets/javascripts/issues_list/index.js +++ b/app/assets/javascripts/issues_list/index.js @@ -3,10 +3,10 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import IssuablesListApp from './components/issuables_list_app.vue'; -import JiraIssuesListRoot from './components/jira_issues_list_root.vue'; +import JiraIssuesImportStatusRoot from './components/jira_issues_import_status_app.vue'; function mountJiraIssuesListApp() { - const el = document.querySelector('.js-projects-issues-root'); + const el = document.querySelector('.js-jira-issues-import-status'); if (!el) { return false; @@ -23,7 +23,7 @@ function mountJiraIssuesListApp() { el, apolloProvider, render(createComponent) { - return createComponent(JiraIssuesListRoot, { + return createComponent(JiraIssuesImportStatusRoot, { props: { canEdit: parseBoolean(el.dataset.canEdit), isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured), @@ -36,7 +36,7 @@ function mountJiraIssuesListApp() { } function mountIssuablesListApp() { - if (!gon.features?.vueIssuablesList && !gon.features?.jiraIssuesIntegration) { + if (!gon.features?.vueIssuablesList) { return; } diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index a4ba86dc6a1..fe5ad8b67d7 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -1,10 +1,11 @@ <script> -import { GlAlert, GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; -import { mapState } from 'vuex'; +import { GlAlert, GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui'; +import { mapState, mapMutations } from 'vuex'; import { getLocation } from '~/jira_connect/api'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - +import { SET_ALERT } from '../store/mutation_types'; +import { retrieveAlert } from '../utils'; import GroupsList from './groups_list.vue'; export default { @@ -14,6 +15,8 @@ export default { GlButton, GlModal, GroupsList, + GlLink, + GlSprintf, }, directives: { GlModalDirective, @@ -30,10 +33,7 @@ export default { }; }, computed: { - ...mapState(['errorMessage']), - showNewUI() { - return this.glFeatures.newJiraConnectUi; - }, + ...mapState(['alert']), usersPathWithReturnTo() { if (this.location) { return `${this.usersPath}?return_to=${this.location}`; @@ -41,6 +41,9 @@ export default { return this.usersPath; }, + shouldShowAlert() { + return Boolean(this.alert?.message); + }, }, modal: { cancelProps: { @@ -48,27 +51,48 @@ export default { }, }, created() { + this.setInitialAlert(); this.setLocation(); }, methods: { + ...mapMutations({ + setAlert: SET_ALERT, + }), async setLocation() { this.location = await getLocation(); }, + setInitialAlert() { + const { linkUrl, title, message, variant } = retrieveAlert() || {}; + this.setAlert({ linkUrl, title, message, variant }); + }, }, }; </script> <template> <div> - <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" :dismissible="false"> - {{ errorMessage }} + <gl-alert + v-if="shouldShowAlert" + class="gl-mb-7" + :variant="alert.variant" + :title="alert.title" + @dismiss="setAlert" + > + <gl-sprintf v-if="alert.linkUrl" :message="alert.message"> + <template #link="{ content }"> + <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + + <template v-else> + {{ alert.message }} + </template> </gl-alert> - <h2>{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <div - v-if="showNewUI" - class="gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" + class="jira-connect-app-body gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" > <h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading"> {{ s__('Integrations|Linked namespaces') }} diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/components/groups_list_item.vue index 69b09ab0a21..b8959a2a505 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list_item.vue +++ b/app/assets/javascripts/jira_connect/components/groups_list_item.vue @@ -1,7 +1,9 @@ <script> import { GlAvatar, GlButton, GlIcon } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { addSubscription } from '~/jira_connect/api'; import { s__ } from '~/locale'; +import { persistAlert } from '../utils'; export default { components: { @@ -31,6 +33,15 @@ export default { addSubscription(this.subscriptionsPath, this.group.full_path) .then(() => { + persistAlert({ + title: s__('Integrations|Namespace successfully linked'), + message: s__( + 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', + ), + linkUrl: helpPagePath('integration/jira_development_panel.html', { anchor: 'usage' }), + variant: 'success', + }); + AP.navigator.reload(); }) .catch((error) => { diff --git a/app/assets/javascripts/jira_connect/constants.js b/app/assets/javascripts/jira_connect/constants.js index 2b3be5cd5cd..63b79581a1b 100644 --- a/app/assets/javascripts/jira_connect/constants.js +++ b/app/assets/javascripts/jira_connect/constants.js @@ -1 +1,2 @@ export const defaultPerPage = 10; +export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert'; diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js index 7191fce3c33..ecdb41607a4 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/index.js @@ -6,7 +6,7 @@ import Translate from '~/vue_shared/translate'; import JiraConnectApp from './components/app.vue'; import createStore from './store'; -import { SET_ERROR_MESSAGE } from './store/mutation_types'; +import { SET_ALERT } from './store/mutation_types'; const store = createStore(); @@ -17,7 +17,7 @@ const reqComplete = () => { const reqFailed = (res, fallbackErrorMessage) => { const { error = fallbackErrorMessage } = res || {}; - store.commit(SET_ERROR_MESSAGE, error); + store.commit(SET_ALERT, { message: error, variant: 'danger' }); }; const updateSignInLinks = async () => { @@ -77,6 +77,7 @@ export async function initJiraConnect() { Vue.use(GlFeatureFlagsPlugin); const { groupsPath, subscriptionsPath, usersPath } = el.dataset; + AP.sizeToParent(); return new Vue({ el, diff --git a/app/assets/javascripts/jira_connect/store/mutation_types.js b/app/assets/javascripts/jira_connect/store/mutation_types.js index 7f6ff1256bb..15f36b824d9 100644 --- a/app/assets/javascripts/jira_connect/store/mutation_types.js +++ b/app/assets/javascripts/jira_connect/store/mutation_types.js @@ -1 +1 @@ -export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; +export const SET_ALERT = 'SET_ALERT'; diff --git a/app/assets/javascripts/jira_connect/store/mutations.js b/app/assets/javascripts/jira_connect/store/mutations.js index c3acd07f89f..2a25e0fe25f 100644 --- a/app/assets/javascripts/jira_connect/store/mutations.js +++ b/app/assets/javascripts/jira_connect/store/mutations.js @@ -1,7 +1,7 @@ -import { SET_ERROR_MESSAGE } from './mutation_types'; +import { SET_ALERT } from './mutation_types'; export default { - [SET_ERROR_MESSAGE](state, errorMessage) { - state.errorMessage = errorMessage; + [SET_ALERT](state, { title, message, variant, linkUrl } = {}) { + state.alert = { title, message, variant, linkUrl }; }, }; diff --git a/app/assets/javascripts/jira_connect/store/state.js b/app/assets/javascripts/jira_connect/store/state.js index 079b8350770..c807df03f00 100644 --- a/app/assets/javascripts/jira_connect/store/state.js +++ b/app/assets/javascripts/jira_connect/store/state.js @@ -1,3 +1,3 @@ export default () => ({ - errorMessage: undefined, + alert: undefined, }); diff --git a/app/assets/javascripts/jira_connect/utils.js b/app/assets/javascripts/jira_connect/utils.js new file mode 100644 index 00000000000..2a6c53ba42c --- /dev/null +++ b/app/assets/javascripts/jira_connect/utils.js @@ -0,0 +1,33 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { ALERT_LOCALSTORAGE_KEY } from './constants'; + +/** + * Persist alert data to localStorage. + */ +export const persistAlert = ({ title, message, linkUrl, variant } = {}) => { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return; + } + + const payload = JSON.stringify({ title, message, linkUrl, variant }); + localStorage.setItem(ALERT_LOCALSTORAGE_KEY, payload); +}; + +/** + * Return alert data from localStorage. + */ +export const retrieveAlert = () => { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return null; + } + + const initialAlertJSON = localStorage.getItem(ALERT_LOCALSTORAGE_KEY); + // immediately clean up + localStorage.removeItem(ALERT_LOCALSTORAGE_KEY); + + if (!initialAlertJSON) { + return null; + } + + return JSON.parse(initialAlertJSON); +}; diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue index 0f34926f689..2018942a7e8 100644 --- a/app/assets/javascripts/jobs/components/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/artifacts_block.vue @@ -37,7 +37,7 @@ export default { }; </script> <template> - <div class="block"> + <div> <div class="title gl-font-weight-bold">{{ s__('Job|Job artifacts') }}</div> <p v-if="isExpired || willExpire" diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue index 222fae6d9a8..eae6b5d5419 100644 --- a/app/assets/javascripts/jobs/components/commit_block.vue +++ b/app/assets/javascripts/jobs/components/commit_block.vue @@ -18,20 +18,11 @@ export default { required: false, default: null, }, - isLastBlock: { - type: Boolean, - required: true, - }, }, }; </script> <template> - <div - :class="{ - 'block-last': isLastBlock, - block: !isLastBlock, - }" - > + <div> <span class="font-weight-bold">{{ __('Commit') }}</span> <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit"> diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index e68368919ab..488d838db52 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -63,7 +63,7 @@ export default { <span class="text-truncate w-100">{{ job.name ? job.name : job.id }}</span> - <gl-icon v-if="job.retried" name="retry" class="js-retry-icon" /> + <gl-icon v-if="job.retried" name="retry" /> </gl-link> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index fbdbfddff56..ce4a85b35b7 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -1,9 +1,7 @@ <script> -/* eslint-disable vue/no-v-html */ import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; import { numberToHumanSize } from '~/lib/utils/number_utils'; import { __, sprintf } from '~/locale'; -import scrollDown from '../svg/scroll_down.svg'; export default { components: { @@ -13,7 +11,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - scrollDown, props: { erasePath: { type: String, @@ -87,7 +84,6 @@ export default { v-gl-tooltip.body :title="s__('Job|Show complete raw')" :href="rawPath" - class="controllers-buttons" data-testid="job-raw-link-controller" icon="doc-text" /> @@ -98,7 +94,7 @@ export default { :title="s__('Job|Erase job log')" :href="erasePath" :data-confirm="__('Are you sure you want to erase this build?')" - class="controllers-buttons" + class="gl-ml-3" data-testid="job-log-erase-link" data-method="post" icon="remove" @@ -106,25 +102,24 @@ export default { <!-- eo links --> <!-- scroll buttons --> - <div v-gl-tooltip :title="s__('Job|Scroll to top')" class="controllers-buttons"> + <div v-gl-tooltip :title="s__('Job|Scroll to top')" class="gl-ml-3"> <gl-button :disabled="isScrollTopDisabled" - class="btn-scroll btn-transparent btn-blank" + class="btn-scroll" data-testid="job-controller-scroll-top" icon="scroll_up" @click="handleScrollToTop" /> </div> - <div v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="controllers-buttons"> + <div v-gl-tooltip :title="s__('Job|Scroll to bottom')" class="gl-ml-3"> <gl-button :disabled="isScrollBottomDisabled" - class="js-scroll-bottom btn-scroll btn-transparent btn-blank" + class="js-scroll-bottom btn-scroll" data-testid="job-controller-scroll-bottom" icon="scroll_down" :class="{ animate: isScrollingDown }" @click="handleScrollToBottom" - v-html="$options.scrollDown" /> </div> <!-- eo scroll buttons --> diff --git a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue index 258b8cadd63..a43b3297d75 100644 --- a/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue +++ b/app/assets/javascripts/jobs/components/job_sidebar_retry_button.vue @@ -36,10 +36,10 @@ export default { v-gl-modal="modalId" :aria-label="$options.i18n.retryLabel" category="primary" - variant="info" + variant="confirm" >{{ $options.i18n.retryLabel }}</gl-button > - <gl-link v-else :href="href" data-method="post" rel="nofollow" + <gl-link v-else :href="href" class="btn gl-button btn-confirm" data-method="post" rel="nofollow" >{{ $options.i18n.retryLabel }} </gl-link> </template> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index f63fe72a71a..fcf03dff34e 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlIcon, GlLink } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; @@ -20,12 +20,12 @@ export default { i18n: { ...JOB_SIDEBAR, }, + borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], forwardDeploymentFailureModalId, components: { ArtifactsBlock, CommitBlock, GlButton, - GlLink, GlIcon, JobsContainer, JobSidebarRetryButton, @@ -45,11 +45,8 @@ export default { computed: { ...mapGetters(['hasForwardDeploymentFailure']), ...mapState(['job', 'stages', 'jobs', 'selectedStage']), - retryButtonClass() { - let className = 'btn gl-button gl-text-decoration-none!'; - className += - this.job.status && this.job.recoverable ? ' btn-confirm' : ' btn-confirm-secondary'; - return className; + retryButtonCategory() { + return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; }, hasArtifact() { return !isEmpty(this.job.artifact); @@ -76,71 +73,94 @@ export default { <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> <div class="sidebar-container"> <div class="blocks-container"> - <div class="block d-flex flex-nowrap align-items-center"> + <div class="gl-py-5 gl-display-flex gl-align-items-center"> <tooltip-on-truncate :title="job.name" truncate-target="child" ><h4 class="my-0 mr-2 gl-text-truncate"> {{ job.name }} </h4> </tooltip-on-truncate> - <div class="flex-grow-1 flex-shrink-0 text-right"> + <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> <job-sidebar-retry-button v-if="job.retry_path" - :class="retryButtonClass" + :category="retryButtonCategory" :href="job.retry_path" :modal-id="$options.forwardDeploymentFailureModalId" + variant="confirm" data-qa-selector="retry_button" data-testid="retry-button" /> - <gl-link + <gl-button v-if="job.cancel_path" :href="job.cancel_path" - class="btn gl-button btn-default gl-text-decoration-none!" data-method="post" data-testid="cancel-button" rel="nofollow" >{{ $options.i18n.cancel }} - </gl-link> + </gl-button> </div> <gl-button :aria-label="$options.i18n.toggleSidebar" category="tertiary" - class="gl-md-display-none gl-ml-2 js-sidebar-build-toggle" + class="gl-md-display-none gl-ml-2" icon="chevron-double-lg-right" @click="toggleSidebar" /> </div> - <div v-if="job.terminal_path || job.new_issue_path" class="block retry-link"> - <gl-link + <div + v-if="job.terminal_path || job.new_issue_path" + class="gl-py-5" + :class="$options.borderTopClass" + > + <gl-button v-if="job.new_issue_path" :href="job.new_issue_path" - class="btn gl-button btn-success-secondary float-left mr-2 gl-text-decoration-none!" + category="secondary" + variant="confirm" data-testid="job-new-issue" - >{{ $options.i18n.newIssue }} - </gl-link> - <gl-link + > + {{ $options.i18n.newIssue }} + </gl-button> + <gl-button v-if="job.terminal_path" :href="job.terminal_path" - class="btn btn-primary btn-inverted visible-md-block visible-lg-block float-left" target="_blank" data-testid="terminal-link" > {{ $options.i18n.debug }} - <gl-icon :size="14" name="external-link" /> - </gl-link> + <gl-icon name="external-link" /> + </gl-button> </div> - <job-sidebar-details-container /> - <artifacts-block v-if="hasArtifact" :artifact="job.artifact" :help-url="artifactHelpUrl" /> - <trigger-block v-if="hasTriggers" :trigger="job.trigger" /> + + <job-sidebar-details-container class="gl-py-5" :class="$options.borderTopClass" /> + + <artifacts-block + v-if="hasArtifact" + class="gl-py-5" + :class="$options.borderTopClass" + :artifact="job.artifact" + :help-url="artifactHelpUrl" + /> + + <trigger-block + v-if="hasTriggers" + class="gl-py-5" + :class="$options.borderTopClass" + :trigger="job.trigger" + /> + <commit-block :commit="commit" - :is-last-block="hasStages" + class="gl-py-5" + :class="$options.borderTopClass" :merge-request="job.merge_request" /> <stages-dropdown v-if="job.pipeline" + class="gl-py-5" + :class="$options.borderTopClass" :pipeline="job.pipeline" :selected-stage="selectedStage" :stages="stages" diff --git a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue index 62cd30fb320..b20d58b6ffe 100644 --- a/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue +++ b/app/assets/javascripts/jobs/components/sidebar_job_details_container.vue @@ -73,7 +73,7 @@ export default { </script> <template> - <div v-if="shouldRenderBlock" class="block"> + <div v-if="shouldRenderBlock"> <detail-row v-if="job.duration" :value="duration" title="Duration" /> <detail-row v-if="job.finished_at" diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue index 64c4031b002..18de849af88 100644 --- a/app/assets/javascripts/jobs/components/stages_dropdown.vue +++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue @@ -43,7 +43,7 @@ export default { }; </script> <template> - <div class="block-last dropdown"> + <div class="dropdown"> <div class="js-pipeline-info"> <ci-icon :status="pipeline.details.status" class="vertical-align-middle" /> diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue index f6b98777011..fef5b37015c 100644 --- a/app/assets/javascripts/jobs/components/trigger_block.vue +++ b/app/assets/javascripts/jobs/components/trigger_block.vue @@ -61,7 +61,7 @@ export default { </script> <template> - <div class="block"> + <div> <p v-if="trigger.short_token" :class="{ 'gl-mb-2': hasVariables, 'gl-mb-0': !hasVariables }" diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 930a225857d..6cb96bee07d 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -1,7 +1,7 @@ import { isEmpty, isString } from 'lodash'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; -export const headerTime = (state) => state.job.started ?? state.job.created_at; +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'; diff --git a/app/assets/javascripts/jobs/svg/scroll_down.svg b/app/assets/javascripts/jobs/svg/scroll_down.svg deleted file mode 100644 index fb934f68704..00000000000 --- a/app/assets/javascripts/jobs/svg/scroll_down.svg +++ /dev/null @@ -1,4 +0,0 @@ -<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path class="scroll-arrow" d="M8 10.4142L4.29289 6.70711C3.90237 6.31658 3.90237 5.68342 4.29289 5.2929C4.68342 4.90237 5.31658 4.90237 5.70711 5.2929L7 6.58579L7 1C7 0.447715 7.44772 0 8 0C8.55229 0 9 0.447715 9 1L9 6.58579L10.2929 5.2929C10.6834 4.90237 11.3166 4.90237 11.7071 5.2929C12.0976 5.68342 12.0976 6.31658 11.7071 6.70711L8 10.4142Z"/> -<path class="scroll-dot" d="M8 16C9.10457 16 10 15.1046 10 14C10 12.8954 9.10457 12 8 12C6.89543 12 6 12.8954 6 14C6 15.1046 6.89543 16 8 16Z"/> -</svg> diff --git a/app/assets/javascripts/lib/chrome_84_icon_fix.js b/app/assets/javascripts/lib/chrome_84_icon_fix.js deleted file mode 100644 index 20fe9590ce3..00000000000 --- a/app/assets/javascripts/lib/chrome_84_icon_fix.js +++ /dev/null @@ -1,78 +0,0 @@ -import { debounce } from 'lodash'; - -/* - Chrome and Edge 84 have a bug relating to icon sprite svgs - https://bugs.chromium.org/p/chromium/issues/detail?id=1107442 - - If the SVG is loaded, under certain circumstances the icons are not - shown. We load our sprite icons with JS and add them to the body. - Then we iterate over all the `use` elements and replace their reference - to that svg which we added internally. In order to avoid id conflicts, - those are renamed with a unique prefix. - - We do that once the DOMContentLoaded fired and otherwise we use a - mutation observer to re-trigger this logic. - - In order to not have a big impact on performance or to cause flickering - of of content, - - 1. we only do it for each svg once - 2. we debounce the event handler and just do it in a requestIdleCallback - - Before we tried to do it with the library svg4everybody and it had a big - performance impact. See: - https://gitlab.com/gitlab-org/quality/performance/-/issues/312 - */ -document.addEventListener('DOMContentLoaded', async () => { - const GITLAB_SVG_PREFIX = 'chrome-issue-230433-gitlab-svgs'; - const FILE_ICON_PREFIX = 'chrome-issue-230433-file-icons'; - const SKIP_ATTRIBUTE = 'data-replaced-by-chrome-issue-230433'; - - const fixSVGs = () => { - requestIdleCallback(() => { - document.querySelectorAll(`use:not([${SKIP_ATTRIBUTE}])`).forEach((use) => { - const href = use?.getAttribute('href') ?? use?.getAttribute('xlink:href') ?? ''; - - if (href.includes(window.gon.sprite_icons)) { - use.removeAttribute('xlink:href'); - use.setAttribute('href', `#${GITLAB_SVG_PREFIX}-${href.split('#')[1]}`); - } else if (href.includes(window.gon.sprite_file_icons)) { - use.removeAttribute('xlink:href'); - use.setAttribute('href', `#${FILE_ICON_PREFIX}-${href.split('#')[1]}`); - } - - use.setAttribute(SKIP_ATTRIBUTE, 'true'); - }); - }); - }; - - const watchForNewSVGs = () => { - const observer = new MutationObserver(debounce(fixSVGs, 200)); - observer.observe(document.querySelector('body'), { - childList: true, - attributes: false, - subtree: true, - }); - }; - - const retrieveIconSprites = async (url, prefix) => { - const div = document.createElement('div'); - div.classList.add('hidden'); - const result = await fetch(url); - div.innerHTML = await result.text(); - div.querySelectorAll('[id]').forEach((node) => { - node.setAttribute('id', `${prefix}-${node.getAttribute('id')}`); - }); - document.body.append(div); - }; - - if (window.gon && window.gon.sprite_icons) { - await retrieveIconSprites(window.gon.sprite_icons, GITLAB_SVG_PREFIX); - if (window.gon.sprite_file_icons) { - await retrieveIconSprites(window.gon.sprite_file_icons, FILE_ICON_PREFIX); - } - - fixSVGs(); - watchForNewSVGs(); - } -}); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index bda5550a9f4..e090f9f6e8c 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -2,6 +2,7 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { ApolloClient } from 'apollo-client'; import { ApolloLink } from 'apollo-link'; import { BatchHttpLink } from 'apollo-link-batch-http'; +import { createHttpLink } from 'apollo-link-http'; import { createUploadLink } from 'apollo-upload-client'; import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link'; import csrf from '~/lib/utils/csrf'; @@ -48,7 +49,7 @@ export default (resolvers = {}, config = {}) => { const uploadsLink = ApolloLink.split( (operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest, createUploadLink(httpOptions), - new BatchHttpLink(httpOptions), + config.useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions), ); const performanceBarLink = new ApolloLink((operation, forward) => { diff --git a/app/assets/javascripts/lib/utils/experimentation.js b/app/assets/javascripts/lib/utils/experimentation.js deleted file mode 100644 index 555e76055e0..00000000000 --- a/app/assets/javascripts/lib/utils/experimentation.js +++ /dev/null @@ -1,3 +0,0 @@ -export function isExperimentEnabled(experimentKey) { - return Boolean(window.gon?.experiments?.[experimentKey]); -} diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 06529f06a66..6b9be34235b 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -19,6 +19,7 @@ const httpStatusCodes = { UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, + METHOD_NOT_ALLOWED: 405, CONFLICT: 409, GONE: 410, UNPROCESSABLE_ENTITY: 422, diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 0f29f538b07..63feb6f9b1d 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -150,3 +150,24 @@ export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-' return `${change >= 0 ? '+' : ''}${change}%`; }; + +/** + * Checks whether a value is numerical in nature by converting it using parseInt + * + * Example outcomes: + * - isNumeric(100) = true + * - isNumeric('100') = true + * - isNumeric(1.0) = true + * - isNumeric('1.0') = true + * - isNumeric('abc100') = false + * - isNumeric('abc') = false + * - isNumeric(true) = false + * - isNumeric(undefined) = false + * - isNumeric(null) = false + * + * @param value + * @returns {boolean} + */ +export const isNumeric = (value) => { + return !Number.isNaN(parseInt(value, 10)); +}; diff --git a/app/assets/javascripts/lib/utils/select2_utils.js b/app/assets/javascripts/lib/utils/select2_utils.js new file mode 100644 index 00000000000..03c0e608b79 --- /dev/null +++ b/app/assets/javascripts/lib/utils/select2_utils.js @@ -0,0 +1,25 @@ +import axios from './axios_utils'; +import { normalizeHeaders, parseIntPagination } from './common_utils'; + +// This is used in the select2 config to replace jQuery.ajax with axios +export const select2AxiosTransport = (params) => { + axios({ + method: params.type?.toLowerCase() || 'get', + url: params.url, + params: params.data, + }) + .then((res) => { + const results = res.data || []; + const headers = normalizeHeaders(res.headers); + const pagination = parseIntPagination(headers); + const more = pagination.nextPage > pagination.page; + + params.success({ + results, + pagination: { + more, + }, + }); + }) + .catch(params.error); +}; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 5e66aa05218..345dfaf895b 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -283,9 +283,9 @@ function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagCo /* eslint-disable @gitlab/require-i18n-strings */ export function keypressNoteText(e) { - if (this.selectionStart === this.selectionEnd) { - return; - } + if (!gon.markdown_surround_selection) return; + if (this.selectionStart === this.selectionEnd) return; + const keys = { '*': '**{text}**', // wraps with bold character _: '_{text}_', // wraps with italic character diff --git a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js index 15f9512fe92..418cc69bf5a 100644 --- a/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js +++ b/app/assets/javascripts/lib/utils/unit_format/formatter_factory.js @@ -1,39 +1,30 @@ +import { formatNumber } from '~/locale'; + /** - * Formats a number as string using `toLocaleString`. + * Formats a number as a string using `toLocaleString`. * * @param {Number} number to be converted - * @param {params} Parameters - * @param {params.fractionDigits} Number of decimal digits - * to display, defaults to using `toLocaleString` defaults. - * @param {params.maxLength} Max output char lenght at the + * + * @param {options.maxCharLength} Max output char length at the * expense of precision, if the output is longer than this, * the formatter switches to using exponential notation. - * @param {params.factor} Value is multiplied by this factor, - * useful for value normalization. - * @returns Formatted value + * + * @param {options.valueFactor} Value is multiplied by this factor, + * useful for value normalization or to alter orders of magnitude. + * + * @param {options} Other options to be passed to + * `formatNumber` such as `valueFactor`, `unit` and `style`. + * */ -function formatNumber( - value, - { fractionDigits = undefined, valueFactor = 1, style = undefined, maxLength = undefined }, -) { - if (value === null) { - return ''; - } - - const locale = document.documentElement.lang || undefined; - const num = value * valueFactor; - const formatted = num.toLocaleString(locale, { - minimumFractionDigits: fractionDigits, - maximumFractionDigits: fractionDigits, - style, - }); +const formatNumberNormalized = (value, { maxCharLength, valueFactor = 1, ...options }) => { + const formatted = formatNumber(value * valueFactor, options); - if (maxLength !== undefined && formatted.length > maxLength) { + if (maxCharLength !== undefined && formatted.length > maxCharLength) { // 123456 becomes 1.23e+8 - return num.toExponential(2); + return value.toExponential(2); } return formatted; -} +}; /** * Formats a number as a string scaling it up according to units. @@ -76,7 +67,10 @@ const scaledFormatter = (units, unitFactor = 1000) => { const unit = units[scale]; - return `${formatNumber(num, { fractionDigits })}${unit}`; + return `${formatNumberNormalized(num, { + maximumFractionDigits: fractionDigits, + minimumFractionDigits: fractionDigits, + })}${unit}`; }; }; @@ -84,8 +78,14 @@ const scaledFormatter = (units, unitFactor = 1000) => { * Returns a function that formats a number as a string. */ export const numberFormatter = (style = 'decimal', valueFactor = 1) => { - return (value, fractionDigits, maxLength) => { - return `${formatNumber(value, { fractionDigits, maxLength, valueFactor, style })}`; + return (value, fractionDigits, maxCharLength) => { + return `${formatNumberNormalized(value, { + maxCharLength, + valueFactor, + style, + maximumFractionDigits: fractionDigits, + minimumFractionDigits: fractionDigits, + })}`; }; }; @@ -93,9 +93,15 @@ export const numberFormatter = (style = 'decimal', valueFactor = 1) => { * Returns a function that formats a number as a string with a suffix. */ export const suffixFormatter = (unit = '', valueFactor = 1) => { - return (value, fractionDigits, maxLength) => { - const length = maxLength !== undefined ? maxLength - unit.length : undefined; - return `${formatNumber(value, { fractionDigits, maxLength: length, valueFactor })}${unit}`; + return (value, fractionDigits, maxCharLength) => { + const length = maxCharLength !== undefined ? maxCharLength - unit.length : undefined; + + return `${formatNumberNormalized(value, { + maxCharLength: length, + valueFactor, + maximumFractionDigits: fractionDigits, + minimumFractionDigits: fractionDigits, + })}${unit}`; }; }; diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js index 9f979f7ea4b..bc82c6aa74d 100644 --- a/app/assets/javascripts/lib/utils/unit_format/index.js +++ b/app/assets/javascripts/lib/utils/unit_format/index.js @@ -46,227 +46,261 @@ export const SUPPORTED_FORMATS = { }; /** - * Returns a function that formats number to different units - * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation. + * Returns a function that formats number to different units. * + * Used for dynamic formatting, for more convenience, use the functions below. * + * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation. */ export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => { // Number - if (format === SUPPORTED_FORMATS.number) { - /** - * Formats a number - * - * @function - * @param {Number} value - Number to format - * @param {Number} fractionDigits - precision decimals - * @param {Number} maxLength - Max length of formatted number - * if length is exceeded, exponential format is used. - */ return numberFormatter(); } if (format === SUPPORTED_FORMATS.percent) { - /** - * Formats a percentge (0 - 1) - * - * @function - * @param {Number} value - Number to format, `1` is rendered as `100%` - * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max length of formatted number - * if length is exceeded, exponential format is used. - */ return numberFormatter('percent'); } if (format === SUPPORTED_FORMATS.percentHundred) { - /** - * Formats a percentge (0 to 100) - * - * @function - * @param {Number} value - Number to format, `100` is rendered as `100%` - * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max length of formatted number - * if length is exceeded, exponential format is used. - */ return numberFormatter('percent', 1 / 100); } // Durations - if (format === SUPPORTED_FORMATS.seconds) { - /** - * Formats a number of seconds - * - * @function - * @param {Number} value - Number to format, `1` is rendered as `1s` - * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max length of formatted number - * if length is exceeded, exponential format is used. - */ return suffixFormatter(s__('Units|s')); } if (format === SUPPORTED_FORMATS.milliseconds) { - /** - * Formats a number of milliseconds with ms as units - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1ms` - * @param {Number} fractionDigits - number of precision decimals - * @param {Number} maxLength - Max length of formatted number - * if length is exceeded, exponential format is used. - */ return suffixFormatter(s__('Units|ms')); } // Digital (Metric) - if (format === SUPPORTED_FORMATS.decimalBytes) { - /** - * Formats a number of bytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1B` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledSIFormatter('B'); } if (format === SUPPORTED_FORMATS.kilobytes) { - /** - * Formats a number of kilobytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1kB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledSIFormatter('B', 1); } if (format === SUPPORTED_FORMATS.megabytes) { - /** - * Formats a number of megabytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1MB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledSIFormatter('B', 2); } if (format === SUPPORTED_FORMATS.gigabytes) { - /** - * Formats a number of gigabytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1GB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledSIFormatter('B', 3); } if (format === SUPPORTED_FORMATS.terabytes) { - /** - * Formats a number of terabytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1GB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledSIFormatter('B', 4); } if (format === SUPPORTED_FORMATS.petabytes) { - /** - * Formats a number of petabytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1PB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledSIFormatter('B', 5); } // Digital (IEC) - if (format === SUPPORTED_FORMATS.bytes) { - /** - * Formats a number of bytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1B` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledBinaryFormatter('B'); } if (format === SUPPORTED_FORMATS.kibibytes) { - /** - * Formats a number of kilobytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1kB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledBinaryFormatter('B', 1); } if (format === SUPPORTED_FORMATS.mebibytes) { - /** - * Formats a number of megabytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1MB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledBinaryFormatter('B', 2); } if (format === SUPPORTED_FORMATS.gibibytes) { - /** - * Formats a number of gigabytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1GB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledBinaryFormatter('B', 3); } if (format === SUPPORTED_FORMATS.tebibytes) { - /** - * Formats a number of terabytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1GB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledBinaryFormatter('B', 4); } if (format === SUPPORTED_FORMATS.pebibytes) { - /** - * Formats a number of petabytes scaled up to larger digital - * units for larger numbers. - * - * @function - * @param {Number} value - Number to format, `1` is formatted as `1PB` - * @param {Number} fractionDigits - number of precision decimals - */ return scaledBinaryFormatter('B', 5); } + // Default if (format === SUPPORTED_FORMATS.engineering) { - /** - * Formats via engineering notation - * - * @function - * @param {Number} value - Value to format - * @param {Number} fractionDigits - precision decimals - Defaults to 2 - */ return engineeringNotation; } // Fail so client library addresses issue throw TypeError(`${format} is not a valid number format`); }; + +/** + * Formats a number + * + * @function + * @param {Number} value - Number to format + * @param {Number} fractionDigits - precision decimals + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + */ +export const number = getFormatter(SUPPORTED_FORMATS.number); + +/** + * Formats a percentage (0 - 1) + * + * @function + * @param {Number} value - Number to format, `1` is rendered as `100%` + * @param {Number} fractionDigits - number of precision decimals + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + */ +export const percent = getFormatter(SUPPORTED_FORMATS.percent); + +/** + * Formats a percentage (0 to 100) + * + * @function + * @param {Number} value - Number to format, `100` is rendered as `100%` + * @param {Number} fractionDigits - number of precision decimals + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + */ +export const percentHundred = getFormatter(SUPPORTED_FORMATS.percentHundred); + +/** + * Formats a number of seconds + * + * @function + * @param {Number} value - Number to format, `1` is rendered as `1s` + * @param {Number} fractionDigits - number of precision decimals + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + */ +export const seconds = getFormatter(SUPPORTED_FORMATS.seconds); + +/** + * Formats a number of milliseconds with ms as units + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1ms` + * @param {Number} fractionDigits - number of precision decimals + * @param {Number} maxLength - Max length of formatted number + * if length is exceeded, exponential format is used. + */ +export const milliseconds = getFormatter(SUPPORTED_FORMATS.milliseconds); + +/** + * Formats a number of bytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1B` + * @param {Number} fractionDigits - number of precision decimals + */ +export const decimalBytes = getFormatter(SUPPORTED_FORMATS.decimalBytes); + +/** + * Formats a number of kilobytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1kB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const kilobytes = getFormatter(SUPPORTED_FORMATS.kilobytes); + +/** + * Formats a number of megabytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1MB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const megabytes = getFormatter(SUPPORTED_FORMATS.megabytes); + +/** + * Formats a number of gigabytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1GB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const gigabytes = getFormatter(SUPPORTED_FORMATS.gigabytes); + +/** + * Formats a number of terabytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1GB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const terabytes = getFormatter(SUPPORTED_FORMATS.terabytes); + +/** + * Formats a number of petabytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1PB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const petabytes = getFormatter(SUPPORTED_FORMATS.petabytes); + +/** + * Formats a number of bytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1B` + * @param {Number} fractionDigits - number of precision decimals + */ +export const bytes = getFormatter(SUPPORTED_FORMATS.bytes); + +/** + * Formats a number of kilobytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1kB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const kibibytes = getFormatter(SUPPORTED_FORMATS.kibibytes); + +/** + * Formats a number of megabytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1MB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const mebibytes = getFormatter(SUPPORTED_FORMATS.mebibytes); + +/** + * Formats a number of gigabytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1GB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const gibibytes = getFormatter(SUPPORTED_FORMATS.gibibytes); + +/** + * Formats a number of terabytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1GB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const tebibytes = getFormatter(SUPPORTED_FORMATS.tebibytes); + +/** + * Formats a number of petabytes scaled up to larger digital + * units for larger numbers. + * + * @function + * @param {Number} value - Number to format, `1` is formatted as `1PB` + * @param {Number} fractionDigits - number of precision decimals + */ +export const pebibytes = getFormatter(SUPPORTED_FORMATS.pebibytes); + +/** + * Formats via engineering notation + * + * @function + * @param {Number} value - Value to format + * @param {Number} fractionDigits - precision decimals - Defaults to 2 + */ +export const engineering = getFormatter(); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index cc2cf787a8f..5b3aa3cf9dc 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -473,6 +473,7 @@ export const setUrlParams = ( url = window.location.href, clearParams = false, railsArraySyntax = false, + decodeParams = false, ) => { const urlObj = new URL(url); const queryString = urlObj.search; @@ -495,7 +496,9 @@ export const setUrlParams = ( } }); - urlObj.search = searchParams.toString(); + urlObj.search = decodeParams + ? decodeURIComponent(searchParams.toString()) + : searchParams.toString(); return urlObj.toString(); }; diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js index 35087b920c7..10518fa73d9 100644 --- a/app/assets/javascripts/locale/index.js +++ b/app/assets/javascripts/locale/index.js @@ -58,10 +58,30 @@ const pgettext = (keyOrContext, key) => { */ const createDateTimeFormat = (formatOptions) => Intl.DateTimeFormat(languageCode(), formatOptions); +/** + * Formats a number as a string using `toLocaleString`. + * + * @param {Number} value - number to be converted + * @param {options?} options - options to be passed to + * `toLocaleString` such as `unit` and `style`. + * @param {langCode?} langCode - If set, forces a different + * language code from the one currently in the document. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat + * + * @returns If value is a number, the formatted value as a string + */ +function formatNumber(value, options = {}, langCode = languageCode()) { + if (typeof value !== 'number' && typeof value !== 'bigint') { + return value; + } + return value.toLocaleString(langCode, options); +} + export { languageCode }; export { gettext as __ }; export { ngettext as n__ }; export { pgettext as s__ }; export { sprintf }; export { createDateTimeFormat }; +export { formatNumber }; export default locale; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js deleted file mode 100644 index a8edeaf5176..00000000000 --- a/app/assets/javascripts/members.js +++ /dev/null @@ -1,111 +0,0 @@ -import $ from 'jquery'; -import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; -import { Rails } from '~/lib/utils/rails_ujs'; -import { __, sprintf } from '~/locale'; - -export default class Members { - constructor() { - this.addListeners(); - this.initGLDropdown(); - } - - addListeners() { - // eslint-disable-next-line @gitlab/no-global-event-off - $('.js-member-update-control').off('change').on('change', this.formSubmit.bind(this)); - // eslint-disable-next-line @gitlab/no-global-event-off - $('.js-edit-member-form').off('ajax:success').on('ajax:success', this.formSuccess.bind(this)); - disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change'); - } - - dropdownClicked(options) { - options.e.preventDefault(); - - this.formSubmit(null, options.$el); - } - - // eslint-disable-next-line class-methods-use-this - dropdownToggleLabel(selected, $el) { - return $el.text(); - } - - // eslint-disable-next-line class-methods-use-this - dropdownIsSelectable(selected, $el) { - return !$el.hasClass('is-active'); - } - - initGLDropdown() { - $('.js-member-permissions-dropdown').each((i, btn) => { - const $btn = $(btn); - - initDeprecatedJQueryDropdown($btn, { - selectable: true, - isSelectable: (selected, $el) => this.dropdownIsSelectable(selected, $el), - fieldName: $btn.data('fieldName'), - id(selected, $el) { - return $el.data('id'); - }, - toggleLabel: (selected, $el) => this.dropdownToggleLabel(selected, $el, $btn), - clicked: (options) => this.dropdownClicked(options), - }); - }); - } - - formSubmit(e, $el = null) { - const $this = e ? $(e.currentTarget) : $el; - const { $toggle, $dateInput } = this.getMemberListItems($this); - const formEl = $this.closest('form').get(0); - - Rails.fire(formEl, 'submit'); - - $toggle.disable(); - $dateInput.disable(); - } - - formSuccess(e) { - const { $toggle, $dateInput, $expiresIn, $expiresInText } = this.getMemberListItems( - $(e.currentTarget).closest('.js-member'), - ); - - const [data] = e.detail; - const expiresIn = data?.expires_in; - - if (expiresIn) { - $expiresIn.removeClass('gl-display-none'); - - $expiresInText.text(sprintf(__('Expires in %{expires_at}'), { expires_at: expiresIn })); - - const { expires_soon: expiresSoon, expires_at_formatted: expiresAtFormatted } = data; - - if (expiresSoon) { - $expiresInText.addClass('text-warning'); - } else { - $expiresInText.removeClass('text-warning'); - } - - // Update tooltip - if (expiresAtFormatted) { - $expiresInText.attr('title', expiresAtFormatted); - $expiresInText.attr('data-original-title', expiresAtFormatted); - } - } else { - $expiresIn.addClass('gl-display-none'); - } - - $toggle.enable(); - $dateInput.enable(); - } - - // eslint-disable-next-line class-methods-use-this - getMemberListItems($el) { - const $memberListItem = $el.is('.js-member') ? $el : $(`#${$el.data('elId')}`); - - return { - $memberListItem, - $expiresIn: $memberListItem.find('.js-expires-in'), - $expiresInText: $memberListItem.find('.js-expires-in-text'), - $toggle: $memberListItem.find('.dropdown-menu-toggle'), - $dateInput: $memberListItem.find('.js-access-expiration-date'), - }; - } -} diff --git a/app/assets/javascripts/members/components/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue index 79dda3c1379..658fb43cecb 100644 --- a/app/assets/javascripts/members/components/avatars/user_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue @@ -5,6 +5,7 @@ import { GlBadge, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; +import { mapState } from 'vuex'; import { generateBadges } from 'ee_else_ce/members/utils'; import { glEmojiTag } from '~/emoji'; import { __ } from '~/locale'; @@ -34,11 +35,16 @@ export default { }, }, computed: { + ...mapState(['canManageMembers']), user() { return this.member.user; }, badges() { - return generateBadges(this.member, this.isCurrentUser).filter((badge) => badge.show); + return generateBadges({ + member: this.member, + isCurrentUser: this.isCurrentUser, + canManageMembers: this.canManageMembers, + }).filter((badge) => badge.show); }, statusEmoji() { return this.user?.status?.emoji; diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index 4de2dadb490..2bf30dd7b6e 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -13,7 +13,7 @@ import { GROUP_LINK_ACCESS_LEVEL_PROPERTY_NAME, } from './constants'; -export const generateBadges = (member, isCurrentUser) => [ +export const generateBadges = ({ member, isCurrentUser, canManageMembers }) => [ { show: isCurrentUser, text: __("It's you"), @@ -25,7 +25,7 @@ export const generateBadges = (member, isCurrentUser) => [ variant: 'danger', }, { - show: member.user?.twoFactorEnabled, + show: member.user?.twoFactorEnabled && (canManageMembers || isCurrentUser), text: __('2FA'), variant: 'info', }, diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js deleted file mode 100644 index 6eaabbb3519..00000000000 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js +++ /dev/null @@ -1,115 +0,0 @@ -// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on -// app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml -// for its template. -/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */ - -import { debounce } from 'lodash'; -import Vue from 'vue'; -import { deprecatedCreateFlash as flash } from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; - -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - global.mergeConflicts.diffFileEditor = Vue.extend({ - props: { - file: { - type: Object, - required: true, - }, - onCancelDiscardConfirmation: { - type: Function, - required: true, - }, - onAcceptDiscardConfirmation: { - type: Function, - required: true, - }, - }, - data() { - return { - saved: false, - fileLoaded: false, - originalContent: '', - }; - }, - computed: { - classObject() { - return { - saved: this.saved, - }; - }, - }, - watch: { - 'file.showEditor': function showEditorWatcher(val) { - this.resetEditorContent(); - - if (!val || this.fileLoaded) { - return; - } - - this.loadEditor(); - }, - }, - mounted() { - if (this.file.loadEditor) { - this.loadEditor(); - } - }, - methods: { - loadEditor() { - const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite'); - const DataPromise = axios.get(this.file.content_path); - - Promise.all([EditorPromise, DataPromise]) - .then( - ([ - { default: EditorLite }, - { - data: { content, new_path: path }, - }, - ]) => { - const contentEl = this.$el.querySelector('.editor'); - - this.originalContent = content; - this.fileLoaded = true; - - this.editor = new EditorLite().createInstance({ - el: contentEl, - blobPath: path, - blobContent: content, - }); - this.editor.onDidChangeModelContent( - debounce(this.saveDiffResolution.bind(this), 250), - ); - }, - ) - .catch(() => { - flash(__('An error occurred while loading the file')); - }); - }, - saveDiffResolution() { - this.saved = true; - - // This probably be better placed in the data provider - /* eslint-disable vue/no-mutating-props */ - this.file.content = this.editor.getValue(); - this.file.resolveEditChanged = this.file.content !== this.originalContent; - this.file.promptDiscardConfirmation = false; - /* eslint-enable vue/no-mutating-props */ - }, - resetEditorContent() { - if (this.fileLoaded) { - this.editor.setValue(this.originalContent); - } - }, - cancelDiscardConfirmation(file) { - this.onCancelDiscardConfirmation(file); - }, - acceptDiscardConfirmation(file) { - this.onAcceptDiscardConfirmation(file); - }, - }, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue new file mode 100644 index 00000000000..2c7c8038af5 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.vue @@ -0,0 +1,128 @@ +<script> +import { debounce } from 'lodash'; +import { deprecatedCreateFlash as flash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; + +export default { + props: { + file: { + type: Object, + required: true, + }, + onCancelDiscardConfirmation: { + type: Function, + required: true, + }, + onAcceptDiscardConfirmation: { + type: Function, + required: true, + }, + }, + data() { + return { + saved: false, + fileLoaded: false, + originalContent: '', + }; + }, + computed: { + classObject() { + return { + saved: this.saved, + }; + }, + }, + watch: { + 'file.showEditor': function showEditorWatcher(val) { + this.resetEditorContent(); + + if (!val || this.fileLoaded) { + return; + } + + this.loadEditor(); + }, + }, + mounted() { + if (this.file.loadEditor) { + this.loadEditor(); + } + }, + methods: { + loadEditor() { + const EditorPromise = import(/* webpackChunkName: 'EditorLite' */ '~/editor/editor_lite'); + const DataPromise = axios.get(this.file.content_path); + + Promise.all([EditorPromise, DataPromise]) + .then( + ([ + { default: EditorLite }, + { + data: { content, new_path: path }, + }, + ]) => { + const contentEl = this.$el.querySelector('.editor'); + + this.originalContent = content; + this.fileLoaded = true; + + this.editor = new EditorLite().createInstance({ + el: contentEl, + blobPath: path, + blobContent: content, + }); + this.editor.onDidChangeModelContent(debounce(this.saveDiffResolution.bind(this), 250)); + }, + ) + .catch(() => { + flash(__('An error occurred while loading the file')); + }); + }, + saveDiffResolution() { + this.saved = true; + + // This probably be better placed in the data provider + /* eslint-disable vue/no-mutating-props */ + this.file.content = this.editor.getValue(); + this.file.resolveEditChanged = this.file.content !== this.originalContent; + this.file.promptDiscardConfirmation = false; + /* eslint-enable vue/no-mutating-props */ + }, + resetEditorContent() { + if (this.fileLoaded) { + this.editor.setValue(this.originalContent); + } + }, + cancelDiscardConfirmation(file) { + this.onCancelDiscardConfirmation(file); + }, + acceptDiscardConfirmation(file) { + this.onAcceptDiscardConfirmation(file); + }, + }, +}; +</script> +<template> + <div v-show="file.showEditor" class="diff-editor-wrap"> + <div v-if="file.promptDiscardConfirmation" class="discard-changes-alert-wrap"> + <div class="discard-changes-alert"> + {{ __('Are you sure you want to discard your changes?') }} + <div class="discard-actions"> + <button + class="btn btn-sm btn-danger-secondary gl-button" + @click="acceptDiscardConfirmation(file)" + > + {{ __('Discard changes') }} + </button> + <button class="btn btn-default btn-sm gl-button" @click="cancelDiscardConfirmation(file)"> + {{ __('Cancel') }} + </button> + </div> + </div> + </div> + <div :class="classObject" class="editor-wrap"> + <div class="editor" style="height: 350px" data-editor-loading="true"></div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js deleted file mode 100644 index 47214e288ae..00000000000 --- a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js +++ /dev/null @@ -1,22 +0,0 @@ -// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on -// app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml -// for its template. -/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */ - -import Vue from 'vue'; -import actionsMixin from '../mixins/line_conflict_actions'; -import utilsMixin from '../mixins/line_conflict_utils'; - -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - global.mergeConflicts.inlineConflictLines = Vue.extend({ - mixins: [utilsMixin, actionsMixin], - props: { - file: { - type: Object, - required: true, - }, - }, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue new file mode 100644 index 00000000000..519fd53af1e --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.vue @@ -0,0 +1,47 @@ +<script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import actionsMixin from '../mixins/line_conflict_actions'; +import utilsMixin from '../mixins/line_conflict_utils'; + +export default { + directives: { + SafeHtml, + }, + mixins: [utilsMixin, actionsMixin], + props: { + file: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <table class="diff-wrap-lines code code-commit js-syntax-highlight"> + <tr + v-for="line in file.inlineLines" + :key="(line.isHeader ? line.id : line.new_line) + line.richText" + class="line_holder diff-inline" + > + <template v-if="line.isHeader"> + <td :class="lineCssClass(line)" class="diff-line-num header"></td> + <td :class="lineCssClass(line)" class="diff-line-num header"></td> + <td :class="lineCssClass(line)" class="line_content header"> + <strong>{{ line.richText }}</strong> + <button class="btn" @click="handleSelected(file, line.id, line.section)"> + {{ line.buttonTitle }} + </button> + </td> + </template> + <template v-else> + <td :class="lineCssClass(line)" class="diff-line-num new_line"> + <a>{{ line.new_line }}</a> + </td> + <td :class="lineCssClass(line)" class="diff-line-num old_line"> + <a>{{ line.old_line }}</a> + </td> + <td v-safe-html="line.richText" :class="lineCssClass(line)" class="line_content"></td> + </template> + </tr> + </table> +</template> diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js deleted file mode 100644 index 1d5946cd78a..00000000000 --- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable no-param-reassign */ - -import Vue from 'vue'; -import actionsMixin from '../mixins/line_conflict_actions'; -import utilsMixin from '../mixins/line_conflict_utils'; - -((global) => { - global.mergeConflicts = global.mergeConflicts || {}; - - global.mergeConflicts.parallelConflictLines = Vue.extend({ - mixins: [utilsMixin, actionsMixin], - props: { - file: { - type: Object, - required: true, - }, - }, - // This is a true violation of @gitlab/no-runtime-template-compiler, as it - // has a template string. - // eslint-disable-next-line @gitlab/no-runtime-template-compiler - template: ` - <table class="diff-wrap-lines code js-syntax-highlight"> - <tr class="line_holder parallel" v-for="section in file.parallelLines"> - <template v-for="line in section"> - <td class="diff-line-num header" :class="lineCssClass(line)" v-if="line.isHeader"></td> - <td class="line_content header" :class="lineCssClass(line)" v-if="line.isHeader"> - <strong>{{line.richText}}</strong> - <button class="btn" @click="handleSelected(file, line.id, line.section)">{{line.buttonTitle}}</button> - </td> - <td class="diff-line-num old_line" :class="lineCssClass(line)" v-if="!line.isHeader">{{line.lineNumber}}</td> - <td class="line_content parallel" :class="lineCssClass(line)" v-if="!line.isHeader" v-html="line.richText"></td> - </template> - </tr> - </table> - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue new file mode 100644 index 00000000000..e66f641f70d --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.vue @@ -0,0 +1,47 @@ +<script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import actionsMixin from '../mixins/line_conflict_actions'; +import utilsMixin from '../mixins/line_conflict_utils'; + +export default { + directives: { + SafeHtml, + }, + mixins: [utilsMixin, actionsMixin], + props: { + file: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <!-- Unfortunately there isn't a good key for these sections --> + <!-- eslint-disable vue/require-v-for-key --> + <table class="diff-wrap-lines code js-syntax-highlight"> + <tr v-for="section in file.parallelLines" class="line_holder parallel"> + <template v-for="line in section"> + <template v-if="line.isHeader"> + <td class="diff-line-num header" :class="lineCssClass(line)"></td> + <td class="line_content header" :class="lineCssClass(line)"> + <strong>{{ line.richText }}</strong> + <button class="btn" @click="handleSelected(file, line.id, line.section)"> + {{ line.buttonTitle }} + </button> + </td> + </template> + <template v-else> + <td class="diff-line-num old_line" :class="lineCssClass(line)"> + {{ line.lineNumber }} + </td> + <td + v-safe-html="line.richText" + class="line_content parallel" + :class="lineCssClass(line)" + ></td> + </template> + </template> + </tr> + </table> +</template> diff --git a/app/assets/javascripts/merge_conflicts/constants.js b/app/assets/javascripts/merge_conflicts/constants.js new file mode 100644 index 00000000000..6f3ee339e36 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/constants.js @@ -0,0 +1,20 @@ +import { s__ } from '~/locale'; + +export const CONFLICT_TYPES = { + TEXT: 'text', + TEXT_EDITOR: 'text-editor', +}; + +export const VIEW_TYPES = { + INLINE: 'inline', + PARALLEL: 'parallel', +}; + +export const EDIT_RESOLVE_MODE = 'edit'; +export const INTERACTIVE_RESOLVE_MODE = 'interactive'; +export const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE; + +export const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes'); +export const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes'); +export const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours'); +export const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs'); diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue new file mode 100644 index 00000000000..16a7cfb2ba8 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/merge_conflict_resolver_app.vue @@ -0,0 +1,217 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import DiffFileEditor from './components/diff_file_editor.vue'; +import InlineConflictLines from './components/inline_conflict_lines.vue'; +import ParallelConflictLines from './components/parallel_conflict_lines.vue'; + +/** + * NOTE: Most of this component is directly using $root, rather than props or a better data store. + * This is BAD and one shouldn't copy that behavior. Similarly a lot of the classes below should + * be replaced with GitLab UI components. + * + * We are just doing it temporarily in order to migrate the template from HAML => Vue in an iterative manner + * and are going to clean it up as part of: + * + * https://gitlab.com/gitlab-org/gitlab/-/issues/321090 + * + */ +export default { + components: { + GlSprintf, + FileIcon, + DiffFileEditor, + InlineConflictLines, + ParallelConflictLines, + }, + inject: ['mergeRequestPath', 'sourceBranchPath'], + i18n: { + commitStatSummary: __('Showing %{conflict} between %{sourceBranch} and %{targetBranch}'), + resolveInfo: __( + 'You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}', + ), + }, +}; +</script> +<template> + <div id="conflicts"> + <div v-if="$root.isLoading" class="loading"> + <div class="spinner spinner-md"></div> + </div> + <div v-if="$root.hasError" class="nothing-here-block"> + {{ $root.conflictsData.errorMessage }} + </div> + <template v-if="!$root.isLoading && !$root.hasError"> + <div class="content-block oneline-block files-changed"> + <div v-if="$root.showDiffViewTypeSwitcher" class="inline-parallel-buttons"> + <div class="btn-group"> + <button + :class="{ active: !$root.isParallel }" + class="btn gl-button" + @click="$root.handleViewTypeChange('inline')" + > + {{ __('Inline') }} + </button> + <button + :class="{ active: $root.isParallel }" + class="btn gl-button" + @click="$root.handleViewTypeChange('parallel')" + > + {{ __('Side-by-side') }} + </button> + </div> + </div> + <div class="js-toggle-container"> + <div class="commit-stat-summary"> + <gl-sprintf :message="$options.i18n.commitStatSummary"> + <template #conflict> + <strong class="cred"> + {{ $root.conflictsCountText }} + </strong> + </template> + <template #sourceBranch> + <strong class="ref-name"> + {{ $root.conflictsData.sourceBranch }} + </strong> + </template> + <template #targetBranch> + <strong class="ref-name"> + {{ $root.conflictsData.targetBranch }} + </strong> + </template> + </gl-sprintf> + </div> + </div> + </div> + <div class="files-wrapper"> + <div class="files"> + <div + v-for="file in $root.conflictsData.files" + :key="file.blobPath" + class="diff-file file-holder conflict" + > + <div class="js-file-title file-title file-title-flex-parent cursor-default"> + <div class="file-header-content"> + <file-icon :file-name="file.filePath" :size="18" css-classes="gl-mr-2" /> + <strong class="file-title-name">{{ file.filePath }}</strong> + </div> + <div class="file-actions d-flex align-items-center gl-ml-auto gl-align-self-start"> + <div v-if="file.type === 'text'" class="btn-group gl-mr-3"> + <button + :class="{ active: file.resolveMode === 'interactive' }" + class="btn gl-button" + type="button" + @click="$root.onClickResolveModeButton(file, 'interactive')" + > + {{ __('Interactive mode') }} + </button> + <button + :class="{ active: file.resolveMode === 'edit' }" + class="btn gl-button" + type="button" + @click="$root.onClickResolveModeButton(file, 'edit')" + > + {{ __('Edit inline') }} + </button> + </div> + <a :href="file.blobPath" class="btn gl-button view-file"> + <gl-sprintf :message="__('View file @ %{commitSha}')"> + <template #commitSha> + {{ $root.conflictsData.shortCommitSha }} + </template> + </gl-sprintf> + </a> + </div> + </div> + <div class="diff-content diff-wrap-lines"> + <div + v-show=" + !$root.isParallel && file.resolveMode === 'interactive' && file.type === 'text' + " + class="file-content" + > + <inline-conflict-lines :file="file" /> + </div> + <div + v-show=" + $root.isParallel && file.resolveMode === 'interactive' && file.type === 'text' + " + class="file-content" + > + <parallel-conflict-lines :file="file" /> + </div> + <div v-show="file.resolveMode === 'edit' || file.type === 'text-editor'"> + <diff-file-editor + :file="file" + :on-accept-discard-confirmation="$root.acceptDiscardConfirmation" + :on-cancel-discard-confirmation="$root.cancelDiscardConfirmation" + /> + </div> + </div> + </div> + </div> + </div> + <hr /> + <div class="resolve-conflicts-form"> + <div class="form-group row"> + <div class="col-md-4"> + <h4> + {{ __('Resolve conflicts on source branch') }} + </h4> + <div class="resolve-info"> + <gl-sprintf :message="$options.i18n.resolveInfo"> + <template #use_ours> + <code>{{ s__('MergeConflict|Use ours') }}</code> + </template> + <template #use_theirs> + <code>{{ s__('MergeConflict|Use theirs') }}</code> + </template> + <template #branch_name> + <a class="ref-name" :href="sourceBranchPath"> + {{ $root.conflictsData.sourceBranch }} + </a> + </template> + </gl-sprintf> + </div> + </div> + <div class="col-md-8"> + <label class="label-bold" for="commit-message"> + {{ __('Commit message') }} + </label> + <div class="commit-message-container"> + <div class="max-width-marker"></div> + <textarea + id="commit-message" + v-model="$root.conflictsData.commitMessage" + class="form-control js-commit-message" + rows="5" + ></textarea> + </div> + </div> + </div> + <div class="form-group row"> + <div class="offset-md-4 col-md-8"> + <div class="row"> + <div class="col-6"> + <button + :disabled="!$root.readyToCommit" + class="btn gl-button btn-success js-submit-button" + type="button" + @click="$root.commit()" + > + <span>{{ $root.commitButtonText }}</span> + </button> + </div> + <div class="col-6 text-right"> + <a :href="mergeRequestPath" class="gl-button btn btn-default"> + {{ __('Cancel') }} + </a> + </div> + </div> + </div> + </div> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index e3972b8b574..4b73dd317cd 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,19 +1,12 @@ -// This is a true violation of @gitlab/no-runtime-template-compiler, as it -// relies on app/views/projects/merge_requests/conflicts/show.html.haml for its -// template. -/* eslint-disable @gitlab/no-runtime-template-compiler */ import $ from 'jquery'; import Vue from 'vue'; import { __ } from '~/locale'; -import FileIcon from '~/vue_shared/components/file_icon.vue'; import { deprecatedCreateFlash as createFlash } from '../flash'; import initIssuableSidebar from '../init_issuable_sidebar'; import './merge_conflict_store'; import syntaxHighlight from '../syntax_highlight'; +import MergeConflictsResolverApp from './merge_conflict_resolver_app.vue'; import MergeConflictsService from './merge_conflict_service'; -import './components/diff_file_editor'; -import './components/inline_conflict_lines'; -import './components/parallel_conflict_lines'; export default function initMergeConflicts() { const INTERACTIVE_RESOLVE_MODE = 'interactive'; @@ -24,15 +17,15 @@ export default function initMergeConflicts() { resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath, }); + const { sourceBranchPath, mergeRequestPath } = conflictsEl.dataset; + initIssuableSidebar(); - gl.MergeConflictsResolverApp = new Vue({ - el: '#conflicts', - components: { - FileIcon, - 'diff-file-editor': gl.mergeConflicts.diffFileEditor, - 'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines, - 'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines, + return new Vue({ + el: conflictsEl, + provide: { + sourceBranchPath, + mergeRequestPath, }, data: mergeConflictsStore.state, computed: { @@ -103,5 +96,8 @@ export default function initMergeConflicts() { }); }, }, + render(createElement) { + return createElement(MergeConflictsResolverApp); + }, }); } diff --git a/app/assets/javascripts/merge_conflicts/store/actions.js b/app/assets/javascripts/merge_conflicts/store/actions.js new file mode 100644 index 00000000000..8036e90c58c --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/actions.js @@ -0,0 +1,120 @@ +import Cookies from 'js-cookie'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import { INTERACTIVE_RESOLVE_MODE, EDIT_RESOLVE_MODE } from '../constants'; +import { decorateFiles, restoreFileLinesState, markLine } from '../utils'; +import * as types from './mutation_types'; + +export const fetchConflictsData = async ({ commit, dispatch }, conflictsPath) => { + commit(types.SET_LOADING_STATE, true); + try { + const { data } = await axios.get(conflictsPath); + if (data.type === 'error') { + commit(types.SET_FAILED_REQUEST, data.message); + } else { + dispatch('setConflictsData', data); + } + } catch (e) { + commit(types.SET_FAILED_REQUEST); + } + commit(types.SET_LOADING_STATE, false); +}; + +export const setConflictsData = async ({ commit }, data) => { + const files = decorateFiles(data.files); + commit(types.SET_CONFLICTS_DATA, { ...data, files }); +}; + +export const submitResolvedConflicts = async ({ commit, getters }, resolveConflictsPath) => { + commit(types.SET_SUBMIT_STATE, true); + try { + const { data } = await axios.post(resolveConflictsPath, getters.getCommitData); + window.location.assign(data.redirect_to); + } catch (e) { + commit(types.SET_SUBMIT_STATE, false); + createFlash({ message: __('Failed to save merge conflicts resolutions. Please try again!') }); + } +}; + +export const setLoadingState = ({ commit }, isLoading) => { + commit(types.SET_LOADING_STATE, isLoading); +}; + +export const setErrorState = ({ commit }, hasError) => { + commit(types.SET_ERROR_STATE, hasError); +}; + +export const setFailedRequest = ({ commit }, message) => { + commit(types.SET_FAILED_REQUEST, message); +}; + +export const setViewType = ({ commit }, viewType) => { + commit(types.SET_VIEW_TYPE, viewType); + Cookies.set('diff_view', viewType); +}; + +export const setSubmitState = ({ commit }, isSubmitting) => { + commit(types.SET_SUBMIT_STATE, isSubmitting); +}; + +export const updateCommitMessage = ({ commit }, commitMessage) => { + commit(types.UPDATE_CONFLICTS_DATA, { commitMessage }); +}; + +export const setFileResolveMode = ({ commit, state, getters }, { file, mode }) => { + const index = getters.getFileIndex(file); + const updated = { ...state.conflictsData.files[index] }; + if (mode === INTERACTIVE_RESOLVE_MODE) { + updated.showEditor = false; + } else if (mode === EDIT_RESOLVE_MODE) { + // Restore Interactive mode when switching to Edit mode + updated.showEditor = true; + updated.loadEditor = true; + updated.resolutionData = {}; + + const { inlineLines, parallelLines } = restoreFileLinesState(updated); + updated.parallelLines = parallelLines; + updated.inlineLines = inlineLines; + } + updated.resolveMode = mode; + commit(types.UPDATE_FILE, { file: updated, index }); +}; + +export const setPromptConfirmationState = ( + { commit, state, getters }, + { file, promptDiscardConfirmation }, +) => { + const index = getters.getFileIndex(file); + const updated = { ...state.conflictsData.files[index], promptDiscardConfirmation }; + commit(types.UPDATE_FILE, { file: updated, index }); +}; + +export const handleSelected = ({ commit, state, getters }, { file, line: { id, section } }) => { + const index = getters.getFileIndex(file); + const updated = { ...state.conflictsData.files[index] }; + updated.resolutionData = { ...updated.resolutionData, [id]: section }; + + updated.inlineLines = file.inlineLines.map((line) => { + if (id === line.id && (line.hasConflict || line.isHeader)) { + return markLine(line, section); + } + return line; + }); + + updated.parallelLines = file.parallelLines.map((lines) => { + let left = { ...lines[0] }; + let right = { ...lines[1] }; + const hasSameId = right.id === id || left.id === id; + const isLeftMatch = left.hasConflict || left.isHeader; + const isRightMatch = right.hasConflict || right.isHeader; + + if (hasSameId && (isLeftMatch || isRightMatch)) { + left = markLine(left, section); + right = markLine(right, section); + } + return [left, right]; + }); + + commit(types.UPDATE_FILE, { file: updated, index }); +}; diff --git a/app/assets/javascripts/merge_conflicts/store/getters.js b/app/assets/javascripts/merge_conflicts/store/getters.js new file mode 100644 index 00000000000..03e425fb478 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/getters.js @@ -0,0 +1,117 @@ +import { s__ } from '~/locale'; +import { CONFLICT_TYPES, EDIT_RESOLVE_MODE, INTERACTIVE_RESOLVE_MODE } from '../constants'; + +export const getConflictsCount = (state) => { + if (!state.conflictsData.files.length) { + return 0; + } + + const { files } = state.conflictsData; + let count = 0; + + files.forEach((file) => { + if (file.type === CONFLICT_TYPES.TEXT) { + file.sections.forEach((section) => { + if (section.conflict) { + count += 1; + } + }); + } else { + count += 1; + } + }); + + return count; +}; + +export const getConflictsCountText = (state, getters) => { + const count = getters.getConflictsCount; + const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict'); + + return `${count} ${text}`; +}; + +export const isReadyToCommit = (state) => { + const { files } = state.conflictsData; + const hasCommitMessage = state.conflictsData.commitMessage.trim().length; + let unresolved = 0; + + for (let i = 0, l = files.length; i < l; i += 1) { + const file = files[i]; + + if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) { + let numberConflicts = 0; + const resolvedConflicts = Object.keys(file.resolutionData).length; + + // We only check for conflicts type 'text' + // since conflicts `text_editor` can´t be resolved in interactive mode + if (file.type === CONFLICT_TYPES.TEXT) { + for (let j = 0, k = file.sections.length; j < k; j += 1) { + if (file.sections[j].conflict) { + numberConflicts += 1; + } + } + + if (resolvedConflicts !== numberConflicts) { + unresolved += 1; + } + } + } else if (file.resolveMode === EDIT_RESOLVE_MODE) { + // Unlikely to happen since switching to Edit mode saves content automatically. + // Checking anyway in case the save strategy changes in the future + if (!file.content) { + unresolved += 1; + // eslint-disable-next-line no-continue + continue; + } + } + } + + return !state.isSubmitting && hasCommitMessage && !unresolved; +}; + +export const getCommitButtonText = (state) => { + const initial = s__('MergeConflict|Commit to source branch'); + const inProgress = s__('MergeConflict|Committing...'); + + return state.isSubmitting ? inProgress : initial; +}; + +export const getCommitData = (state) => { + let commitData = {}; + + commitData = { + commit_message: state.conflictsData.commitMessage, + files: [], + }; + + state.conflictsData.files.forEach((file) => { + const addFile = { + old_path: file.old_path, + new_path: file.new_path, + }; + + if (file.type === CONFLICT_TYPES.TEXT) { + // Submit only one data for type of editing + if (file.resolveMode === INTERACTIVE_RESOLVE_MODE) { + addFile.sections = file.resolutionData; + } else if (file.resolveMode === EDIT_RESOLVE_MODE) { + addFile.content = file.content; + } + } else if (file.type === CONFLICT_TYPES.TEXT_EDITOR) { + addFile.content = file.content; + } + + commitData.files.push(addFile); + }); + + return commitData; +}; + +export const fileTextTypePresent = (state) => { + return state.conflictsData?.files.some((f) => f.type === CONFLICT_TYPES.TEXT); +}; + +export const getFileIndex = (state) => ({ blobPath }) => { + return state.conflictsData.files.findIndex((f) => f.blobPath === blobPath); +}; diff --git a/app/assets/javascripts/merge_conflicts/store/index.js b/app/assets/javascripts/merge_conflicts/store/index.js new file mode 100644 index 00000000000..18e3351ed13 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + state, + getters, + actions, + mutations, + }); diff --git a/app/assets/javascripts/merge_conflicts/store/mutation_types.js b/app/assets/javascripts/merge_conflicts/store/mutation_types.js new file mode 100644 index 00000000000..ab80f8e52ad --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/mutation_types.js @@ -0,0 +1,8 @@ +export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_ERROR_STATE = 'SET_ERROR_STATE'; +export const SET_FAILED_REQUEST = 'SET_FAILED_REQUEST'; +export const SET_VIEW_TYPE = 'SET_VIEW_TYPE'; +export const SET_SUBMIT_STATE = 'SET_SUBMIT_STATE'; +export const SET_CONFLICTS_DATA = 'SET_CONFLICTS_DATA'; +export const UPDATE_FILE = 'UPDATE_FILE'; +export const UPDATE_CONFLICTS_DATA = 'UPDATE_CONFLICTS_DATA'; diff --git a/app/assets/javascripts/merge_conflicts/store/mutations.js b/app/assets/javascripts/merge_conflicts/store/mutations.js new file mode 100644 index 00000000000..2cee55319eb --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/mutations.js @@ -0,0 +1,40 @@ +import { VIEW_TYPES } from '../constants'; +import * as types from './mutation_types'; + +export default { + [types.SET_LOADING_STATE]: (state, value) => { + state.isLoading = value; + }, + [types.SET_ERROR_STATE]: (state, value) => { + state.hasError = value; + }, + [types.SET_FAILED_REQUEST]: (state, value) => { + state.hasError = true; + state.conflictsData.errorMessage = value; + }, + [types.SET_VIEW_TYPE]: (state, value) => { + state.diffView = value; + state.isParallel = value === VIEW_TYPES.PARALLEL; + }, + [types.SET_SUBMIT_STATE]: (state, value) => { + state.isSubmitting = value; + }, + [types.SET_CONFLICTS_DATA]: (state, data) => { + state.conflictsData = { + files: data.files, + commitMessage: data.commit_message, + sourceBranch: data.source_branch, + targetBranch: data.target_branch, + shortCommitSha: data.commit_sha.slice(0, 7), + }; + }, + [types.UPDATE_CONFLICTS_DATA]: (state, payload) => { + state.conflictsData = { + ...state.conflictsData, + ...payload, + }; + }, + [types.UPDATE_FILE]: (state, { file, index }) => { + state.conflictsData.files.splice(index, 1, file); + }, +}; diff --git a/app/assets/javascripts/merge_conflicts/store/state.js b/app/assets/javascripts/merge_conflicts/store/state.js new file mode 100644 index 00000000000..8f700f58e54 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/store/state.js @@ -0,0 +1,13 @@ +import Cookies from 'js-cookie'; +import { VIEW_TYPES } from '../constants'; + +const diffViewType = Cookies.get('diff_view'); + +export default () => ({ + isLoading: true, + hasError: false, + isSubmitting: false, + isParallel: diffViewType === VIEW_TYPES.PARALLEL, + diffViewType, + conflictsData: {}, +}); diff --git a/app/assets/javascripts/merge_conflicts/utils.js b/app/assets/javascripts/merge_conflicts/utils.js new file mode 100644 index 00000000000..e42703ef0a5 --- /dev/null +++ b/app/assets/javascripts/merge_conflicts/utils.js @@ -0,0 +1,228 @@ +import { + ORIGIN_HEADER_TEXT, + ORIGIN_BUTTON_TITLE, + HEAD_HEADER_TEXT, + HEAD_BUTTON_TITLE, + DEFAULT_RESOLVE_MODE, + CONFLICT_TYPES, +} from './constants'; + +export const getFilePath = (file) => { + const { old_path, new_path } = file; + // eslint-disable-next-line babel/camelcase + return old_path === new_path ? new_path : `${old_path} → ${new_path}`; +}; + +export const checkLineLengths = ({ left, right }) => { + const wLeft = [...left]; + const wRight = [...right]; + if (left.length !== right.length) { + if (left.length > right.length) { + const diff = left.length - right.length; + for (let i = 0; i < diff; i += 1) { + wRight.push({ lineType: 'emptyLine', richText: '' }); + } + } else { + const diff = right.length - left.length; + for (let i = 0; i < diff; i += 1) { + wLeft.push({ lineType: 'emptyLine', richText: '' }); + } + } + } + return { left: wLeft, right: wRight }; +}; + +export const getHeadHeaderLine = (id) => { + return { + id, + richText: HEAD_HEADER_TEXT, + buttonTitle: HEAD_BUTTON_TITLE, + type: 'new', + section: 'head', + isHeader: true, + isHead: true, + isSelected: false, + isUnselected: false, + }; +}; + +export const decorateLineForInlineView = (line, id, conflict) => { + const { type } = line; + return { + id, + hasConflict: conflict, + isHead: type === 'new', + isOrigin: type === 'old', + hasMatch: type === 'match', + richText: line.rich_text, + isSelected: false, + isUnselected: false, + }; +}; + +export const getLineForParallelView = (line, id, lineType, isHead) => { + const { old_line, new_line, rich_text } = line; + const hasConflict = lineType === 'conflict'; + + return { + id, + lineType, + hasConflict, + isHead: hasConflict && isHead, + isOrigin: hasConflict && !isHead, + hasMatch: lineType === 'match', + // eslint-disable-next-line babel/camelcase + lineNumber: isHead ? new_line : old_line, + section: isHead ? 'head' : 'origin', + richText: rich_text, + isSelected: false, + isUnselected: false, + }; +}; + +export const getOriginHeaderLine = (id) => { + return { + id, + richText: ORIGIN_HEADER_TEXT, + buttonTitle: ORIGIN_BUTTON_TITLE, + type: 'old', + section: 'origin', + isHeader: true, + isOrigin: true, + isSelected: false, + isUnselected: false, + }; +}; + +export const setInlineLine = (file) => { + const inlineLines = []; + + file.sections.forEach((section) => { + let currentLineType = 'new'; + const { conflict, lines, id } = section; + + if (conflict) { + inlineLines.push(getHeadHeaderLine(id)); + } + + lines.forEach((line) => { + const { type } = line; + + if ((type === 'new' || type === 'old') && currentLineType !== type) { + currentLineType = type; + inlineLines.push({ lineType: 'emptyLine', richText: '' }); + } + + const decoratedLine = decorateLineForInlineView(line, id, conflict); + inlineLines.push(decoratedLine); + }); + + if (conflict) { + inlineLines.push(getOriginHeaderLine(id)); + } + }); + + return inlineLines; +}; + +export const setParallelLine = (file) => { + const parallelLines = []; + let linesObj = { left: [], right: [] }; + + file.sections.forEach((section) => { + const { conflict, lines, id } = section; + + if (conflict) { + linesObj.left.push(getOriginHeaderLine(id)); + linesObj.right.push(getHeadHeaderLine(id)); + } + + lines.forEach((line) => { + const { type } = line; + + if (conflict) { + if (type === 'old') { + linesObj.left.push(getLineForParallelView(line, id, 'conflict')); + } else if (type === 'new') { + linesObj.right.push(getLineForParallelView(line, id, 'conflict', true)); + } + } else { + const lineType = type || 'context'; + + linesObj.left.push(getLineForParallelView(line, id, lineType)); + linesObj.right.push(getLineForParallelView(line, id, lineType, true)); + } + }); + + linesObj = checkLineLengths(linesObj); + }); + + for (let i = 0, len = linesObj.left.length; i < len; i += 1) { + parallelLines.push([linesObj.right[i], linesObj.left[i]]); + } + return parallelLines; +}; + +export const decorateFiles = (files) => { + return files.map((file) => { + const f = { ...file }; + f.content = ''; + f.resolutionData = {}; + f.promptDiscardConfirmation = false; + f.resolveMode = DEFAULT_RESOLVE_MODE; + f.filePath = getFilePath(file); + f.blobPath = f.blob_path; + + if (f.type === CONFLICT_TYPES.TEXT) { + f.showEditor = false; + f.loadEditor = false; + + f.inlineLines = setInlineLine(file); + f.parallelLines = setParallelLine(file); + } else if (f.type === CONFLICT_TYPES.TEXT_EDITOR) { + f.showEditor = true; + f.loadEditor = true; + } + return f; + }); +}; + +export const restoreFileLinesState = (file) => { + const inlineLines = file.inlineLines.map((line) => { + if (line.hasConflict || line.isHeader) { + return { ...line, isSelected: false, isUnselected: false }; + } + return { ...line }; + }); + + const parallelLines = file.parallelLines.map((lines) => { + const left = { ...lines[0] }; + const right = { ...lines[1] }; + const isLeftMatch = left.hasConflict || left.isHeader; + const isRightMatch = right.hasConflict || right.isHeader; + + if (isLeftMatch || isRightMatch) { + left.isSelected = false; + left.isUnselected = false; + right.isSelected = false; + right.isUnselected = false; + } + return [left, right]; + }); + return { inlineLines, parallelLines }; +}; + +export const markLine = (line, selection) => { + const updated = { ...line }; + if (selection === 'head' && line.isHead) { + updated.isSelected = true; + updated.isUnselected = false; + } else if (selection === 'origin' && updated.isOrigin) { + updated.isSelected = true; + updated.isUnselected = false; + } else { + updated.isSelected = false; + updated.isUnselected = true; + } + return updated; +}; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 4dab796d8a4..81b9db6b4d5 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -361,10 +361,8 @@ export default class MergeRequestTabs { return createElement(CommitPipelinesTable, { props: { endpoint: pipelineTableViewEl.dataset.endpoint, - helpPagePath: pipelineTableViewEl.dataset.helpPagePath, emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath, - autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath, canCreatePipelineInTargetProject: Boolean( mrWidgetData?.can_create_pipeline_in_target_project, ), diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index f249fef5ea4..280613bda49 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -2,7 +2,6 @@ import $ from 'jquery'; import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; -import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover'; export default class Milestone { constructor() { @@ -43,30 +42,4 @@ export default class Milestone { .catch(() => flash(__('Error loading milestone tab'))); } } - - static initDeprecationMessage() { - const deprecationMesssageContainer = document.querySelector( - '.js-milestone-deprecation-message', - ); - - if (!deprecationMesssageContainer) return; - - const deprecationMessage = deprecationMesssageContainer.querySelector( - '.js-milestone-deprecation-message-template', - ).innerHTML; - const $popover = $('.js-popover-link', deprecationMesssageContainer); - const hideOnScroll = togglePopover.bind($popover, false); - - $popover - .popover({ - content: deprecationMessage, - html: true, - placement: 'bottom', - }) - .on('mouseenter', mouseenter) - .on('mouseleave', debouncedMouseleave()) - .on('show.bs.popover', () => { - window.addEventListener('scroll', hideOnScroll, { once: true }); - }); - } } diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 4cbe0a53307..f4b60fc0961 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -119,7 +119,7 @@ export default class MilestoneSelect { title: __('Any milestone'), }); } - if (showNo) { + if (showNo && term.trim() === '') { extraOptions.push({ id: -1, name: __('No milestone'), diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 8522ac6a57d..a0b4fd0b608 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,7 +1,7 @@ +import * as Sentry from '@sentry/browser'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; -import * as Sentry from '~/sentry/wrapper'; import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants'; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/index.js b/app/assets/javascripts/monitoring/stores/embed_group/index.js index 773bca9f87e..66c65adc413 100644 --- a/app/assets/javascripts/monitoring/stores/embed_group/index.js +++ b/app/assets/javascripts/monitoring/stores/embed_group/index.js @@ -20,5 +20,3 @@ export const createStore = () => }, }, }); - -export default createStore(); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index 50db3b86025..08d7c745791 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -1,39 +1,61 @@ <script> -import { GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui'; +import { + GlAlert, + GlButton, + GlIcon, + GlFormCheckbox, + GlTooltipDirective, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, +} from '@gitlab/ui'; import Autosize from 'autosize'; import $ from 'jquery'; import { mapActions, mapGetters, mapState } from 'vuex'; import Autosave from '~/autosave'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { deprecatedCreateFlash as Flash } from '~/flash'; +import httpStatusCodes from '~/lib/utils/http_status'; import { capitalizeFirstCharacter, convertToCamelCase, splitCamelCase, slugifyWithUnderscore, } from '~/lib/utils/text_utility'; -import { __, sprintf } from '~/locale'; +import { sprintf } from '~/locale'; import markdownField from '~/vue_shared/components/markdown/field.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + import * as constants from '../constants'; import eventHub from '../event_hub'; +import { COMMENT_FORM } from '../i18n'; + import issuableStateMixin from '../mixins/issuable_state'; import CommentFieldLayout from './comment_field_layout.vue'; import discussionLockedWidget from './discussion_locked_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; +const { UNPROCESSABLE_ENTITY } = httpStatusCodes; + export default { name: 'CommentForm', + i18n: COMMENT_FORM, + noteTypeComment: constants.COMMENT, + noteTypeDiscussion: constants.DISCUSSION, components: { noteSignedOutWidget, discussionLockedWidget, markdownField, + GlAlert, GlButton, TimelineEntryItem, GlIcon, CommentFieldLayout, GlFormCheckbox, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, }, directives: { GlTooltip: GlTooltipDirective, @@ -49,6 +71,7 @@ export default { return { note: '', noteType: constants.COMMENT, + errors: [], noteIsConfidential: false, isSubmitting: false, }; @@ -63,6 +86,12 @@ export default { 'openState', ]), ...mapState(['isToggleStateButtonLoading']), + isNoteTypeComment() { + return this.noteType === constants.COMMENT; + }, + isNoteTypeDiscussion() { + return this.noteType === constants.DISCUSSION; + }, noteableDisplayName() { return splitCamelCase(this.noteableType).toLowerCase(); }, @@ -70,12 +99,19 @@ export default { return this.getUserData.id; }, commentButtonTitle() { - return this.noteType === constants.COMMENT ? __('Comment') : __('Start thread'); + return this.noteType === constants.COMMENT + ? this.$options.i18n.comment + : this.$options.i18n.startThread; }, startDiscussionDescription() { return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE - ? __('Discuss a specific suggestion or question that needs to be resolved.') - : __('Discuss a specific suggestion or question.'); + ? this.$options.i18n.discussionThatNeedsResolution + : this.$options.i18n.discussion; + }, + commentDescription() { + return sprintf(this.$options.i18n.submitButton.commentHelp, { + noteableDisplayName: this.noteableDisplayName, + }); }, isOpen() { return this.openState === constants.OPENED || this.openState === constants.REOPENED; @@ -90,21 +126,18 @@ export default { const openOrClose = this.isOpen ? 'close' : 'reopen'; if (this.note.length) { - return sprintf(__('%{actionText} & %{openOrClose} %{noteable}'), { + return sprintf(this.$options.i18n.actionButtonWithNote, { actionText: this.commentButtonTitle, openOrClose, noteable: this.noteableDisplayName, }); } - return sprintf(__('%{openOrClose} %{noteable}'), { + return sprintf(this.$options.i18n.actionButton, { openOrClose: capitalizeFirstCharacter(openOrClose), noteable: this.noteableDisplayName, }); }, - buttonVariant() { - return this.isOpen ? 'warning' : 'default'; - }, actionButtonClassNames() { return { 'btn-reopen': !this.isOpen, @@ -140,8 +173,8 @@ export default { }, issuableTypeTitle() { return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE - ? __('merge request') - : __('issue'); + ? this.$options.i18n.mergeRequest + : this.$options.i18n.issue; }, isIssue() { return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE; @@ -149,9 +182,6 @@ export default { trackingLabel() { return slugifyWithUnderscore(`${this.commentButtonTitle} button`); }, - hasCloseAndCommentButton() { - return !this.glFeatures.removeCommentCloseReopen; - }, confidentialNotesEnabled() { return Boolean(this.glFeatures.confidentialNotes); }, @@ -177,11 +207,19 @@ export default { 'reopenIssuable', 'toggleIssueLocalState', ]), + handleSaveError({ data, status }) { + if (status === UNPROCESSABLE_ENTITY && data.errors?.commands_only?.length) { + this.errors = data.errors.commands_only; + } else { + this.errors = [this.$options.i18n.GENERIC_UNSUBMITTABLE_NETWORK]; + } + }, handleSave(withIssueAction) { + this.errors = []; + if (this.note.length) { const noteData = { endpoint: this.endpoint, - flashContainer: this.$el, data: { note: { noteable_type: this.noteableType, @@ -212,12 +250,10 @@ export default { this.toggleIssueState(); } }) - .catch(() => { + .catch(({ response }) => { + this.handleSaveError(response); + this.discard(false); - const msg = __( - 'Your comment could not be submitted! Please check your network connection and try again.', - ); - Flash(msg, 'alert', this.$el); this.note = noteData.data.note.note; // Restore textarea content. this.removePlaceholderNotes(); }) @@ -260,6 +296,12 @@ export default { setNoteType(type) { this.noteType = type; }, + setNoteTypeToComment() { + this.setNoteType(constants.COMMENT); + }, + setNoteTypeToDiscussion() { + this.setNoteType(constants.DISCUSSION); + }, editCurrentUserLastNote() { if (this.note === '') { const lastNote = this.getCurrentUserLastNote; @@ -276,7 +318,7 @@ export default { const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); this.autosave = new Autosave($(this.$refs.textarea), [ - __('Note'), + this.$options.i18n.note, noteableType, this.getNoteableData.id, ]); @@ -290,6 +332,9 @@ export default { hasEmailParticipants() { return this.getNoteableData.issue_email_participants?.length; }, + dismissError(index) { + this.errors.splice(index, 1); + }, }, }; </script> @@ -300,7 +345,15 @@ export default { <discussion-locked-widget v-else-if="!canCreateNote" :issuable-type="issuableTypeTitle" /> <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> + <gl-alert + v-for="(error, index) in errors" + :key="index" + variant="danger" + class="gl-mb-2" + @dismiss="() => dismissError(index)" + > + {{ error }} + </gl-alert> <div class="timeline-content timeline-content-form"> <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> <comment-field-layout @@ -329,8 +382,8 @@ export default { data-qa-selector="comment_field" data-testid="comment-field" :data-supports-quick-actions="!glFeatures.tributeAutocomplete" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" + :aria-label="$options.i18n.comment" + :placeholder="$options.i18n.bodyPlaceholder" @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleSave()" @keydown.ctrl.enter="handleSave()" @@ -345,87 +398,52 @@ export default { class="gl-mb-6" data-testid="confidential-note-checkbox" > - {{ s__('Notes|Make this comment confidential') }} + {{ $options.i18n.confidential }} <gl-icon v-gl-tooltip:tooltipcontainer.bottom name="question" :size="16" - :title="s__('Notes|Confidential comments are only visible to project members')" + :title="$options.i18n.confidentialVisibility" class="gl-text-gray-500" /> </gl-form-checkbox> - <div - class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" + <gl-dropdown + split + :text="commentButtonTitle" + class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown" + category="primary" + variant="success" + :disabled="disableSubmitButton" + data-testid="comment-button" + data-qa-selector="comment_button" + :data-track-label="trackingLabel" + data-track-event="click_button" + @click="handleSave()" > - <gl-button - :disabled="disableSubmitButton" - class="js-comment-button js-comment-submit-button" - data-qa-selector="comment_button" - data-testid="comment-button" - type="submit" - category="primary" - variant="success" - :data-track-label="trackingLabel" - data-track-event="click_button" - @click.prevent="handleSave()" - >{{ commentButtonTitle }}</gl-button + <gl-dropdown-item + is-check-item + :is-checked="isNoteTypeComment" + :selected="isNoteTypeComment" + @click="setNoteTypeToComment" > - <gl-button - :disabled="disableSubmitButton" - name="button" - category="primary" - variant="success" - class="note-type-toggle js-note-new-discussion dropdown-toggle" - data-qa-selector="note_dropdown" - data-display="static" - data-toggle="dropdown" - icon="chevron-down" - :aria-label="__('Open comment type dropdown')" - /> - - <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> - <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> - <button - type="button" - class="btn btn-transparent" - @click.prevent="setNoteType('comment')" - > - <gl-icon name="check" class="icon gl-flex-shrink-0" /> - <div class="description"> - <strong>{{ __('Comment') }}</strong> - <p> - {{ - sprintf(__('Add a general comment to this %{noteableDisplayName}.'), { - noteableDisplayName, - }) - }} - </p> - </div> - </button> - </li> - <li class="divider droplab-item-ignore"></li> - <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> - <button - type="button" - class="btn btn-transparent" - data-qa-selector="discussion_menu_item" - @click.prevent="setNoteType('discussion')" - > - <gl-icon name="check" class="icon gl-flex-shrink-0" /> - <div class="description"> - <strong>{{ __('Start thread') }}</strong> - <p>{{ startDiscussionDescription }}</p> - </div> - </button> - </li> - </ul> - </div> - + <strong>{{ $options.i18n.submitButton.comment }}</strong> + <p class="gl-m-0">{{ commentDescription }}</p> + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-item + is-check-item + :is-checked="isNoteTypeDiscussion" + :selected="isNoteTypeDiscussion" + data-qa-selector="discussion_menu_item" + @click="setNoteTypeToDiscussion" + > + <strong>{{ $options.i18n.submitButton.startThread }}</strong> + <p class="gl-m-0">{{ startDiscussionDescription }}</p> + </gl-dropdown-item> + </gl-dropdown> <gl-button - v-if="hasCloseAndCommentButton && canToggleIssueState" + v-if="canToggleIssueState" :loading="isToggleStateButtonLoading" - category="secondary" - :variant="buttonVariant" :class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']" :disabled="isSubmitting" data-testid="close-reopen-button" diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 27408bc3354..6f0745d4fb0 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -50,8 +50,8 @@ export default { <div class="discussion-with-resolve-btn clearfix"> <reply-placeholder data-qa-selector="discussion_reply_tab" - :button-text="s__('MergeRequests|Reply...')" - @onClick="$emit('showReplyForm')" + :placeholder-text="__('Reply…')" + @focus="$emit('showReplyForm')" /> <div v-if="userCanResolveDiscussion" class="btn-group discussion-actions" role="group"> diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue index dfeda4aae7c..663163a7552 100644 --- a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue +++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue @@ -20,7 +20,7 @@ export default { 'li', { class: - 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base gl-overflow-hidden clearfix', + 'discussion-collapsible gl-border-solid gl-border-gray-100 gl-border-1 gl-rounded-base clearfix', }, [h('ul', { class: 'notes' }, children)], ); diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue index 0204169214b..1165a869d2b 100644 --- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue +++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue @@ -1,23 +1,30 @@ <script> +import { __ } from '~/locale'; + export default { name: 'ReplyPlaceholder', props: { - buttonText: { + placeholderText: { + type: String, + required: false, + default: __('Reply…'), + }, + labelText: { type: String, - required: true, + required: false, + default: __('Reply to comment'), }, }, }; </script> <template> - <button - ref="button" - type="button" - class="js-vue-discussion-reply btn btn-text-field" - :title="s__('MergeRequests|Add a reply')" - @click="$emit('onClick')" - > - {{ buttonText }} - </button> + <textarea + ref="textarea" + rows="1" + class="reply-placeholder-text-field js-vue-discussion-reply" + :placeholder="placeholderText" + :aria-label="labelText" + @focus="$emit('focus')" + ></textarea> </template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 907a4316a93..ed6701b34e8 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,12 +1,14 @@ <script> import { GlTooltipDirective, GlIcon, GlButton, GlDropdownItem } from '@gitlab/ui'; -import { mapGetters } from 'vuex'; +import { mapActions, mapGetters } from 'vuex'; import Api from '~/api'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; import { deprecatedCreateFlash as flash } from '~/flash'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import eventHub from '~/sidebar/event_hub'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { splitCamelCase } from '../../lib/utils/text_utility'; import ReplyButton from './note_actions/reply_button.vue'; @@ -17,11 +19,13 @@ export default { ReplyButton, GlButton, GlDropdownItem, + UserAccessRoleBadge, + EmojiPicker: () => import('~/emoji/components/picker.vue'), }, directives: { GlTooltip: GlTooltipDirective, }, - mixins: [resolvedStatusMixin], + mixins: [resolvedStatusMixin, glFeatureFlagsMixin()], props: { author: { type: Object, @@ -115,6 +119,10 @@ export default { type: Boolean, required: true, }, + awardPath: { + type: String, + required: true, + }, }, computed: { ...mapGetters(['getUserDataByProp', 'getNoteableData']), @@ -183,6 +191,7 @@ export default { }, }, methods: { + ...mapActions(['toggleAwardRequest']), onEdit() { this.$emit('handleEdit'); }, @@ -220,30 +229,43 @@ export default { .catch(() => flash(__('Something went wrong while updating assignees'))); } }, + setAwardEmoji(awardName) { + this.toggleAwardRequest({ + endpoint: this.awardPath, + noteId: this.noteId, + awardName, + }); + }, }, }; </script> <template> <div class="note-actions"> - <span + <user-access-role-badge v-if="isAuthor" - class="note-role user-access-role has-tooltip d-none d-md-inline-block" + v-gl-tooltip + class="gl-mx-3 d-none d-md-inline-block" :title="displayAuthorBadgeText" - >{{ __('Author') }}</span > - <span + {{ __('Author') }} + </user-access-role-badge> + <user-access-role-badge v-if="accessLevel" - class="note-role user-access-role has-tooltip" + v-gl-tooltip + class="gl-mx-3" :title="displayMemberBadgeText" - >{{ accessLevel }}</span > - <span + {{ accessLevel }} + </user-access-role-badge> + <user-access-role-badge v-else-if="isContributor" - class="note-role user-access-role has-tooltip" + v-gl-tooltip + class="gl-mx-3" :title="displayContributorBadgeText" - >{{ __('Contributor') }}</span > + {{ __('Contributor') }} + </user-access-role-badge> <gl-button v-if="canResolve" ref="resolveButton" @@ -259,19 +281,41 @@ export default { class="line-resolve-btn note-action-button" @click="onResolve" /> - <a - v-if="canAwardEmoji" - v-gl-tooltip - :class="{ 'js-user-authored': isAuthoredByCurrentUser }" - class="note-action-button note-emoji-button js-add-award js-note-emoji gl-text-gray-600 gl-m-2" - href="#" - title="Add reaction" - data-position="right" - > - <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="smile" /> - </a> + <template v-if="canAwardEmoji"> + <emoji-picker + v-if="glFeatures.improvedEmojiPicker" + toggle-class="note-action-button note-emoji-button gl-text-gray-600 gl-m-2 gl-p-0! gl-shadow-none! gl-bg-transparent!" + @click="setAwardEmoji" + > + <template #button-content> + <gl-icon class="link-highlight award-control-icon-neutral gl-m-0!" name="slight-smile" /> + <gl-icon class="link-highlight award-control-icon-positive gl-m-0!" name="smiley" /> + <gl-icon class="link-highlight award-control-icon-super-positive gl-m-0!" name="smile" /> + </template> + </emoji-picker> + <gl-button + v-else + v-gl-tooltip + :class="{ 'js-user-authored': isAuthoredByCurrentUser }" + class="note-action-button note-emoji-button add-reaction-button js-add-award js-note-emoji" + category="tertiary" + variant="default" + size="small" + title="Add reaction" + data-position="right" + :aria-label="__('Add reaction')" + > + <span class="reaction-control-icon reaction-control-icon-neutral"> + <gl-icon name="slight-smile" /> + </span> + <span class="reaction-control-icon reaction-control-icon-positive"> + <gl-icon name="smiley" /> + </span> + <span class="reaction-control-icon reaction-control-icon-super-positive"> + <gl-icon name="smile" /> + </span> + </gl-button> + </template> <reply-button v-if="showReply" ref="replyButton" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 653bc450d0b..d74ade15de1 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -201,7 +201,7 @@ export default { changedCommentText() { return sprintf( __( - 'This comment has changed since you started editing, please review the %{startTag}updated comment%{endTag} to ensure information is not lost.', + 'This comment changed after you started editing it. Review the %{startTag}updated comment%{endTag} to ensure information is not lost.', ), { startTag: `<a href="${this.noteHash}" target="_blank" rel="noopener noreferrer">`, @@ -345,7 +345,7 @@ export default { class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" data-qa-selector="reply_field" dir="auto" - :aria-label="__('Description')" + :aria-label="__('Reply to comment')" :placeholder="__('Write a comment or drag your files here…')" @keydown.meta.enter="handleKeySubmit()" @keydown.ctrl.enter="handleKeySubmit()" diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 4343fac3cfa..185f4a70367 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -1,7 +1,7 @@ <script> import { GlSprintf, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; import $ from 'jquery'; -import { escape } from 'lodash'; +import { escape, isEmpty } from 'lodash'; import { mapGetters, mapActions } from 'vuex'; import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -282,9 +282,13 @@ export default { note: { target_type: this.getNoteableData.targetType, target_id: this.note.noteable_id, - note: { note: noteText, position: JSON.stringify(position) }, + note: { note: noteText }, }, }; + + // Stringifying an empty object yields `{}` which breaks graphql queries + // https://gitlab.com/gitlab-org/gitlab/-/issues/298827 + if (!isEmpty(position)) data.note.note.position = JSON.stringify(position); this.isRequesting = true; this.oldContent = this.note.note_html; // eslint-disable-next-line vue/no-mutating-props @@ -416,6 +420,7 @@ export default { :is-draft="note.isDraft" :resolve-discussion="note.isDraft && note.resolve_discussion" :discussion-id="discussionId" + :award-path="note.toggle_award_path" @handleEdit="editHandler" @handleDelete="deleteHandler" @handleResolve="resolveHandler" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index 2d66e0d24e3..58cfd150659 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -17,6 +17,7 @@ import commentForm from './comment_form.vue'; import discussionFilterNote from './discussion_filter_note.vue'; import noteableDiscussion from './noteable_discussion.vue'; import noteableNote from './noteable_note.vue'; +import SidebarSubscription from './sidebar_subscription.vue'; export default { name: 'NotesApp', @@ -30,6 +31,7 @@ export default { skeletonLoadingContainer, discussionFilterNote, OrderedLayout, + SidebarSubscription, }, mixins: [glFeatureFlagsMixin()], props: { @@ -261,6 +263,7 @@ export default { <template> <div v-show="shouldShow" id="notes"> + <sidebar-subscription :iid="noteableData.iid" :noteable-data="noteableData" /> <ordered-layout :slot-keys="slotKeys"> <template #form> <comment-form diff --git a/app/assets/javascripts/notes/components/sidebar_subscription.vue b/app/assets/javascripts/notes/components/sidebar_subscription.vue new file mode 100644 index 00000000000..047c04c8482 --- /dev/null +++ b/app/assets/javascripts/notes/components/sidebar_subscription.vue @@ -0,0 +1,58 @@ +<script> +import { mapActions } from 'vuex'; +import { IssuableType } from '~/issue_show/constants'; +import { fetchPolicies } from '~/lib/graphql'; +import { confidentialityQueries } from '~/sidebar/constants'; +import { defaultClient as gqlClient } from '~/sidebar/graphql'; + +export default { + props: { + noteableData: { + type: Object, + required: true, + }, + iid: { + type: Number, + required: true, + }, + }, + computed: { + fullPath() { + if (this.noteableData.web_url) { + return this.noteableData.web_url.split('/-/')[0].substring(1).replace('groups/', ''); + } + return null; + }, + issuableType() { + return this.noteableData.noteableType.toLowerCase(); + }, + }, + created() { + if (this.issuableType !== IssuableType.Issue && this.issuableType !== IssuableType.Epic) { + return; + } + + gqlClient + .watchQuery({ + query: confidentialityQueries[this.issuableType].query, + variables: { + iid: String(this.iid), + fullPath: this.fullPath, + }, + fetchPolicy: fetchPolicies.CACHE_ONLY, + }) + .subscribe((res) => { + const issuable = res.data?.workspace?.issuable; + if (issuable) { + this.setConfidentiality(issuable.confidential); + } + }); + }, + methods: { + ...mapActions(['setConfidentiality']), + }, + render() { + return null; + }, +}; +</script> diff --git a/app/assets/javascripts/notes/i18n.js b/app/assets/javascripts/notes/i18n.js new file mode 100644 index 00000000000..1ffb94d11ad --- /dev/null +++ b/app/assets/javascripts/notes/i18n.js @@ -0,0 +1,26 @@ +import { __, s__ } from '~/locale'; + +export const COMMENT_FORM = { + GENERIC_UNSUBMITTABLE_NETWORK: __( + 'Your comment could not be submitted! Please check your network connection and try again.', + ), + note: __('Note'), + comment: __('Comment'), + issue: __('issue'), + startThread: __('Start thread'), + mergeRequest: __('merge request'), + bodyPlaceholder: __('Write a comment or drag your files here…'), + confidential: s__('Notes|Make this comment confidential'), + confidentialVisibility: s__('Notes|Confidential comments are only visible to project members'), + discussionThatNeedsResolution: __( + 'Discuss a specific suggestion or question that needs to be resolved.', + ), + discussion: __('Discuss a specific suggestion or question.'), + actionButtonWithNote: __('%{actionText} & %{openOrClose} %{noteable}'), + actionButton: __('%{openOrClose} %{noteable}'), + submitButton: { + startThread: __('Start thread'), + comment: __('Comment'), + commentHelp: __('Add a general comment to this %{noteableDisplayName}.'), + }, +}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 19403c29cda..1204d68159f 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -2,9 +2,10 @@ import $ from 'jquery'; import Visibility from 'visibilityjs'; import Vue from 'vue'; import Api from '~/api'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; -import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql'; +import { confidentialWidget } from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; import loadAwardsHandler from '../../awards_handler'; @@ -267,7 +268,7 @@ export const toggleStateButtonLoading = ({ commit }, value) => commit(types.TOGGLE_STATE_BUTTON_LOADING, value); export const emitStateChangedEvent = ({ getters }, data) => { - const event = new CustomEvent('issuable_vue_app:change', { + const event = new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, { detail: { data, isClosed: getters.openState === constants.CLOSED, @@ -340,6 +341,15 @@ export const saveNote = ({ commit, dispatch }, noteData) => { if (hasQuickActions && message) { eTagPoll.makeRequest(); + // synchronizing the quick action with the sidebar widget + // this is a temporary solution until we have confidentiality real-time updates + if ( + confidentialWidget.setConfidentiality && + message.some((m) => m.includes('confidential')) + ) { + confidentialWidget.setConfidentiality(); + } + $('.js-gfm-input').trigger('clear-commands-cache.atwho'); Flash(message || __('Commands applied'), 'notice', noteData.flashContainer); @@ -719,33 +729,3 @@ export const updateAssignees = ({ commit }, assignees) => { export const updateDiscussionPosition = ({ commit }, updatedPosition) => { commit(types.UPDATE_DISCUSSION_POSITION, updatedPosition); }; - -export const updateConfidentialityOnIssuable = ( - { getters, commit }, - { confidential, fullPath }, -) => { - const { iid } = getters.getNoteableData; - - return utils.gqClient - .mutate({ - mutation: updateIssueConfidentialMutation, - variables: { - input: { - projectPath: fullPath, - iid: String(iid), - confidential, - }, - }, - }) - .then(({ data }) => { - const { - issueSetConfidential: { issue, errors }, - } = data; - - if (errors?.length) { - Flash(errors[0], 'alert'); - } else { - setConfidentiality({ commit }, issue.confidential); - } - }); -}; diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 627e405c75c..592e634e034 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -1,4 +1,4 @@ -import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; +import { trimFirstCharOfLineContent } from '~/diffs/store/utils'; // eslint-disable-line import/no-deprecated import createGqClient, { fetchPolicies } from '~/lib/graphql'; import AjaxCache from '~/lib/utils/ajax_cache'; import { sprintf, __ } from '~/locale'; @@ -34,7 +34,7 @@ export const hasQuickActions = (note) => createQuickActionsRegex().test(note); export const stripQuickActions = (note) => note.replace(createQuickActionsRegex(), '').trim(); export const prepareDiffLines = (diffLines) => - diffLines.map((line) => ({ ...trimFirstCharOfLineContent(line) })); + diffLines.map((line) => ({ ...trimFirstCharOfLineContent(line) })); // eslint-disable-line import/no-deprecated export const gqClient = createGqClient( {}, diff --git a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue index 0f628897e17..2b5cff35fc8 100644 --- a/app/assets/javascripts/notifications/components/custom_notifications_modal.vue +++ b/app/assets/javascripts/notifications/components/custom_notifications_modal.vue @@ -24,12 +24,21 @@ export default { default: '', }, }, + model: { + prop: 'visible', + event: 'change', + }, props: { modalId: { type: String, required: false, default: 'custom-notifications-modal', }, + visible: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -95,9 +104,11 @@ export default { <template> <gl-modal ref="modal" + :visible="visible" :modal-id="modalId" :title="$options.i18n.customNotificationsModal.title" @show="onOpen" + v-on="$listeners" > <div class="container-fluid"> <div class="row"> @@ -115,7 +126,11 @@ export default { <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" /> <template v-else> <gl-form-group v-for="event in events" :key="event.id"> - <gl-form-checkbox v-model="event.enabled" @change="updateEvent($event, event)"> + <gl-form-checkbox + v-model="event.enabled" + :data-testid="`notification-setting-${event.id}`" + @change="updateEvent($event, event)" + > <strong>{{ event.name }}</strong ><gl-loading-icon v-if="event.loading" :inline="true" class="gl-ml-2" /> </gl-form-checkbox> diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown.vue b/app/assets/javascripts/notifications/components/notifications_dropdown.vue index e4cedfdb810..4963b9386c1 100644 --- a/app/assets/javascripts/notifications/components/notifications_dropdown.vue +++ b/app/assets/javascripts/notifications/components/notifications_dropdown.vue @@ -1,12 +1,5 @@ <script> -import { - GlButtonGroup, - GlButton, - GlDropdown, - GlDropdownDivider, - GlTooltipDirective, - GlModalDirective, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownDivider, GlTooltipDirective } from '@gitlab/ui'; import Api from '~/api'; import { sprintf } from '~/locale'; import { CUSTOM_LEVEL, i18n } from '../constants'; @@ -16,8 +9,6 @@ import NotificationsDropdownItem from './notifications_dropdown_item.vue'; export default { name: 'NotificationsDropdown', components: { - GlButtonGroup, - GlButton, GlDropdown, GlDropdownDivider, NotificationsDropdownItem, @@ -25,7 +16,6 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - 'gl-modal': GlModalDirective, }, inject: { containerClass: { @@ -57,6 +47,7 @@ export default { return { selectedNotificationLevel: this.initialNotificationLevel, isLoading: false, + notificationsModalVisible: false, }; }, computed: { @@ -95,6 +86,11 @@ export default { }, }, methods: { + openNotificationsModal() { + if (this.isCustomNotification) { + this.notificationsModalVisible = true; + } + }, selectItem(level) { if (level !== this.selectedNotificationLevel) { this.updateNotificationLevel(level); @@ -106,10 +102,7 @@ export default { try { await Api.updateNotificationSettings(this.projectId, this.groupId, { level }); this.selectedNotificationLevel = level; - - if (level === CUSTOM_LEVEL) { - this.$refs.customNotificationsModal.open(); - } + this.openNotificationsModal(); } catch (error) { this.$toast.show(this.$options.i18n.updateNotificationLevelErrorMessage, { type: 'error' }); } finally { @@ -125,52 +118,16 @@ export default { <template> <div :class="containerClass"> - <gl-button-group - v-if="isCustomNotification" - v-gl-tooltip="{ title: buttonTooltip }" - data-testid="notificationButton" - :size="buttonSize" - > - <gl-button - v-gl-modal="$options.modalId" - :size="buttonSize" - :icon="buttonIcon" - :loading="isLoading" - :disabled="disabled" - > - <template v-if="buttonText">{{ buttonText }}</template> - </gl-button> - <gl-dropdown :size="buttonSize" :disabled="disabled"> - <notifications-dropdown-item - v-for="item in notificationLevels" - :key="item.level" - :level="item.level" - :title="item.title" - :description="item.description" - :notification-level="selectedNotificationLevel" - @item-selected="selectItem" - /> - <gl-dropdown-divider /> - <notifications-dropdown-item - :key="$options.customLevel" - :level="$options.customLevel" - :title="$options.i18n.notificationTitles.custom" - :description="$options.i18n.notificationDescriptions.custom" - :notification-level="selectedNotificationLevel" - @item-selected="selectItem" - /> - </gl-dropdown> - </gl-button-group> - <gl-dropdown - v-else v-gl-tooltip="{ title: buttonTooltip }" - data-testid="notificationButton" - :text="buttonText" + data-testid="notification-dropdown" + :size="buttonSize" :icon="buttonIcon" :loading="isLoading" - :size="buttonSize" :disabled="disabled" + :split="isCustomNotification" + :text="buttonText" + @click="openNotificationsModal" > <notifications-dropdown-item v-for="item in notificationLevels" @@ -191,6 +148,6 @@ export default { @item-selected="selectItem" /> </gl-dropdown> - <custom-notifications-modal ref="customNotificationsModal" :modal-id="$options.modalId" /> + <custom-notifications-modal v-model="notificationsModalVisible" :modal-id="$options.modalId" /> </div> </template> diff --git a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue index 73bb9c1b36f..2138372d8ad 100644 --- a/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue +++ b/app/assets/javascripts/notifications/components/notifications_dropdown_item.vue @@ -33,7 +33,13 @@ export default { </script> <template> - <gl-dropdown-item is-check-item :is-checked="isActive" @click="$emit('item-selected', level)"> + <gl-dropdown-item + is-check-item + :is-checked="isActive" + :class="{ 'is-active': isActive }" + data-testid="notification-item" + @click="$emit('item-selected', level)" + > <div class="gl-display-flex gl-flex-direction-column"> <span class="gl-font-weight-bold">{{ title }}</span> <span class="gl-text-gray-500">{{ description }}</span> diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js index 07c569a0293..4f875977d78 100644 --- a/app/assets/javascripts/notifications/constants.js +++ b/app/assets/javascripts/notifications/constants.js @@ -22,10 +22,10 @@ export const i18n = { owner_disabled: __('Notifications have been disabled by the project or group owner'), }, updateNotificationLevelErrorMessage: __( - 'An error occured while updating the notification settings. Please try again.', + 'An error occurred while updating the notification settings. Please try again.', ), loadNotificationLevelErrorMessage: __( - 'An error occured while loading the notification settings. Please try again.', + 'An error occurred while loading the notification settings. Please try again.', ), customNotificationsModal: { title: __('Custom notification events'), @@ -53,6 +53,7 @@ export const i18n = { reassign_merge_request: s__('NotificationEvent|Reassign merge request'), reopen_issue: s__('NotificationEvent|Reopen issue'), reopen_merge_request: s__('NotificationEvent|Reopen merge request'), + merge_when_pipeline_succeeds: s__('NotificationEvent|Merge when pipeline succeeds'), success_pipeline: s__('NotificationEvent|Successful pipeline'), }, }; diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js deleted file mode 100644 index d61defed14d..00000000000 --- a/app/assets/javascripts/notifications_dropdown.js +++ /dev/null @@ -1,35 +0,0 @@ -import $ from 'jquery'; -import { Rails } from '~/lib/utils/rails_ujs'; -import { __ } from '~/locale'; -import { deprecatedCreateFlash as Flash } from './flash'; - -export default function notificationsDropdown() { - $(document).on('click', '.update-notification', function updateNotificationCallback(e) { - e.preventDefault(); - - if ($(this).is('.is-active') && $(this).data('notificationLevel') === 'custom') { - return; - } - - const notificationLevel = $(this).data('notificationLevel'); - const form = $(this).parents('.notification-form').first(); - - form.find('.js-notification-loading').toggleClass('spinner'); - if (form.hasClass('no-label')) { - form.find('.js-notification-loading').toggleClass('hidden'); - form.find('.js-notifications-icon').toggleClass('hidden'); - } - form.find('#notification_setting_level').val(notificationLevel); - Rails.fire(form[0], 'submit'); - }); - - $(document).on('ajax:success', '.notification-form', (e) => { - const data = e.detail[0]; - - if (data.saved) { - $(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html); - } else { - Flash(__('Failed to save new settings'), 'alert'); - } - }); -} diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js deleted file mode 100644 index 8b90da71bef..00000000000 --- a/app/assets/javascripts/notifications_form.js +++ /dev/null @@ -1,48 +0,0 @@ -import $ from 'jquery'; -import { deprecatedCreateFlash as flash } from './flash'; -import axios from './lib/utils/axios_utils'; -import { __ } from './locale'; - -export default class NotificationsForm { - constructor() { - this.toggleCheckbox = this.toggleCheckbox.bind(this); - this.initEventListeners(); - } - - initEventListeners() { - $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox); - } - - toggleCheckbox(e) { - const $checkbox = $(e.currentTarget); - const $parent = $checkbox.closest('.form-check'); - - this.saveEvent($checkbox, $parent); - } - - // eslint-disable-next-line class-methods-use-this - showCheckboxLoadingSpinner($parent) { - $parent.find('.is-loading').removeClass('gl-display-none'); - $parent.find('.is-done').addClass('gl-display-none'); - } - - saveEvent($checkbox, $parent) { - const form = $parent.parents('form').first(); - - this.showCheckboxLoadingSpinner($parent); - - axios[form.attr('method')](form.attr('action'), form.serialize()) - .then(({ data }) => { - $checkbox.enable(); - if (data.saved) { - $parent.find('.is-loading').addClass('gl-display-none'); - $parent.find('.is-done').removeClass('gl-display-none'); - - setTimeout(() => { - $parent.find('.is-done').addClass('gl-display-none'); - }, 2000); - } - }) - .catch(() => flash(__('There was an error saving your notification settings.'))); - } -} diff --git a/app/assets/javascripts/packages/details/components/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue index 9d87ae8f836..bf1e5083e12 100644 --- a/app/assets/javascripts/packages/details/components/composer_installation.vue +++ b/app/assets/javascripts/packages/details/components/composer_installation.vue @@ -2,12 +2,14 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import { s__ } from '~/locale'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; import { TrackingActions, TrackingLabels } from '../constants'; export default { name: 'ComposerInstallation', components: { + InstallationTitle, CodeInstruction, GlLink, GlSprintf, @@ -27,12 +29,13 @@ export default { }, trackingActions: { ...TrackingActions }, TrackingLabels, + installOptions: [{ value: 'composer', label: s__('PackageRegistry|Show Composer commands') }], }; </script> <template> <div v-if="groupExists" data-testid="root-node"> - <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <installation-title package-type="composer" :options="$options.installOptions" /> <code-instruction :label="$options.i18n.registryInclude" diff --git a/app/assets/javascripts/packages/details/components/conan_installation.vue b/app/assets/javascripts/packages/details/components/conan_installation.vue index a5df87c9c5b..1d855f6cf3e 100644 --- a/app/assets/javascripts/packages/details/components/conan_installation.vue +++ b/app/assets/javascripts/packages/details/components/conan_installation.vue @@ -2,12 +2,14 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import { s__ } from '~/locale'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; import { TrackingActions, TrackingLabels } from '../constants'; export default { name: 'ConanInstallation', components: { + InstallationTitle, CodeInstruction, GlLink, GlSprintf, @@ -23,12 +25,13 @@ export default { }, trackingActions: { ...TrackingActions }, TrackingLabels, + installOptions: [{ value: 'conan', label: s__('PackageRegistry|Show Conan commands') }], }; </script> <template> <div> - <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <installation-title package-type="conan" :options="$options.installOptions" /> <code-instruction :label="s__('PackageRegistry|Conan Command')" diff --git a/app/assets/javascripts/packages/details/components/installation_title.vue b/app/assets/javascripts/packages/details/components/installation_title.vue new file mode 100644 index 00000000000..43133bf7825 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/installation_title.vue @@ -0,0 +1,38 @@ +<script> +import PersistedDropdownSelection from '~/vue_shared/components/registry/persisted_dropdown_selection.vue'; + +export default { + name: 'InstallationTitle', + components: { + PersistedDropdownSelection, + }, + props: { + packageType: { + type: String, + required: true, + }, + options: { + type: Array, + required: true, + }, + }, + computed: { + storageKey() { + return `package_${this.packageType}_installation_instructions`; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <div> + <persisted-dropdown-selection + :storage-key="storageKey" + :options="options" + @change="$emit('change', $event)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue index c2f6f76967b..b9532cb7e72 100644 --- a/app/assets/javascripts/packages/details/components/maven_installation.vue +++ b/app/assets/javascripts/packages/details/components/maven_installation.vue @@ -2,19 +2,36 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import { s__ } from '~/locale'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; + import { TrackingActions, TrackingLabels } from '../constants'; export default { name: 'MavenInstallation', components: { + InstallationTitle, CodeInstruction, GlLink, GlSprintf, }, + data() { + return { + instructionType: 'maven', + }; + }, computed: { ...mapState(['mavenHelpPath']), - ...mapGetters(['mavenInstallationXml', 'mavenInstallationCommand', 'mavenSetupXml']), + ...mapGetters([ + 'mavenInstallationXml', + 'mavenInstallationCommand', + 'mavenSetupXml', + 'gradleGroovyInstalCommand', + 'gradleGroovyAddSourceCommand', + ]), + showMaven() { + return this.instructionType === 'maven'; + }, }, i18n: { xmlText: s__( @@ -29,57 +46,84 @@ export default { }, trackingActions: { ...TrackingActions }, TrackingLabels, + installOptions: [ + { value: 'maven', label: s__('PackageRegistry|Show Maven commands') }, + { value: 'groovy', label: s__('PackageRegistry|Show Gradle Groovy DSL commands') }, + ], }; </script> <template> <div> - <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <installation-title + package-type="maven" + :options="$options.installOptions" + @change="instructionType = $event" + /> - <p> - <gl-sprintf :message="$options.i18n.xmlText"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> + <template v-if="showMaven"> + <p> + <gl-sprintf :message="$options.i18n.xmlText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> - <code-instruction - :label="s__('PackageRegistry|Maven XML')" - :instruction="mavenInstallationXml" - :copy-text="s__('PackageRegistry|Copy Maven XML')" - multiline - :tracking-action="$options.trackingActions.COPY_MAVEN_XML" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> + <code-instruction + :instruction="mavenInstallationXml" + :copy-text="s__('PackageRegistry|Copy Maven XML')" + :tracking-action="$options.trackingActions.COPY_MAVEN_XML" + :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" + multiline + /> - <code-instruction - :label="s__('PackageRegistry|Maven Command')" - :instruction="mavenInstallationCommand" - :copy-text="s__('PackageRegistry|Copy Maven command')" - :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> + <code-instruction + :label="s__('PackageRegistry|Maven Command')" + :instruction="mavenInstallationCommand" + :copy-text="s__('PackageRegistry|Copy Maven command')" + :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND" + :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" + /> - <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> - <p> - <gl-sprintf :message="$options.i18n.setupText"> - <template #code="{ content }"> - <code>{{ content }}</code> + <h3 class="gl-font-lg">{{ s__('PackageRegistry|Registry setup') }}</h3> + <p> + <gl-sprintf :message="$options.i18n.setupText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <code-instruction + :instruction="mavenSetupXml" + :copy-text="s__('PackageRegistry|Copy Maven registry XML')" + :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP" + :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" + multiline + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> - </p> - <code-instruction - :instruction="mavenSetupXml" - :copy-text="s__('PackageRegistry|Copy Maven registry XML')" - multiline - :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP" - :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" - /> - <gl-sprintf :message="$options.i18n.helpText"> - <template #link="{ content }"> - <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> + </template> + <template v-else> + <code-instruction + class="gl-mb-5" + :label="s__('PackageRegistry|Gradle Groovy DSL install command')" + :instruction="gradleGroovyInstalCommand" + :copy-text="s__('PackageRegistry|Copy Gradle Groovy DSL install command')" + :tracking-action="$options.trackingActions.COPY_GRADLE_INSTALL_COMMAND" + :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" + /> + <code-instruction + :label="s__('PackageRegistry|Add Gradle Groovy DSL repository command')" + :instruction="gradleGroovyAddSourceCommand" + :copy-text="s__('PackageRegistry|Copy add Gradle Groovy DSL repository command')" + :tracking-action="$options.trackingActions.COPY_GRADLE_ADD_TO_SOURCE_COMMAND" + :tracking-label="$options.TrackingLabels.CODE_INSTRUCTION" + multiline + /> + </template> </div> </template> diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue index 37ba279d098..18f15e2c63e 100644 --- a/app/assets/javascripts/packages/details/components/npm_installation.vue +++ b/app/assets/javascripts/packages/details/components/npm_installation.vue @@ -2,12 +2,14 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import { s__ } from '~/locale'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; import { NpmManager, TrackingActions, TrackingLabels } from '../constants'; export default { name: 'NpmInstallation', components: { + InstallationTitle, CodeInstruction, GlLink, GlSprintf, @@ -35,12 +37,13 @@ export default { }, trackingActions: { ...TrackingActions }, TrackingLabels, + installOptions: [{ value: 'npm', label: s__('PackageRegistry|Show NPM commands') }], }; </script> <template> <div> - <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <installation-title package-type="npm" :options="$options.installOptions" /> <code-instruction :label="s__('PackageRegistry|npm command')" diff --git a/app/assets/javascripts/packages/details/components/nuget_installation.vue b/app/assets/javascripts/packages/details/components/nuget_installation.vue index 36887703716..d5e64722f24 100644 --- a/app/assets/javascripts/packages/details/components/nuget_installation.vue +++ b/app/assets/javascripts/packages/details/components/nuget_installation.vue @@ -2,12 +2,14 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import { s__ } from '~/locale'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; import { TrackingActions, TrackingLabels } from '../constants'; export default { name: 'NugetInstallation', components: { + InstallationTitle, CodeInstruction, GlLink, GlSprintf, @@ -23,12 +25,14 @@ export default { }, trackingActions: { ...TrackingActions }, TrackingLabels, + installOptions: [{ value: 'nuget', label: s__('PackageRegistry|Show Nuget commands') }], }; </script> <template> <div> - <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <installation-title package-type="nuget" :options="$options.installOptions" /> + <code-instruction :label="s__('PackageRegistry|NuGet Command')" :instruction="nugetInstallationCommand" diff --git a/app/assets/javascripts/packages/details/components/pypi_installation.vue b/app/assets/javascripts/packages/details/components/pypi_installation.vue index f87be68be48..fe4709d5feb 100644 --- a/app/assets/javascripts/packages/details/components/pypi_installation.vue +++ b/app/assets/javascripts/packages/details/components/pypi_installation.vue @@ -2,12 +2,14 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { mapGetters, mapState } from 'vuex'; import { s__ } from '~/locale'; +import InstallationTitle from '~/packages/details/components/installation_title.vue'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; import { TrackingActions, TrackingLabels } from '../constants'; export default { name: 'PyPiInstallation', components: { + InstallationTitle, CodeInstruction, GlLink, GlSprintf, @@ -26,12 +28,13 @@ export default { }, trackingActions: { ...TrackingActions }, TrackingLabels, + installOptions: [{ value: 'pypi', label: s__('PackageRegistry|Show PyPi commands') }], }; </script> <template> <div> - <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <installation-title package-type="pypi" :options="$options.installOptions" /> <code-instruction :label="s__('PackageRegistry|Pip Command')" diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js index 986b0667356..f0300ee29b4 100644 --- a/app/assets/javascripts/packages/details/constants.js +++ b/app/assets/javascripts/packages/details/constants.js @@ -35,6 +35,9 @@ export const TrackingActions = { COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND: 'copy_composer_registry_include_command', COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND: 'copy_composer_package_include_command', + + COPY_GRADLE_INSTALL_COMMAND: 'copy_gradle_install_command', + COPY_GRADLE_ADD_TO_SOURCE_COMMAND: 'copy_gradle_add_to_source_command', }; export const NpmManager = { diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js index 14e76ac84bd..fd75d476b86 100644 --- a/app/assets/javascripts/packages/details/store/getters.js +++ b/app/assets/javascripts/packages/details/store/getters.js @@ -110,4 +110,20 @@ export const composerPackageInclude = ({ packageEntity }) => // eslint-disable-next-line @gitlab/require-i18n-strings `composer req ${[packageEntity.name]}:${packageEntity.version}`; +export const gradleGroovyInstalCommand = ({ packageEntity }) => { + const { + app_group: group = '', + app_name: name = '', + app_version: version = '', + } = packageEntity.maven_metadatum; + // eslint-disable-next-line @gitlab/require-i18n-strings + return `implementation '${group}:${name}:${version}'`; +}; + +export const gradleGroovyAddSourceCommand = ({ mavenPath }) => + // eslint-disable-next-line @gitlab/require-i18n-strings + `gitlab { + url "${mavenPath}" +}`; + export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0; diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js index d47eb8c3421..25a55200df2 100644 --- a/app/assets/javascripts/packages/list/constants.js +++ b/app/assets/javascripts/packages/list/constants.js @@ -71,7 +71,7 @@ export const PACKAGE_TYPES = [ type: PackageType.MAVEN, }, { - title: s__('PackageRegistry|NPM'), + title: s__('PackageRegistry|npm'), type: PackageType.NPM, }, { diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue index cdfe042c39f..172b356227a 100644 --- a/app/assets/javascripts/packages/shared/components/package_list_row.vue +++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue @@ -123,7 +123,7 @@ export default { <gl-button data-testid="action-delete" icon="remove" - category="primary" + category="secondary" variant="danger" :title="s__('PackageRegistry|Remove package')" :aria-label="s__('PackageRegistry|Remove package')" diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js index 677550f77ec..d34372e89b6 100644 --- a/app/assets/javascripts/packages/shared/utils.js +++ b/app/assets/javascripts/packages/shared/utils.js @@ -14,7 +14,7 @@ export const getPackageTypeLabel = (packageType) => { case PackageType.MAVEN: return s__('PackageType|Maven'); case PackageType.NPM: - return s__('PackageType|NPM'); + return s__('PackageType|npm'); case PackageType.NUGET: return s__('PackageType|NuGet'); case PackageType.PYPI: diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue index 933cbeaedce..4f5c53ed4a3 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue @@ -111,7 +111,10 @@ export default { {{ alertMessage }} </gl-alert> - <settings-block :default-expanded="defaultExpanded"> + <settings-block + :default-expanded="defaultExpanded" + data-qa-selector="package_registry_settings_content" + > <template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template> <template #description> <span data-testid="description"> diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue index aa7c6e9d8d6..d4f51b83e1e 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/maven_settings.vue @@ -79,11 +79,12 @@ export default { <form> <div class="gl-display-flex"> <gl-toggle + data-qa-selector="allow_duplicates_toggle" :value="mavenDuplicatesAllowed" @change="update($options.modelNames.MAVEN_DUPLICATES_ALLOWED, $event)" /> <div class="gl-ml-5"> - <div data-testid="toggle-label"> + <div data-testid="toggle-label" data-qa-selector="allow_duplicates_label"> <gl-sprintf :message="enabledButtonLabel"> <template #bold="{ content }"> <strong>{{ content }}</strong> diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js index e92262852cf..2732fc191be 100644 --- a/app/assets/javascripts/pages/admin/admin.js +++ b/app/assets/javascripts/pages/admin/admin.js @@ -12,8 +12,6 @@ function showDenylistType() { } export default function adminInit() { - const modal = $('.change-owner-holder'); - $('input#user_force_random_password').on('change', function randomPasswordClick() { const $elems = $('#user_password, #user_password_confirmation'); if ($(this).attr('checked')) { @@ -28,36 +26,6 @@ export default function adminInit() { $('.js-toggle-colors-container').toggleClass('hide'); }); - $('.log-tabs a').on('click', function logTabsClick(e) { - e.preventDefault(); - $(this).tab('show'); - }); - - $('.log-bottom').on('click', (e) => { - e.preventDefault(); - const $visibleLog = $('.file-content:visible'); - - // eslint-disable-next-line no-jquery/no-animate - $visibleLog.animate( - { - scrollTop: $visibleLog.find('ol').height(), - }, - 'fast', - ); - }); - - $('.change-owner-link').on('click', function changeOwnerLinkClick(e) { - e.preventDefault(); - $(this).hide(); - modal.show(); - }); - - $('.change-owner-cancel-link').on('click', (e) => { - e.preventDefault(); - modal.hide(); - $('.change-owner-link').show(); - }); - $('li.project_member, li.group_member').on('ajax:success', refreshCurrentPage); $("input[name='denylist_type']").on('click', showDenylistType); 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 220fc049562..cf06ee2c22a 100644 --- a/app/assets/javascripts/pages/admin/dev_ops_report/index.js +++ b/app/assets/javascripts/pages/admin/dev_ops_report/index.js @@ -1,5 +1,3 @@ -import initDevopAdoption from 'ee_else_ce/admin/dev_ops_report/devops_adoption'; -import initDevOpsScoreEmptyState from '~/admin/dev_ops_report/devops_score_empty_state'; +import initDevOpsScoreEmptyState from '~/analytics/devops_report/devops_score_empty_state'; initDevOpsScoreEmptyState(); -initDevopAdoption(); diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js index ae2209b0292..dc1bb88bf4b 100644 --- a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js +++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js @@ -1,3 +1,3 @@ -import initExpiresAtField from '~/access_tokens'; +import { initExpiresAtField } from '~/access_tokens'; -document.addEventListener('DOMContentLoaded', initExpiresAtField); +initExpiresAtField(); diff --git a/app/assets/javascripts/pages/admin/instance_statistics/index.js b/app/assets/javascripts/pages/admin/instance_statistics/index.js deleted file mode 100644 index d6b0a834ce3..00000000000 --- a/app/assets/javascripts/pages/admin/instance_statistics/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initInstanceStatisticsApp from '~/analytics/instance_statistics'; - -document.addEventListener('DOMContentLoaded', () => initInstanceStatisticsApp()); diff --git a/app/assets/javascripts/pages/admin/usage_trends/index.js b/app/assets/javascripts/pages/admin/usage_trends/index.js new file mode 100644 index 00000000000..23d2bd85979 --- /dev/null +++ b/app/assets/javascripts/pages/admin/usage_trends/index.js @@ -0,0 +1,3 @@ +import initUsageTrendsApp from '~/analytics/usage_trends'; + +initUsageTrendsApp(); diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index 3ad95fb1318..3e09b1796b1 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -4,13 +4,11 @@ import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import projectSelect from '~/project_select'; -document.addEventListener('DOMContentLoaded', () => { - initFilteredSearch({ - page: FILTERED_SEARCH.ISSUES, - filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, - useDefaultState: true, - }); - - projectSelect(); - initManualOrdering(); +initFilteredSearch({ + page: FILTERED_SEARCH.ISSUES, + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, }); + +projectSelect(); +initManualOrdering(); diff --git a/app/assets/javascripts/pages/dashboard/milestones/show/index.js b/app/assets/javascripts/pages/dashboard/milestones/show/index.js index 8b529585898..397149aaa9e 100644 --- a/app/assets/javascripts/pages/dashboard/milestones/show/index.js +++ b/app/assets/javascripts/pages/dashboard/milestones/show/index.js @@ -6,6 +6,4 @@ document.addEventListener('DOMContentLoaded', () => { new Milestone(); // eslint-disable-line no-new new Sidebar(); // eslint-disable-line no-new new MountMilestoneSidebar(); // eslint-disable-line no-new - - Milestone.initDeprecationMessage(); }); diff --git a/app/assets/javascripts/pages/dashboard/projects/index/index.js b/app/assets/javascripts/pages/dashboard/projects/index/index.js index b3c95f4ac1f..c34d15b869a 100644 --- a/app/assets/javascripts/pages/dashboard/projects/index/index.js +++ b/app/assets/javascripts/pages/dashboard/projects/index/index.js @@ -1,8 +1,5 @@ import ProjectsList from '~/projects_list'; import initCustomizeHomepageBanner from './init_customize_homepage_banner'; -document.addEventListener('DOMContentLoaded', () => { - new ProjectsList(); // eslint-disable-line no-new - - initCustomizeHomepageBanner(); -}); +new ProjectsList(); // eslint-disable-line no-new +initCustomizeHomepageBanner(); diff --git a/app/assets/javascripts/pages/dashboard/todos/index/index.js b/app/assets/javascripts/pages/dashboard/todos/index/index.js index 9d2c2f2994f..2fe90c24e77 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/index.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/index.js @@ -1,3 +1,3 @@ import Todos from './todos'; -document.addEventListener('DOMContentLoaded', () => new Todos()); +new Todos(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/activity/index.js b/app/assets/javascripts/pages/groups/activity/index.js index 1b887cad496..8b7c36a0976 100644 --- a/app/assets/javascripts/pages/groups/activity/index.js +++ b/app/assets/javascripts/pages/groups/activity/index.js @@ -1,3 +1,4 @@ import Activities from '~/activities'; -document.addEventListener('DOMContentLoaded', () => new Activities()); +// eslint-disable-next-line no-new +new Activities(); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 95ee512b71a..176d2406751 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -6,6 +6,7 @@ import TransferDropdown from '~/groups/transfer_dropdown'; import groupsSelect from '~/groups_select'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import projectSelect from '~/project_select'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; import setupTransferEdit from '~/transfer_edit'; @@ -24,5 +25,7 @@ document.addEventListener('DOMContentLoaded', () => { projectSelect(); + initSearchSettings(); + return new TransferDropdown(); }); diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js index 3496f699b06..ab70fa572ba 100644 --- a/app/assets/javascripts/pages/groups/group_members/index.js +++ b/app/assets/javascripts/pages/groups/group_members/index.js @@ -1,11 +1,13 @@ import Vue from 'vue'; import { groupMemberRequestFormatter } from '~/groups/members/utils'; import groupsSelect from '~/groups_select'; +import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; +import initInviteMembersForm from '~/invite_members/init_invite_members_form'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { s__ } from '~/locale'; import memberExpirationDate from '~/member_expiration_date'; -import { initMembersApp } from '~/members/index'; +import { initMembersApp } from '~/members'; import { groupLinkRequestFormatter } from '~/members/utils'; import UsersSelect from '~/users_select'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; @@ -70,5 +72,10 @@ memberExpirationDate('.js-access-expiration-date-groups'); mountRemoveMemberModal(); initInviteMembersModal(); initInviteMembersTrigger(); +initInviteGroupTrigger(); + +// This is only used when `invite_members_group_modal` feature flag is disabled. +// This can be removed when `invite_members_group_modal` feature flag is removed. +initInviteMembersForm(); new UsersSelect(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/groups/milestones/show/index.js b/app/assets/javascripts/pages/groups/milestones/show/index.js index 3094fe5cd21..2a2cc5faebe 100644 --- a/app/assets/javascripts/pages/groups/milestones/show/index.js +++ b/app/assets/javascripts/pages/groups/milestones/show/index.js @@ -1,9 +1,7 @@ -import Milestone from '~/milestone'; import initDeleteMilestoneModal from '~/pages/milestones/shared/delete_milestone_modal_init'; import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; document.addEventListener('DOMContentLoaded', () => { initMilestonesShow(); initDeleteMilestoneModal(); - Milestone.initDeprecationMessage(); }); diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 63515abe698..322ad2c79e7 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import BindInOut from '~/behaviors/bind_in_out'; import initFilePickers from '~/file_pickers'; import Group from '~/group'; +import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; import GroupPathValidator from './group_path_validator'; const parentId = $('#group_parent_id'); @@ -12,3 +13,16 @@ BindInOut.initAll(); initFilePickers(); new Group(); // eslint-disable-line no-new + +const CONTAINER_SELECTOR = '.group-edit-container .nav-tabs'; +const DEFAULT_ACTION = '#create-group-pane'; +// eslint-disable-next-line no-new +new LinkedTabs({ + defaultAction: DEFAULT_ACTION, + parentEl: CONTAINER_SELECTOR, + hashedTabs: true, +}); + +if (window.location.hash) { + $(CONTAINER_SELECTOR).find(`a[href="${window.location.hash}"]`).tab('show'); +} 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 378b8663777..0c3fdcf3e75 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -4,21 +4,22 @@ import initSharedRunnersForm from '~/group_settings/mount_shared_runners'; import { FILTERED_SEARCH } from '~/pages/constants'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; -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, - }); +initFilteredSearch({ + page: FILTERED_SEARCH.ADMIN_RUNNERS, + filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys, + anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR, + useDefaultState: false, +}); - initSharedRunnersForm(); - initVariableList(); +initSharedRunnersForm(); +initVariableList(); - initInstallRunner(); -}); +initInstallRunner(); + +initSearchSettings(); diff --git a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js index ba4b271f09e..a8698e10c57 100644 --- a/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js +++ b/app/assets/javascripts/pages/groups/settings/integrations/edit/index.js @@ -1,13 +1,11 @@ import IntegrationSettingsForm from '~/integrations/integration_settings_form'; import PrometheusMetrics from '~/prometheus_metrics/prometheus_metrics'; -document.addEventListener('DOMContentLoaded', () => { - const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); - const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - integrationSettingsForm.init(); +const prometheusSettingsWrapper = document.querySelector('.js-prometheus-metrics-monitoring'); +const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); +integrationSettingsForm.init(); - if (prometheusSettingsWrapper) { - const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); - prometheusMetrics.loadActiveMetrics(); - } -}); +if (prometheusSettingsWrapper) { + const prometheusMetrics = new PrometheusMetrics('.js-prometheus-metrics-monitoring'); + prometheusMetrics.loadActiveMetrics(); +} diff --git a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js index 3b922622d2c..d13bf026777 100644 --- a/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js +++ b/app/assets/javascripts/pages/groups/settings/packages_and_registries/index.js @@ -1,3 +1,6 @@ import bundle from '~/packages_and_registries/settings/group/bundle'; +import initSearchSettings from '~/search_settings'; bundle(); + +document.addEventListener('DOMContentLoaded', initSearchSettings); diff --git a/app/assets/javascripts/pages/groups/settings/repository/show/index.js b/app/assets/javascripts/pages/groups/settings/repository/show/index.js index a1bcf6dbf57..2c9867653de 100644 --- a/app/assets/javascripts/pages/groups/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/repository/show/index.js @@ -1,9 +1,10 @@ import DueDateSelectors from '~/due_date_select'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; -document.addEventListener('DOMContentLoaded', () => { - // Initialize expandable settings panels - initSettingsPanels(); +// Initialize expandable settings panels +initSettingsPanels(); - new DueDateSelectors(); // eslint-disable-line no-new -}); +new DueDateSelectors(); // eslint-disable-line no-new + +initSearchSettings(); diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js index 8c272e561db..9e75985c130 100644 --- a/app/assets/javascripts/pages/groups/shared/group_details.js +++ b/app/assets/javascripts/pages/groups/shared/group_details.js @@ -3,12 +3,8 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants'; import initInviteMembersBanner from '~/groups/init_invite_members_banner'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import { getPagePath, getDashPath } from '~/lib/utils/common_utils'; import initNotificationsDropdown from '~/notifications'; -import notificationsDropdown from '~/notifications_dropdown'; -import NotificationsForm from '~/notifications_form'; import ProjectsList from '~/projects_list'; import GroupTabs from './group_tabs'; @@ -22,17 +18,10 @@ export default function initGroupDetails(actionName = 'show') { new GroupTabs({ parentEl: '.groups-listing', action }); new ShortcutsNavigation(); - new NotificationsForm(); - if (gon.features?.vueNotificationDropdown) { - initNotificationsDropdown(); - } else { - notificationsDropdown(); - } + initNotificationsDropdown(); new ProjectsList(); initInviteMembersBanner(); - initInviteMembersModal(); - initInviteMembersTrigger(); } diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js index 82ee5ead83d..e4a84dd5eec 100644 --- a/app/assets/javascripts/pages/groups/show/index.js +++ b/app/assets/javascripts/pages/groups/show/index.js @@ -1,7 +1,5 @@ import leaveByUrl from '~/namespaces/leave_by_url'; import initGroupDetails from '../shared/group_details'; -document.addEventListener('DOMContentLoaded', () => { - leaveByUrl('group'); - initGroupDetails(); -}); +leaveByUrl('group'); +initGroupDetails(); diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js index 535fe5fa4eb..80bc32dd43f 100644 --- a/app/assets/javascripts/pages/profiles/index.js +++ b/app/assets/javascripts/pages/profiles/index.js @@ -3,21 +3,19 @@ import '~/profile/gl_crop'; import Profile from '~/profile/profile'; import initSearchSettings from '~/search_settings'; -document.addEventListener('DOMContentLoaded', () => { - // eslint-disable-next-line func-names - $(document).on('input.ssh_key', '#key_key', function () { - const $title = $('#key_title'); - const comment = $(this) - .val() - .match(/^\S+ \S+ (.+)\n?$/); +// eslint-disable-next-line func-names +$(document).on('input.ssh_key', '#key_key', function () { + const $title = $('#key_title'); + const comment = $(this) + .val() + .match(/^\S+ \S+ (.+)\n?$/); - // Extract the SSH Key title from its comment - if (comment && comment.length > 1) { - $title.val(comment[1]).change(); - } - }); + // Extract the SSH Key title from its comment + if (comment && comment.length > 1) { + $title.val(comment[1]).change(); + } +}); - new Profile(); // eslint-disable-line no-new +new Profile(); // eslint-disable-line no-new - initSearchSettings(); -}); +initSearchSettings(); diff --git a/app/assets/javascripts/pages/profiles/index/index.js b/app/assets/javascripts/pages/profiles/index/index.js deleted file mode 100644 index 586b3be8661..00000000000 --- a/app/assets/javascripts/pages/profiles/index/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import notificationsDropdown from '../../../notifications_dropdown'; -import NotificationsForm from '../../../notifications_form'; - -document.addEventListener('DOMContentLoaded', () => { - new NotificationsForm(); // eslint-disable-line no-new - notificationsDropdown(); -}); diff --git a/app/assets/javascripts/pages/profiles/notifications/show/index.js b/app/assets/javascripts/pages/profiles/notifications/show/index.js index 639f5deb72c..51ba6c7a01e 100644 --- a/app/assets/javascripts/pages/profiles/notifications/show/index.js +++ b/app/assets/javascripts/pages/profiles/notifications/show/index.js @@ -1,9 +1,5 @@ import initNotificationsDropdown from '~/notifications'; -import notificationsDropdown from '../../../../notifications_dropdown'; -import NotificationsForm from '../../../../notifications_form'; document.addEventListener('DOMContentLoaded', () => { - new NotificationsForm(); // eslint-disable-line no-new - notificationsDropdown(); initNotificationsDropdown(); }); diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js index ae2209b0292..fdbfc35456f 100644 --- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js +++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js @@ -1,3 +1,4 @@ -import initExpiresAtField from '~/access_tokens'; +import { initExpiresAtField, initProjectsField } from '~/access_tokens'; -document.addEventListener('DOMContentLoaded', initExpiresAtField); +initExpiresAtField(); +initProjectsField(); diff --git a/app/assets/javascripts/pages/projects/blob/edit/index.js b/app/assets/javascripts/pages/projects/blob/edit/index.js index 189053f3ed7..ed416610173 100644 --- a/app/assets/javascripts/pages/projects/blob/edit/index.js +++ b/app/assets/javascripts/pages/projects/blob/edit/index.js @@ -1,3 +1,3 @@ import initBlobBundle from '~/blob_edit/blob_bundle'; -document.addEventListener('DOMContentLoaded', initBlobBundle); +initBlobBundle(); diff --git a/app/assets/javascripts/pages/projects/blob/new/index.js b/app/assets/javascripts/pages/projects/blob/new/index.js index 189053f3ed7..ed416610173 100644 --- a/app/assets/javascripts/pages/projects/blob/new/index.js +++ b/app/assets/javascripts/pages/projects/blob/new/index.js @@ -1,3 +1,3 @@ import initBlobBundle from '~/blob_edit/blob_bundle'; -document.addEventListener('DOMContentLoaded', initBlobBundle); +initBlobBundle(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 61ff1c95a38..10bac6d60c2 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -7,61 +7,59 @@ import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import '~/sourcegraph/load'; -document.addEventListener('DOMContentLoaded', () => { - new BlobViewer(); // eslint-disable-line no-new - initBlob(); +new BlobViewer(); // eslint-disable-line no-new +initBlob(); - const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); - const statusLink = document.querySelector('.commit-actions .ci-status-link'); - if (statusLink) { - statusLink.remove(); - // eslint-disable-next-line no-new - new Vue({ - el: CommitPipelineStatusEl, - components: { - commitPipelineStatus, - }, - render(createElement) { - return createElement('commit-pipeline-status', { - props: { - endpoint: CommitPipelineStatusEl.dataset.endpoint, - }, - }); - }, - }); - } +const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); +const statusLink = document.querySelector('.commit-actions .ci-status-link'); +if (statusLink) { + statusLink.remove(); + // eslint-disable-next-line no-new + new Vue({ + el: CommitPipelineStatusEl, + components: { + commitPipelineStatus, + }, + render(createElement) { + return createElement('commit-pipeline-status', { + props: { + endpoint: CommitPipelineStatusEl.dataset.endpoint, + }, + }); + }, + }); +} - initWebIdeLink({ el: document.getElementById('js-blob-web-ide-link') }); +initWebIdeLink({ el: document.getElementById('js-blob-web-ide-link') }); - GpgBadges.fetch(); +GpgBadges.fetch(); - const codeNavEl = document.getElementById('js-code-navigation'); +const codeNavEl = document.getElementById('js-code-navigation'); - if (codeNavEl) { - const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset; +if (codeNavEl) { + const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset; - // eslint-disable-next-line promise/catch-or-return - import('~/code_navigation').then((m) => - m.default({ - blobs: [{ path: blobPath, codeNavigationPath }], - definitionPathPrefix, - }), - ); - } + // eslint-disable-next-line promise/catch-or-return + import('~/code_navigation').then((m) => + m.default({ + blobs: [{ path: blobPath, codeNavigationPath }], + definitionPathPrefix, + }), + ); +} - const successPipelineEl = document.querySelector('.js-success-pipeline-modal'); +const successPipelineEl = document.querySelector('.js-success-pipeline-modal'); - if (successPipelineEl) { - // eslint-disable-next-line no-new - new Vue({ - el: successPipelineEl, - render(createElement) { - return createElement(PipelineTourSuccessModal, { - props: { - ...successPipelineEl.dataset, - }, - }); - }, - }); - } -}); +if (successPipelineEl) { + // eslint-disable-next-line no-new + new Vue({ + el: successPipelineEl, + render(createElement) { + return createElement(PipelineTourSuccessModal, { + props: { + ...successPipelineEl.dataset, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/pages/projects/boards/index.js b/app/assets/javascripts/pages/projects/boards/index.js index 3a06d0faa3e..bde0007ec6a 100644 --- a/app/assets/javascripts/pages/projects/boards/index.js +++ b/app/assets/javascripts/pages/projects/boards/index.js @@ -2,8 +2,6 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import initBoards from '~/boards'; import UsersSelect from '~/users_select'; -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/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js index 623d0a10606..72861855c5a 100644 --- a/app/assets/javascripts/pages/projects/branches/index/index.js +++ b/app/assets/javascripts/pages/projects/branches/index/index.js @@ -2,8 +2,6 @@ import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner'; import DeleteModal from '~/branches/branches_delete_modal'; import initDiverganceGraph from '~/branches/divergence_graph'; -document.addEventListener('DOMContentLoaded', () => { - AjaxLoadingSpinner.init(); - new DeleteModal(); // eslint-disable-line no-new - initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint); -}); +AjaxLoadingSpinner.init(); +new DeleteModal(); // eslint-disable-line no-new +initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint); diff --git a/app/assets/javascripts/pages/projects/branches/new/index.js b/app/assets/javascripts/pages/projects/branches/new/index.js index 13ff47d53c2..364223f1898 100644 --- a/app/assets/javascripts/pages/projects/branches/new/index.js +++ b/app/assets/javascripts/pages/projects/branches/new/index.js @@ -1,11 +1,8 @@ import $ from 'jquery'; import NewBranchForm from '~/new_branch_form'; -document.addEventListener( - 'DOMContentLoaded', - () => - new NewBranchForm( - $('.js-create-branch-form'), - JSON.parse(document.getElementById('availableRefs').innerHTML), - ), +// eslint-disable-next-line no-new +new NewBranchForm( + $('.js-create-branch-form'), + JSON.parse(document.getElementById('availableRefs').innerHTML), ); diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js index f1cf9caa28b..549e596cb8d 100644 --- a/app/assets/javascripts/pages/projects/compare/show/index.js +++ b/app/assets/javascripts/pages/projects/compare/show/index.js @@ -1,6 +1,9 @@ import Diff from '~/diff'; import GpgBadges from '~/gpg_badges'; import initChangesDropdown from '~/init_changes_dropdown'; +import initCompareSelector from '~/projects/compare'; + +initCompareSelector(); document.addEventListener('DOMContentLoaded', () => { new Diff(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js index df58e9dd072..255d05b39be 100644 --- a/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js +++ b/app/assets/javascripts/pages/projects/cycle_analytics/show/index.js @@ -1,3 +1,3 @@ -import initCycleAnalytics from '~/cycle_analytics/cycle_analytics_bundle'; +import initCycleAnalytics from '~/cycle_analytics'; document.addEventListener('DOMContentLoaded', initCycleAnalytics); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js index bbe84322462..43fd5375222 100644 --- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/edit/index.js @@ -1,3 +1,5 @@ +/* eslint-disable no-new */ + import Vue from 'vue'; import Vuex from 'vuex'; import EditUserList from '~/user_lists/components/edit_user_list.vue'; @@ -5,15 +7,13 @@ import createStore from '~/user_lists/store/edit'; Vue.use(Vuex); -document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('js-edit-user-list'); - const { userListsDocsPath } = el.dataset; - return new Vue({ - el, - store: createStore(el.dataset), - provide: { userListsDocsPath }, - render(h) { - return h(EditUserList, {}); - }, - }); +const el = document.getElementById('js-edit-user-list'); +const { userListsDocsPath } = el.dataset; +new Vue({ + el, + store: createStore(el.dataset), + provide: { userListsDocsPath }, + render(h) { + return h(EditUserList, {}); + }, }); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js index 679f0af8efc..e855447d5ce 100644 --- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/new/index.js @@ -1,3 +1,5 @@ +/* eslint-disable no-new */ + import Vue from 'vue'; import Vuex from 'vuex'; import NewUserList from '~/user_lists/components/new_user_list.vue'; @@ -5,18 +7,16 @@ import createStore from '~/user_lists/store/new'; Vue.use(Vuex); -document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('js-new-user-list'); - const { userListsDocsPath, featureFlagsPath } = el.dataset; - return new Vue({ - el, - store: createStore(el.dataset), - provide: { - userListsDocsPath, - featureFlagsPath, - }, - render(h) { - return h(NewUserList); - }, - }); +const el = document.getElementById('js-new-user-list'); +const { userListsDocsPath, featureFlagsPath } = el.dataset; +new Vue({ + el, + store: createStore(el.dataset), + provide: { + userListsDocsPath, + featureFlagsPath, + }, + render(h) { + return h(NewUserList); + }, }); diff --git a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js index bccd9dce2ec..2dca0ea7f29 100644 --- a/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js +++ b/app/assets/javascripts/pages/projects/feature_flags_user_lists/show/index.js @@ -1,18 +1,3 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import UserList from '~/user_lists/components/user_list.vue'; -import createStore from '~/user_lists/store/show'; +import featureFlagsUserListInit from '~/projects/feature_flags_user_lists/show/index'; -Vue.use(Vuex); - -document.addEventListener('DOMContentLoaded', () => { - const el = document.getElementById('js-edit-user-list'); - return new Vue({ - el, - store: createStore(el.dataset), - render(h) { - const { emptyStatePath } = el.dataset; - return h(UserList, { props: { emptyStatePath } }); - }, - }); -}); +featureFlagsUserListInit(); diff --git a/app/assets/javascripts/pages/projects/forks/new/components/app.vue b/app/assets/javascripts/pages/projects/forks/new/components/app.vue new file mode 100644 index 00000000000..02b357d389b --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/app.vue @@ -0,0 +1,72 @@ +<script> +import ForkForm from './fork_form.vue'; + +export default { + components: { + ForkForm, + }, + props: { + forkIllustration: { + type: String, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + projectFullPath: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + projectName: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectDescription: { + type: String, + required: true, + }, + projectVisibility: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div class="row gl-mt-5"> + <div class="col-lg-3"> + <img :src="forkIllustration" /> + <h4 class="">{{ s__('ForkProject|Fork project') }}</h4> + <p> + {{ s__('ForkProject|A fork is a copy of a project.') }} + <br /> + {{ + s__( + 'ForkProject|Forking a repository allows you to make changes without affecting the original project.', + ) + }} + </p> + </div> + <div class="col-lg-9"> + <fork-form + :endpoint="endpoint" + :project-full-path="projectFullPath" + :project-id="projectId" + :project-name="projectName" + :project-path="projectPath" + :project-description="projectDescription" + :project-visibility="projectVisibility" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue new file mode 100644 index 00000000000..7112b23775d --- /dev/null +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -0,0 +1,304 @@ +<script> +import { + GlIcon, + GlLink, + GlForm, + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlFormGroup, + GlFormTextarea, + GlButton, + GlFormRadio, + GlFormRadioGroup, + GlFormSelect, +} from '@gitlab/ui'; +import { buildApiUrl } from '~/api/api_utils'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import csrf from '~/lib/utils/csrf'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { s__ } from '~/locale'; + +const PRIVATE_VISIBILITY = 'private'; +const INTERNAL_VISIBILITY = 'internal'; +const PUBLIC_VISIBILITY = 'public'; + +const ALLOWED_VISIBILITY = { + private: [PRIVATE_VISIBILITY], + internal: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY], + public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY], +}; + +export default { + components: { + GlForm, + GlIcon, + GlLink, + GlButton, + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlFormTextarea, + GlFormGroup, + GlFormRadio, + GlFormRadioGroup, + GlFormSelect, + }, + inject: { + newGroupPath: { + default: '', + }, + visibilityHelpPath: { + default: '', + }, + }, + props: { + endpoint: { + type: String, + required: true, + }, + projectFullPath: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + projectName: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectDescription: { + type: String, + required: true, + }, + projectVisibility: { + type: String, + required: true, + }, + }, + data() { + return { + isSaving: false, + namespaces: [], + selectedNamespace: {}, + fork: { + name: this.projectName, + slug: this.projectPath, + description: this.projectDescription, + visibility: this.projectVisibility, + }, + }; + }, + computed: { + projectUrl() { + return `${gon.gitlab_url}/`; + }, + projectAllowedVisibility() { + return ALLOWED_VISIBILITY[this.projectVisibility]; + }, + namespaceAllowedVisibility() { + return ( + ALLOWED_VISIBILITY[this.selectedNamespace.visibility] || + ALLOWED_VISIBILITY[PUBLIC_VISIBILITY] + ); + }, + visibilityLevels() { + return [ + { + text: s__('ForkProject|Private'), + value: PRIVATE_VISIBILITY, + icon: 'lock', + help: s__('ForkProject|The project can be accessed without any authentication.'), + disabled: this.isVisibilityLevelDisabled(PRIVATE_VISIBILITY), + }, + { + text: s__('ForkProject|Internal'), + value: INTERNAL_VISIBILITY, + icon: 'shield', + help: s__('ForkProject|The project can be accessed by any logged in user.'), + disabled: this.isVisibilityLevelDisabled(INTERNAL_VISIBILITY), + }, + { + text: s__('ForkProject|Public'), + value: PUBLIC_VISIBILITY, + icon: 'earth', + help: s__( + 'ForkProject|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.', + ), + disabled: this.isVisibilityLevelDisabled(PUBLIC_VISIBILITY), + }, + ]; + }, + }, + watch: { + selectedNamespace(newVal) { + const { visibility } = newVal; + + if (this.projectAllowedVisibility.includes(visibility)) { + this.fork.visibility = visibility; + } + }, + }, + mounted() { + this.fetchNamespaces(); + }, + methods: { + async fetchNamespaces() { + const { data } = await axios.get(this.endpoint); + this.namespaces = data.namespaces; + }, + isVisibilityLevelDisabled(visibilityLevel) { + return !( + this.projectAllowedVisibility.includes(visibilityLevel) && + this.namespaceAllowedVisibility.includes(visibilityLevel) + ); + }, + async onSubmit() { + this.isSaving = true; + + const { projectId } = this; + const { name, slug, description, visibility } = this.fork; + const { id: namespaceId } = this.selectedNamespace; + + const postParams = { + id: projectId, + name, + namespace_id: namespaceId, + path: slug, + description, + visibility, + }; + + const forkProjectPath = `/api/:version/projects/:id/fork`; + const url = buildApiUrl(forkProjectPath).replace(':id', encodeURIComponent(this.projectId)); + + try { + const { data } = await axios.post(url, postParams); + redirectTo(data.web_url); + return; + } catch (error) { + createFlash({ message: error }); + } + }, + }, + csrf, +}; +</script> + +<template> + <gl-form method="POST" @submit.prevent="onSubmit"> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + + <gl-form-group label="Project name" label-for="fork-name"> + <gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required /> + </gl-form-group> + + <div class="gl-md-display-flex"> + <div class="gl-flex-basis-half"> + <gl-form-group label="Project URL" label-for="fork-url" class="gl-md-mr-3"> + <gl-form-input-group> + <template #prepend> + <gl-input-group-text> + {{ projectUrl }} + </gl-input-group-text> + </template> + <gl-form-select + id="fork-url" + v-model="selectedNamespace" + data-testid="fork-url-input" + required + > + <template slot="first"> + <option :value="null" disabled>{{ s__('ForkProject|Select a namespace') }}</option> + </template> + <option v-for="namespace in namespaces" :key="namespace.id" :value="namespace"> + {{ namespace.name }} + </option> + </gl-form-select> + </gl-form-input-group> + </gl-form-group> + </div> + <div class="gl-flex-basis-half"> + <gl-form-group label="Project slug" label-for="fork-slug" class="gl-md-ml-3"> + <gl-form-input + id="fork-slug" + v-model="fork.slug" + data-testid="fork-slug-input" + required + /> + </gl-form-group> + </div> + </div> + + <p class="gl-mt-n5 gl-text-gray-500"> + {{ s__('ForkProject|Want to house several dependent projects under the same namespace?') }} + <gl-link :href="newGroupPath" target="_blank"> + {{ s__('ForkProject|Create a group') }} + </gl-link> + </p> + + <gl-form-group label="Project description (optional)" label-for="fork-description"> + <gl-form-textarea + id="fork-description" + v-model="fork.description" + data-testid="fork-description-textarea" + /> + </gl-form-group> + + <gl-form-group> + <label> + {{ s__('ForkProject|Visibility level') }} + <gl-link :href="visibilityHelpPath" target="_blank"> + <gl-icon name="question-o" /> + </gl-link> + </label> + <gl-form-radio-group + v-model="fork.visibility" + data-testid="fork-visibility-radio-group" + required + > + <gl-form-radio + v-for="{ text, value, icon, help, disabled } in visibilityLevels" + :key="value" + :value="value" + :disabled="disabled" + :data-testid="`radio-${value}`" + > + <div> + <gl-icon :name="icon" /> + <span>{{ text }}</span> + </div> + <template #help>{{ help }}</template> + </gl-form-radio> + </gl-form-radio-group> + </gl-form-group> + + <div class="gl-display-flex gl-justify-content-space-between gl-mt-8"> + <gl-button + type="submit" + category="primary" + variant="confirm" + data-testid="submit-button" + :loading="isSaving" + > + {{ s__('ForkProject|Fork project') }} + </gl-button> + <gl-button + type="reset" + class="gl-mr-3" + data-testid="cancel-button" + :disabled="isSaving" + :href="projectFullPath" + > + {{ s__('ForkProject|Cancel') }} + </gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue index 46d1696b88b..88f4bba5e2a 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue @@ -11,6 +11,7 @@ import { } from '@gitlab/ui'; import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/groups/constants'; import csrf from '~/lib/utils/csrf'; +import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; export default { components: { @@ -20,6 +21,7 @@ export default { GlButton, GlTooltip, GlLink, + UserAccessRoleBadge, }, directives: { GlTooltip: GlTooltipDirective, @@ -72,7 +74,9 @@ export default { <template> <li :class="rowClass" class="group-row"> <div class="group-row-contents gl-display-flex gl-align-items-center gl-py-3 gl-pr-5"> - <div class="folder-toggle-wrap gl-mr-2 gl-display-flex gl-align-items-center"> + <div + class="folder-toggle-wrap gl-mr-3 gl-display-flex gl-align-items-center gl-text-gray-500" + > <gl-icon name="folder-o" /> </div> <gl-link @@ -84,12 +88,12 @@ export default { <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1"> <div class="title gl-display-flex gl-align-items-center gl-flex-wrap gl-mr-3"> - <gl-link :href="group.relative_path" class="gl-mt-3 gl-mr-3 gl-text-gray-900!">{{ - group.full_name - }}</gl-link> + <gl-link :href="group.relative_path" class="gl-mt-3 gl-mr-3 gl-text-gray-900!"> + {{ group.full_name }} + </gl-link> <gl-icon v-gl-tooltip.hover.bottom - class="gl-mr-0 gl-inline-flex gl-mt-3 text-secondary" + class="gl-display-inline-flex gl-mt-3 gl-mr-3 gl-text-gray-500" :name="visibilityIcon" :title="visibilityTooltip" /> @@ -99,11 +103,11 @@ export default { class="gl-display-none gl-sm-display-flex gl-mt-3 gl-mr-1" >{{ __('pending removal') }}</gl-badge > - <span v-if="group.permission" class="user-access-role gl-mt-3"> + <user-access-role-badge v-if="group.permission" class="gl-mt-3"> {{ group.permission }} - </span> + </user-access-role-badge> </div> - <div v-if="group.description" class="description"> + <div v-if="group.description" class="description gl-line-height-20"> <span v-safe-html="group.markdown_description"> </span> </div> </div> diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index a018d7e0926..372967c8a1e 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -1,12 +1,52 @@ import Vue from 'vue'; +import App from './components/app.vue'; import ForkGroupsList from './components/fork_groups_list.vue'; -document.addEventListener('DOMContentLoaded', () => { - const mountElement = document.getElementById('fork-groups-mount-element'); +const mountElement = document.getElementById('fork-groups-mount-element'); +if (gon.features.forkProjectForm) { + const { + forkIllustration, + endpoint, + newGroupPath, + projectFullPath, + visibilityHelpPath, + projectId, + projectName, + projectPath, + projectDescription, + projectVisibility, + } = mountElement.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: mountElement, + provide: { + newGroupPath, + visibilityHelpPath, + }, + render(h) { + return h(App, { + props: { + forkIllustration, + endpoint, + newGroupPath, + projectFullPath, + visibilityHelpPath, + projectId, + projectName, + projectPath, + projectDescription, + projectVisibility, + }, + }); + }, + }); +} else { const { endpoint } = mountElement.dataset; - return new Vue({ + // eslint-disable-next-line no-new + new Vue({ el: mountElement, render(h) { return h(ForkGroupsList, { @@ -16,4 +56,4 @@ document.addEventListener('DOMContentLoaded', () => { }); }, }); -}); +} diff --git a/app/assets/javascripts/pages/projects/imports/show/index.js b/app/assets/javascripts/pages/projects/imports/show/index.js index d5f92baf054..8397826f8eb 100644 --- a/app/assets/javascripts/pages/projects/imports/show/index.js +++ b/app/assets/javascripts/pages/projects/imports/show/index.js @@ -1,5 +1,3 @@ import ProjectImport from '~/project_import'; -document.addEventListener('DOMContentLoaded', () => { - new ProjectImport(); // eslint-disable-line no-new -}); +new ProjectImport(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index 525d90e162d..a3a053c3c31 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -2,6 +2,7 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; +import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons'; import initIssuableByEmail from '~/issuable/init_issuable_by_email'; import IssuableIndex from '~/issuable_index'; import initIssuablesList from '~/issues_list'; @@ -26,3 +27,4 @@ new UsersSelect(); initManualOrdering(); initIssuablesList(); initIssuableByEmail(); +initCsvImportExportButtons(); diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js index 6a70d4cf26d..681d151b77f 100644 --- a/app/assets/javascripts/pages/projects/jobs/index/index.js +++ b/app/assets/javascripts/pages/projects/jobs/index/index.js @@ -1,19 +1,17 @@ import Vue from 'vue'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; -document.addEventListener('DOMContentLoaded', () => { - const remainingTimeElements = document.querySelectorAll('.js-remaining-time'); - remainingTimeElements.forEach( - (el) => - new Vue({ - el, - render(h) { - return h(GlCountdown, { - props: { - endDateString: el.dateTime, - }, - }); - }, - }), - ); -}); +const remainingTimeElements = document.querySelectorAll('.js-remaining-time'); +remainingTimeElements.forEach( + (el) => + new Vue({ + el, + render(h) { + return h(GlCountdown, { + props: { + endDateString: el.dateTime, + }, + }); + }, + }), +); diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js index 83d6ac9fd14..3b7562deed9 100644 --- a/app/assets/javascripts/pages/projects/labels/edit/index.js +++ b/app/assets/javascripts/pages/projects/labels/edit/index.js @@ -1,3 +1,3 @@ import Labels from 'ee_else_ce/labels'; -document.addEventListener('DOMContentLoaded', () => new Labels()); +new Labels(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue index 0393793bfe1..32ca623ca45 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue @@ -1,11 +1,11 @@ <script> import { GlLink } from '@gitlab/ui'; -import { ACTION_TEXT } from '../constants'; +import { ACTION_LABELS } from '../constants'; export default { components: { GlLink }, i18n: { - ACTION_TEXT, + ACTION_LABELS, }, props: { actions: { @@ -18,9 +18,9 @@ export default { <template> <ul> <li v-for="(value, action) in actions" :key="action"> - <span v-if="value.completed">{{ $options.i18n.ACTION_TEXT[action] }}</span> + <span v-if="value.completed">{{ $options.i18n.ACTION_LABELS[action].title }}</span> <span v-else> - <gl-link :href="value.url">{{ $options.i18n.ACTION_TEXT[action] }}</gl-link> + <gl-link :href="value.url">{{ $options.i18n.ACTION_LABELS[action].title }}</gl-link> </span> </li> </ul> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue index 0393793bfe1..230054ff76e 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue @@ -1,11 +1,35 @@ <script> -import { GlLink } from '@gitlab/ui'; -import { ACTION_TEXT } from '../constants'; +import { GlProgressBar, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { ACTION_LABELS } from '../constants'; +import LearnGitlabInfoCard from './learn_gitlab_info_card.vue'; export default { - components: { GlLink }, + components: { LearnGitlabInfoCard, GlProgressBar, GlSprintf }, i18n: { - ACTION_TEXT, + title: s__('LearnGitLab|Learn GitLab'), + description: s__( + 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.', + ), + percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`), + workspace: { + title: s__('LearnGitLab|Set up your workspace'), + description: s__( + "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:", + ), + }, + plan: { + title: s__('LearnGitLab|Plan and execute'), + description: s__( + 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:', + ), + }, + deploy: { + title: s__('LearnGitLab|Deploy'), + description: s__( + 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:', + ), + }, }, props: { actions: { @@ -13,15 +37,76 @@ export default { type: Object, }, }, + maxValue: Object.keys(ACTION_LABELS).length, + methods: { + infoProps(action) { + return { + ...this.actions[action], + ...ACTION_LABELS[action], + }; + }, + progressValue() { + return Object.values(this.actions).filter((a) => a.completed).length; + }, + progressPercentage() { + return Math.round((this.progressValue() / this.$options.maxValue) * 100); + }, + }, }; </script> <template> - <ul> - <li v-for="(value, action) in actions" :key="action"> - <span v-if="value.completed">{{ $options.i18n.ACTION_TEXT[action] }}</span> - <span v-else> - <gl-link :href="value.url">{{ $options.i18n.ACTION_TEXT[action] }}</gl-link> - </span> - </li> - </ul> + <div> + <div class="row"> + <div class="gl-mb-7 col-md-8 col-lg-7"> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p> + </div> + </div> + + <div class="gl-mb-3"> + <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage"> + <gl-sprintf :message="$options.i18n.percentageCompleted"> + <template #percentage>{{ progressPercentage() }}</template> + <template #percentSymbol>%</template> + </gl-sprintf> + </p> + <gl-progress-bar :value="progressValue()" :max="$options.maxValue" /> + </div> + + <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.workspace.title }}</h2> + <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.workspace.description }}</p> + + <div class="row row-cols-2 row-cols-md-3 row-cols-lg-4"> + <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('userAdded')" /></div> + <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('gitWrite')" /></div> + <div class="col gl-mb-6"> + <learn-gitlab-info-card v-bind="infoProps('pipelineCreated')" /> + </div> + <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('trialStarted')" /></div> + <div class="col gl-mb-6"> + <learn-gitlab-info-card v-bind="infoProps('codeOwnersEnabled')" /> + </div> + <div class="col gl-mb-6"> + <learn-gitlab-info-card v-bind="infoProps('requiredMrApprovalsEnabled')" /> + </div> + </div> + + <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.plan.title }}</h2> + <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.plan.description }}</p> + + <div class="row row-cols-2 row-cols-md-3 row-cols-lg-4"> + <div class="col gl-mb-6"> + <learn-gitlab-info-card v-bind="infoProps('mergeRequestCreated')" /> + </div> + </div> + + <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.deploy.title }}</h2> + <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.deploy.description }}</p> + + <div class="row row-cols-2 row-cols-lg-4 g-2 g-lg-3"> + <div class="col gl-mb-6"> + <learn-gitlab-info-card v-bind="infoProps('securityScanEnabled')" /> + </div> + </div> + </div> </template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue new file mode 100644 index 00000000000..3d2a8eed9d4 --- /dev/null +++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_info_card.vue @@ -0,0 +1,70 @@ +<script> +import { GlLink, GlCard, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'LearnGitlabInfoCard', + components: { GlLink, GlCard, GlIcon }, + i18n: { + trial: s__('Learn GitLab|Trial only'), + }, + props: { + title: { + required: true, + type: String, + }, + description: { + required: true, + type: String, + }, + actionLabel: { + required: true, + type: String, + }, + url: { + required: true, + type: String, + }, + completed: { + required: true, + type: Boolean, + }, + svg: { + required: true, + type: String, + }, + trialRequired: { + default: false, + required: false, + type: Boolean, + }, + }, +}; +</script> +<template> + <gl-card class="gl-pt-0"> + <div class="gl-text-right gl-h-5"> + <gl-icon + v-if="completed" + name="check-circle-filled" + class="gl-text-green-500" + :size="16" + data-testid="completed-icon" + /> + <span + v-else-if="trialRequired" + class="gl-text-gray-500 gl-font-sm gl-font-style-italic" + data-testid="trial-only" + >{{ $options.i18n.trial }}</span + > + </div> + <div + class="gl-text-center gl-display-flex gl-justify-content-center gl-align-items-center gl-flex-direction-column learn-gitlab-info-card-content" + > + <img :src="svg" /> + <h6>{{ title }}</h6> + <p class="gl-font-sm gl-text-gray-700">{{ description }}</p> + <gl-link :href="url" target="_blank">{{ actionLabel }}</gl-link> + </div> + </gl-card> +</template> diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js index 8606af29785..80f04b0cf44 100644 --- a/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js +++ b/app/assets/javascripts/pages/projects/learn_gitlab/constants/index.js @@ -1,12 +1,50 @@ import { s__ } from '~/locale'; -export const ACTION_TEXT = { - gitWrite: s__('LearnGitLab|Create a repository'), - userAdded: s__('LearnGitLab|Invite your colleagues'), - pipelineCreated: s__('LearnGitLab|Set-up CI/CD'), - trialStarted: s__('LearnGitLab|Start a free trial of GitLab Gold'), - codeOwnersEnabled: s__('LearnGitLab|Add code owners'), - requiredMrApprovalsEnabled: s__('LearnGitLab|Enable require merge approvals'), - mergeRequestCreated: s__('LearnGitLab|Submit a merge request (MR)'), - securityScanEnabled: s__('LearnGitLab|Run a Security scan using CI/CD'), +export const ACTION_LABELS = { + gitWrite: { + title: s__('LearnGitLab|Create or import a repository'), + actionLabel: s__('LearnGitLab|Create or import a repository'), + description: s__('LearnGitLab|Create or import your first repository into your new project.'), + }, + userAdded: { + title: s__('LearnGitLab|Invite your colleagues'), + actionLabel: s__('LearnGitLab|Invite your colleagues'), + description: s__( + 'LearnGitLab|GitLab works best as a team. Invite your colleague to enjoy all features.', + ), + }, + pipelineCreated: { + title: s__('LearnGitLab|Set up CI/CD'), + actionLabel: s__('LearnGitLab|Set-up CI/CD'), + description: s__('LearnGitLab|Save time by automating your integration and deployment tasks.'), + }, + trialStarted: { + title: s__('LearnGitLab|Start a free Ultimate trial'), + actionLabel: s__('LearnGitLab|Try GitLab Ultimate for free'), + description: s__('LearnGitLab|Try all GitLab features for 30 days, no credit card required.'), + }, + codeOwnersEnabled: { + title: s__('LearnGitLab|Add code owners'), + actionLabel: s__('LearnGitLab|Add code owners'), + description: s__( + 'LearnGitLab|Prevent unexpected changes to important assets by assigning ownership of files and paths.', + ), + trialRequired: true, + }, + requiredMrApprovalsEnabled: { + title: s__('LearnGitLab|Add merge request approval'), + actionLabel: s__('LearnGitLab|Enable require merge approvals'), + description: s__('LearnGitLab|Route code reviews to the right reviewers, every time.'), + trialRequired: true, + }, + mergeRequestCreated: { + title: s__('LearnGitLab|Submit a merge request'), + actionLabel: s__('LearnGitLab|Submit a merge request (MR)'), + description: s__('LearnGitLab|Review and edit proposed changes to source code.'), + }, + securityScanEnabled: { + title: s__('LearnGitLab|Run a security scan'), + actionLabel: s__('LearnGitLab|Run a Security scan'), + description: s__('LearnGitLab|Scan your code to uncover vulnerabilities before deploying.'), + }, }; diff --git a/app/assets/javascripts/pages/projects/logs/index.js b/app/assets/javascripts/pages/projects/logs/index.js index 36747069ebb..0cff1ffc27e 100644 --- a/app/assets/javascripts/pages/projects/logs/index.js +++ b/app/assets/javascripts/pages/projects/logs/index.js @@ -1,3 +1,3 @@ import logsBundle from '~/logs'; -document.addEventListener('DOMContentLoaded', logsBundle); +logsBundle(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js index 399aebb0c83..ec21d8c84e0 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js @@ -1,7 +1,5 @@ import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request'; import initCheckFormState from './check_form_state'; -document.addEventListener('DOMContentLoaded', () => { - initMergeRequest(); - initCheckFormState(); -}); +initMergeRequest(); +initCheckFormState(); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index 76705256fe2..d279086df7b 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -1,6 +1,7 @@ import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import initCsvImportExportButtons from '~/issuable/init_csv_import_export_buttons'; import initIssuableByEmail from '~/issuable/init_issuable_by_email'; import IssuableIndex from '~/issuable_index'; import { FILTERED_SEARCH } from '~/pages/constants'; @@ -22,3 +23,4 @@ new UsersSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new initIssuableByEmail(); +initCsvImportExportButtons(); diff --git a/app/assets/javascripts/pages/projects/milestones/edit/index.js b/app/assets/javascripts/pages/projects/milestones/edit/index.js index 9a4ebf9890d..4f8514a9a1d 100644 --- a/app/assets/javascripts/pages/projects/milestones/edit/index.js +++ b/app/assets/javascripts/pages/projects/milestones/edit/index.js @@ -1,3 +1,3 @@ -import initForm from '../../../../shared/milestones/form'; +import initForm from '~/shared/milestones/form'; -document.addEventListener('DOMContentLoaded', () => initForm()); +initForm(); diff --git a/app/assets/javascripts/pages/projects/milestones/index/index.js b/app/assets/javascripts/pages/projects/milestones/index/index.js index 38789365a67..150b506b121 100644 --- a/app/assets/javascripts/pages/projects/milestones/index/index.js +++ b/app/assets/javascripts/pages/projects/milestones/index/index.js @@ -1,3 +1,3 @@ import milestones from '~/pages/milestones/shared'; -document.addEventListener('DOMContentLoaded', milestones); +milestones(); diff --git a/app/assets/javascripts/pages/projects/milestones/new/index.js b/app/assets/javascripts/pages/projects/milestones/new/index.js index 9a4ebf9890d..364b0d95d9c 100644 --- a/app/assets/javascripts/pages/projects/milestones/new/index.js +++ b/app/assets/javascripts/pages/projects/milestones/new/index.js @@ -1,3 +1,3 @@ import initForm from '../../../../shared/milestones/form'; -document.addEventListener('DOMContentLoaded', () => initForm()); +initForm(); diff --git a/app/assets/javascripts/pages/projects/milestones/show/index.js b/app/assets/javascripts/pages/projects/milestones/show/index.js index a853413e1f7..3c755e9b98c 100644 --- a/app/assets/javascripts/pages/projects/milestones/show/index.js +++ b/app/assets/javascripts/pages/projects/milestones/show/index.js @@ -1,7 +1,5 @@ import milestones from '~/pages/milestones/shared'; import initMilestonesShow from '~/pages/milestones/shared/init_milestones_show'; -document.addEventListener('DOMContentLoaded', () => { - initMilestonesShow(); - milestones(); -}); +initMilestonesShow(); +milestones(); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index 437594fdf11..e10e2872dce 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -3,28 +3,26 @@ import { __ } from '~/locale'; import initProjectVisibilitySelector from '../../../project_visibility'; import initProjectNew from '../../../projects/project_new'; -document.addEventListener('DOMContentLoaded', () => { - initProjectVisibilitySelector(); - initProjectNew.bindEvents(); +initProjectVisibilitySelector(); +initProjectNew.bindEvents(); - import( - /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation' - ) - .then((m) => { - const el = document.querySelector('.js-experiment-new-project-creation'); +import( + /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation' +) + .then((m) => { + const el = document.querySelector('.js-experiment-new-project-creation'); - if (!el) { - return; - } + if (!el) { + return; + } - const config = { - hasErrors: 'hasErrors' in el.dataset, - isCiCdAvailable: 'isCiCdAvailable' in el.dataset, - newProjectGuidelines: el.dataset.newProjectGuidelines, - }; - m.default(el, config); - }) - .catch(() => { - createFlash(__('An error occurred while loading project creation UI')); - }); -}); + const config = { + hasErrors: 'hasErrors' in el.dataset, + isCiCdAvailable: 'isCiCdAvailable' in el.dataset, + newProjectGuidelines: el.dataset.newProjectGuidelines, + }; + m.default(el, config); + }) + .catch(() => { + createFlash(__('An error occurred while loading project creation UI')); + }); diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js index ed11b07be4a..4aea5614bfb 100644 --- a/app/assets/javascripts/pages/projects/project_members/index.js +++ b/app/assets/javascripts/pages/projects/project_members/index.js @@ -1,11 +1,14 @@ import Vue from 'vue'; -import { deprecatedCreateFlash as flash } from '~/flash'; import groupsSelect from '~/groups_select'; +import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger'; +import initInviteMembersForm from '~/invite_members/init_invite_members_form'; import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import memberExpirationDate from '~/member_expiration_date'; -import Members from '~/members'; +import { initMembersApp } from '~/members'; +import { groupLinkRequestFormatter } from '~/members/utils'; +import { projectMemberRequestFormatter } from '~/projects/members/utils'; import UsersSelect from '~/users_select'; import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue'; @@ -29,68 +32,51 @@ memberExpirationDate('.js-access-expiration-date-groups'); mountRemoveMemberModal(); initInviteMembersModal(); initInviteMembersTrigger(); +initInviteGroupTrigger(); -new Members(); // eslint-disable-line no-new -new UsersSelect(); // eslint-disable-line no-new +// This is only used when `invite_members_group_modal` feature flag is disabled. +// This can be removed when `invite_members_group_modal` feature flag is removed. +initInviteMembersForm(); -if (window.gon.features.vueProjectMembersList) { - const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; +new UsersSelect(); // eslint-disable-line no-new - Promise.all([ - import('~/members/index'), - import('~/members/utils'), - import('~/projects/members/utils'), - import('~/locale'), - ]) - .then( - ([ - { initMembersApp }, - { groupLinkRequestFormatter }, - { projectMemberRequestFormatter }, - { s__ }, - ]) => { - initMembersApp(document.querySelector('.js-project-members-list'), { - tableFields: SHARED_FIELDS.concat(['source', 'granted']), - tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, - tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], - requestFormatter: projectMemberRequestFormatter, - filteredSearchBar: { - show: true, - tokens: ['with_inherited_permissions'], - searchParam: 'search', - placeholder: s__('Members|Filter members'), - recentSearchesStorageKey: 'project_members', - }, - }); +const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']; +initMembersApp(document.querySelector('.js-project-members-list'), { + tableFields: SHARED_FIELDS.concat(['source', 'granted']), + tableAttrs: { tr: { 'data-qa-selector': 'member_row' } }, + tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'], + requestFormatter: projectMemberRequestFormatter, + filteredSearchBar: { + show: true, + tokens: ['with_inherited_permissions'], + searchParam: 'search', + placeholder: s__('Members|Filter members'), + recentSearchesStorageKey: 'project_members', + }, +}); - initMembersApp(document.querySelector('.js-project-group-links-list'), { - tableFields: SHARED_FIELDS.concat('granted'), - tableAttrs: { - table: { 'data-qa-selector': 'groups_list' }, - tr: { 'data-qa-selector': 'group_row' }, - }, - requestFormatter: groupLinkRequestFormatter, - filteredSearchBar: { - show: true, - tokens: [], - searchParam: 'search_groups', - placeholder: s__('Members|Search groups'), - recentSearchesStorageKey: 'project_group_links', - }, - }); +initMembersApp(document.querySelector('.js-project-group-links-list'), { + tableFields: SHARED_FIELDS.concat('granted'), + tableAttrs: { + table: { 'data-qa-selector': 'groups_list' }, + tr: { 'data-qa-selector': 'group_row' }, + }, + requestFormatter: groupLinkRequestFormatter, + filteredSearchBar: { + show: true, + tokens: [], + searchParam: 'search_groups', + placeholder: s__('Members|Search groups'), + recentSearchesStorageKey: 'project_group_links', + }, +}); - initMembersApp(document.querySelector('.js-project-invited-members-list'), { - tableFields: SHARED_FIELDS.concat('invited'), - requestFormatter: projectMemberRequestFormatter, - }); +initMembersApp(document.querySelector('.js-project-invited-members-list'), { + tableFields: SHARED_FIELDS.concat('invited'), + requestFormatter: projectMemberRequestFormatter, +}); - initMembersApp(document.querySelector('.js-project-access-requests-list'), { - tableFields: SHARED_FIELDS.concat('requested'), - requestFormatter: projectMemberRequestFormatter, - }); - }, - ) - .catch(() => { - flash(__('An error occurred while loading the members, please try again.')); - }); -} +initMembersApp(document.querySelector('.js-project-access-requests-list'), { + tableFields: SHARED_FIELDS.concat('requested'), + requestFormatter: projectMemberRequestFormatter, +}); diff --git a/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js b/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js index 2fd047675b9..82856c1c8b9 100644 --- a/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js +++ b/app/assets/javascripts/pages/projects/prometheus/metrics/edit/index.js @@ -1,3 +1,3 @@ -import customMetrics from '~/custom_metrics'; +import CustomMetrics from '~/custom_metrics'; -document.addEventListener('DOMContentLoaded', customMetrics); +CustomMetrics(); diff --git a/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js b/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js index 2fd047675b9..82856c1c8b9 100644 --- a/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js +++ b/app/assets/javascripts/pages/projects/prometheus/metrics/new/index.js @@ -1,3 +1,3 @@ -import customMetrics from '~/custom_metrics'; +import CustomMetrics from '~/custom_metrics'; -document.addEventListener('DOMContentLoaded', customMetrics); +CustomMetrics(); diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js index 22dddb72f98..dc1bb88bf4b 100644 --- a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js +++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js @@ -1,3 +1,3 @@ -import initExpiresAtField from '~/access_tokens'; +import { initExpiresAtField } from '~/access_tokens'; initExpiresAtField(); 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 be9259ec3ca..b7e8d4b03ac 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -6,6 +6,7 @@ import initDeployFreeze from '~/deploy_freeze'; import { initInstallRunner } from '~/pages/shared/mount_runner_instructions'; import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle'; import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { @@ -42,4 +43,6 @@ document.addEventListener('DOMContentLoaded', () => { } initInstallRunner(); + + initSearchSettings(); }); 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 3a46241e2eb..4a800ab150d 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -3,6 +3,7 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; import mountGrafanaIntegration from '~/grafana_integration'; import initIncidentsSettings from '~/incidents_settings'; import mountOperationSettings from '~/operation_settings'; +import initSearchSettings from '~/search_settings'; import initSettingsPanels from '~/settings_panels'; initIncidentsSettings(); @@ -13,3 +14,7 @@ if (!IS_EE) { initSettingsPanels(); } mountAlertsSettings(document.querySelector('.js-alerts-settings')); + +document.addEventListener('DOMContentLoaded', () => { + initSearchSettings(); +}); diff --git a/app/assets/javascripts/pages/projects/settings/repository/show/index.js b/app/assets/javascripts/pages/projects/settings/repository/show/index.js index e90954c14c5..c7bcbb83051 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/repository/show/index.js @@ -1,4 +1,5 @@ import MirrorRepos from '~/mirrors/mirror_repos'; +import initSearchSettings from '~/search_settings'; import initForm from '../form'; document.addEventListener('DOMContentLoaded', () => { @@ -6,4 +7,6 @@ document.addEventListener('DOMContentLoaded', () => { const mirrorReposContainer = document.querySelector('.js-mirror-settings'); if (mirrorReposContainer) new MirrorRepos(mirrorReposContainer).init(); + + initSearchSettings(); }); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue index 94a9bc168e5..62b565a4856 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 @@ -11,6 +11,7 @@ import { featureAccessLevelEveryone, featureAccessLevel, featureAccessLevelNone, + CVE_ID_REQUEST_BUTTON_I18N, } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; import projectFeatureSetting from './project_feature_setting.vue'; @@ -19,6 +20,10 @@ import projectSettingRow from './project_setting_row.vue'; const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone'); export default { + i18n: { + ...CVE_ID_REQUEST_BUTTON_I18N, + }, + components: { projectFeatureSetting, projectSettingRow, @@ -31,6 +36,11 @@ export default { mixins: [settingsMixin, glFeatureFlagsMixin()], props: { + requestCveAvailable: { + type: Boolean, + required: false, + default: false, + }, currentSettings: { type: Object, required: true, @@ -99,6 +109,11 @@ export default { required: false, default: '', }, + cveIdRequestHelpPath: { + type: String, + required: false, + default: '', + }, registryHelpPath: { type: String, required: false, @@ -152,6 +167,7 @@ export default { requestAccessEnabled: true, highlightChangesClass: false, emailsDisabled: false, + cveIdRequestEnabled: true, featureAccessLevelEveryone, featureAccessLevelMembers, }; @@ -230,6 +246,9 @@ export default { 'ProjectSettings|View and edit files in this project. Non-project members will only have read access.', ); }, + cveIdRequestIsDisabled() { + return this.visibilityLevel !== visibilityOptions.PUBLIC; + }, }, watch: { @@ -417,6 +436,19 @@ export default { :options="featureAccessLevelOptions" name="project[project_feature_attributes][issues_access_level]" /> + <project-setting-row + v-if="requestCveAvailable" + :help-path="cveIdRequestHelpPath" + :help-text="$options.i18n.cve_request_toggle_label" + > + <gl-toggle + v-model="cveIdRequestEnabled" + class="gl-my-2" + :disabled="cveIdRequestIsDisabled" + name="project[project_setting_attributes][cve_id_request_enabled]" + data-testid="cve_id_request_toggle" + /> + </project-setting-row> </project-setting-row> <project-setting-row ref="repository-settings" @@ -613,7 +645,9 @@ export default { <project-setting-row ref="operations-settings" :label="s__('ProjectSettings|Operations')" - :help-text="s__('ProjectSettings|Environments, logs, cluster management, and more.')" + :help-text=" + s__('ProjectSettings|Configure your project resources and monitor their health.') + " > <project-feature-setting v-model="operationsAccessLevel" diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js index 6771391254e..e160fdacca6 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js @@ -1,4 +1,4 @@ -import { __ } from '~/locale'; +import { s__, __ } from '~/locale'; export const visibilityOptions = { PRIVATE: 0, @@ -42,3 +42,7 @@ export const featureAccessLevelEveryone = [ featureAccessLevel.EVERYONE, featureAccessLevelDescriptions[featureAccessLevel.EVERYONE], ]; + +export const CVE_ID_REQUEST_BUTTON_I18N = { + cve_request_toggle_label: s__('CVE|Enable CVE ID requests in the issue sidebar'), +}; diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 0494dad6e33..a0831c7df41 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -3,20 +3,15 @@ import Activities from '~/activities'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import BlobViewer from '~/blob/viewer/index'; import { initUploadForm } from '~/blob_edit/blob_bundle'; -import initInviteMembersModal from '~/invite_members/init_invite_members_modal'; -import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger'; import leaveByUrl from '~/namespaces/leave_by_url'; import initVueNotificationsDropdown from '~/notifications'; -import NotificationsForm from '~/notifications_form'; import initReadMore from '~/read_more'; import UserCallout from '~/user_callout'; -import notificationsDropdown from '../../../notifications_dropdown'; import Star from '../../../star'; initReadMore(); new Star(); // eslint-disable-line no-new -new NotificationsForm(); // eslint-disable-line no-new // eslint-disable-next-line no-new new UserCallout({ setCalloutPerProject: false, @@ -24,9 +19,12 @@ new UserCallout({ }); // Project show page loads different overview content based on user preferences -const treeSlider = document.getElementById('js-tree-list'); -if (treeSlider) { + +if (document.querySelector('.js-upload-blob-form')) { initUploadForm(); +} + +if (document.getElementById('js-tree-list')) { initTree(); } @@ -40,15 +38,6 @@ if (document.querySelector('.project-show-activity')) { leaveByUrl('project'); -if (gon.features?.vueNotificationDropdown) { - initVueNotificationsDropdown(); -} else { - notificationsDropdown(); -} - initVueNotificationsDropdown(); new ShortcutsNavigation(); // eslint-disable-line no-new - -initInviteMembersTrigger(); -initInviteMembersModal(); diff --git a/app/assets/javascripts/pages/projects/tags/new/index.js b/app/assets/javascripts/pages/projects/tags/new/index.js index 11a19a673b1..b071e7a45fc 100644 --- a/app/assets/javascripts/pages/projects/tags/new/index.js +++ b/app/assets/javascripts/pages/projects/tags/new/index.js @@ -3,8 +3,6 @@ import GLForm from '../../../../gl_form'; import RefSelectDropdown from '../../../../ref_select_dropdown'; import ZenMode from '../../../../zen_mode'; -document.addEventListener('DOMContentLoaded', () => { - new ZenMode(); // eslint-disable-line no-new - new GLForm($('.tag-form')); // eslint-disable-line no-new - new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new -}); +new ZenMode(); // eslint-disable-line no-new +new GLForm($('.tag-form')); // eslint-disable-line no-new +new RefSelectDropdown($('.js-branch-select')); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/tags/releases/index.js b/app/assets/javascripts/pages/projects/tags/releases/index.js index abdc97f62d0..cafd880b4be 100644 --- a/app/assets/javascripts/pages/projects/tags/releases/index.js +++ b/app/assets/javascripts/pages/projects/tags/releases/index.js @@ -2,7 +2,5 @@ import $ from 'jquery'; import GLForm from '~/gl_form'; import ZenMode from '~/zen_mode'; -document.addEventListener('DOMContentLoaded', () => { - new ZenMode(); // eslint-disable-line no-new - new GLForm($('.release-form')); // eslint-disable-line no-new -}); +new ZenMode(); // eslint-disable-line no-new +new GLForm($('.release-form')); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js index a8c288c3663..2ee33584ee1 100644 --- a/app/assets/javascripts/pages/search/show/index.js +++ b/app/assets/javascripts/pages/search/show/index.js @@ -1,5 +1,3 @@ import { initSearchApp } from '~/search'; -document.addEventListener('DOMContentLoaded', () => { - initSearchApp(); -}); +initSearchApp(); diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue new file mode 100644 index 00000000000..6cea26f2bed --- /dev/null +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_alert.vue @@ -0,0 +1,31 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; + +export default { + components: { + GlAlert, + GlLink, + GlSprintf, + }, + props: { + error: { + type: String, + required: true, + }, + wikiPagePath: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <gl-alert variant="danger" :dismissible="false"> + <gl-sprintf :message="error"> + <template #wikiLink="{ content }"> + <gl-link :href="wikiPagePath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> +</template> diff --git a/app/assets/javascripts/pages/shared/wikis/index.js b/app/assets/javascripts/pages/shared/wikis/index.js index c8dc75828e4..c382a372260 100644 --- a/app/assets/javascripts/pages/shared/wikis/index.js +++ b/app/assets/javascripts/pages/shared/wikis/index.js @@ -6,9 +6,10 @@ import Translate from '~/vue_shared/translate'; import GLForm from '../../../gl_form'; import ZenMode from '../../../zen_mode'; import deleteWikiModal from './components/delete_wiki_modal.vue'; +import wikiAlert from './components/wiki_alert.vue'; import Wikis from './wikis'; -export default () => { +const createModalVueApp = () => { new Wikis(); // eslint-disable-line no-new new ShortcutsWiki(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new @@ -39,3 +40,28 @@ export default () => { }); } }; + +const createAlertVueApp = () => { + const el = document.getElementById('js-wiki-error'); + if (el) { + const { error, wikiPagePath } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(wikiAlert, { + props: { + error, + wikiPagePath, + }, + }); + }, + }); + } +}; + +export default () => { + createModalVueApp(); + createAlertVueApp(); +}; diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index b22287a0093..58ceb524360 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -15,9 +15,7 @@ function initUserProfile(action) { }); } -document.addEventListener('DOMContentLoaded', () => { - const page = $('body').attr('data-page'); - const action = page.split(':')[1]; - initUserProfile(action); - new UserCallout(); // eslint-disable-line no-new -}); +const page = $('body').attr('data-page'); +const action = page.split(':')[1]; +initUserProfile(action); +new UserCallout(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js index 069f3c265f3..4ac758550e0 100644 --- a/app/assets/javascripts/performance/constants.js +++ b/app/assets/javascripts/performance/constants.js @@ -54,3 +54,24 @@ 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'; + +// +// Pipelines Detail namespace +// + +// Marks +export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START = + 'pipelines-detail-links-mark-calculate-start'; +export const PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END = + 'pipelines-detail-links-mark-calculate-end'; + +// Measures +export const PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION = + 'Pipelines Detail Graph: Links Calculation'; + +// Metrics +// Note: These strings must match the backend +// (defined in: app/services/ci/prometheus_metrics/observe_histograms_service.rb) +export const PIPELINES_DETAIL_LINK_DURATION = 'pipeline_graph_link_calculation_duration_seconds'; +export const PIPELINES_DETAIL_LINKS_TOTAL = 'pipeline_graph_links_total'; +export const PIPELINES_DETAIL_LINKS_JOB_RATIO = 'pipeline_graph_link_per_job_ratio'; diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 85789cd1fdf..6b446eb6073 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -30,13 +30,17 @@ export default { type: String, required: true, }, + statsUrl: { + type: String, + required: true, + }, }, detailedMetrics: [ { metric: 'active-record', title: 'pg', header: s__('PerformanceBar|SQL queries'), - keys: ['sql', 'cached'], + keys: ['sql', 'cached', 'db_role'], }, { metric: 'bullet', @@ -169,6 +173,9 @@ export default { class="ml-auto" @change-current-request="changeCurrentRequest" /> + <div v-if="statsUrl" id="peek-stats" class="view"> + <a class="gl-text-blue-300" :href="statsUrl">{{ s__('PerformanceBar|Stats') }}</a> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 522e34753e9..51b6108868f 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -29,6 +29,7 @@ const initPerformanceBar = (el) => { requestId: performanceBarData.requestId, peekUrl: performanceBarData.peekUrl, profileUrl: performanceBarData.profileUrl, + statsUrl: performanceBarData.statsUrl, }; }, mounted() { @@ -119,6 +120,7 @@ const initPerformanceBar = (el) => { requestId: this.requestId, peekUrl: this.peekUrl, profileUrl: this.profileUrl, + statsUrl: this.statsUrl, }, on: { 'add-request': this.addRequestManually, diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue index 9279273283e..b088678fee8 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue @@ -21,7 +21,7 @@ export default { GlSprintf, }, props: { - defaultBranch: { + currentBranch: { type: String, required: false, default: '', @@ -40,23 +40,23 @@ export default { data() { return { message: this.defaultMessage, - branch: this.defaultBranch, openMergeRequest: false, + targetBranch: this.currentBranch, }; }, computed: { - isDefaultBranch() { - return this.branch === this.defaultBranch; + isCurrentBranchTarget() { + return this.targetBranch === this.currentBranch; }, submitDisabled() { - return !(this.message && this.branch); + return !(this.message && this.targetBranch); }, }, methods: { onSubmit() { this.$emit('submit', { message: this.message, - branch: this.branch, + targetBranch: this.targetBranch, openMergeRequest: this.openMergeRequest, }); }, @@ -100,12 +100,12 @@ export default { > <gl-form-input id="target-branch-field" - v-model="branch" + v-model="targetBranch" class="gl-font-monospace!" required /> <gl-form-checkbox - v-if="!isDefaultBranch" + v-if="!isCurrentBranchTarget" v-model="openMergeRequest" data-testid="new-mr-checkbox" class="gl-mt-3" diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue index b40c9a48903..0640b9c35df 100644 --- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue +++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue @@ -4,6 +4,7 @@ import { __, s__, sprintf } from '~/locale'; import { COMMIT_FAILURE, COMMIT_SUCCESS } from '../../constants'; import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql'; import getCommitSha from '../../graphql/queries/client/commit_sha.graphql'; +import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql'; import CommitForm from './commit_form.vue'; @@ -21,7 +22,7 @@ export default { components: { CommitForm, }, - inject: ['projectFullPath', 'ciConfigPath', 'defaultBranch', 'newMergeRequestPath'], + inject: ['projectFullPath', 'ciConfigPath', 'newMergeRequestPath'], props: { ciFileContent: { type: String, @@ -38,6 +39,9 @@ export default { commitSha: { query: getCommitSha, }, + currentBranch: { + query: getCurrentBranch, + }, }, computed: { defaultCommitMessage() { @@ -49,13 +53,13 @@ export default { const url = mergeUrlParams( { [MR_SOURCE_BRANCH]: sourceBranch, - [MR_TARGET_BRANCH]: this.defaultBranch, + [MR_TARGET_BRANCH]: this.currentBranch, }, this.newMergeRequestPath, ); redirectTo(url); }, - async onCommitSubmit({ message, branch, openMergeRequest }) { + async onCommitSubmit({ message, targetBranch, openMergeRequest }) { this.isSaving = true; try { @@ -67,8 +71,8 @@ export default { mutation: commitCIFile, variables: { projectPath: this.projectFullPath, - branch, - startBranch: this.defaultBranch, + branch: targetBranch, + startBranch: this.currentBranch, message, filePath: this.ciConfigPath, content: this.ciFileContent, @@ -86,7 +90,7 @@ export default { if (errors?.length) { this.$emit('showError', { type: COMMIT_FAILURE, reasons: errors }); } else if (openMergeRequest) { - this.redirectToNewMergeRequest(branch); + this.redirectToNewMergeRequest(targetBranch); } else { this.$emit('commit', { type: COMMIT_SUCCESS }); } @@ -105,7 +109,7 @@ export default { <template> <commit-form - :default-branch="defaultBranch" + :current-branch="currentBranch" :default-message="defaultCommitMessage" :is-saving="isSaving" @cancel="onCommitCancel" diff --git a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue index 007faa4ed0d..f36b22f33c3 100644 --- a/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue +++ b/app/assets/javascripts/pipeline_editor/components/editor/ci_config_merged_preview.vue @@ -82,7 +82,7 @@ export default { </gl-alert> <div v-else> <div class="gl-display-flex gl-align-items-center"> - <gl-icon :size="18" name="lock" class="gl-text-gray-500 gl-mr-3" /> + <gl-icon :size="18" name="lock" use-deprecated-sizes class="gl-text-gray-500 gl-mr-3" /> {{ $options.i18n.viewOnlyMessage }} </div> <div class="gl-mt-3 gl-border-solid gl-border-gray-100 gl-border-1"> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue index ab41c0170e9..7a35e31e9ce 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue @@ -1,13 +1,40 @@ <script> +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import PipelineStatus from './pipeline_status.vue'; import ValidationSegment from './validation_segment.vue'; +const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100']; + +const pipelineStatusClasses = [ + ...baseClasses, + 'gl-border-1', + 'gl-border-b-0!', + 'gl-rounded-top-base', +]; + +const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base']; + +const validationSegmentWithPipelineStatusClasses = [ + ...baseClasses, + 'gl-border-1', + 'gl-rounded-bottom-left-base', + 'gl-rounded-bottom-right-base', +]; + export default { - validationSegmentClasses: - 'gl-p-5 gl-bg-gray-10 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base', + pipelineStatusClasses, + validationSegmentClasses, + validationSegmentWithPipelineStatusClasses, components: { + PipelineStatus, ValidationSegment, }, + mixins: [glFeatureFlagsMixin()], props: { + ciFileContent: { + type: String, + required: true, + }, ciConfigData: { type: Object, required: true, @@ -17,13 +44,27 @@ export default { required: true, }, }, + computed: { + showPipelineStatus() { + return this.glFeatures.pipelineStatusForPipelineEditor; + }, + // make sure corners are rounded correctly depending on if + // pipeline status is rendered + validationStyling() { + return this.showPipelineStatus + ? this.$options.validationSegmentWithPipelineStatusClasses + : this.$options.validationSegmentClasses; + }, + }, }; </script> <template> <div class="gl-mb-5"> + <pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" /> <validation-segment - :class="$options.validationSegmentClasses" + :class="validationStyling" :loading="isCiConfigDataLoading" + :ci-file-content="ciFileContent" :ci-config="ciConfigData" /> </div> diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue new file mode 100644 index 00000000000..b1ea464be99 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue @@ -0,0 +1,120 @@ +<script> +import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { s__ } from '~/locale'; +import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql'; +import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; + +const POLL_INTERVAL = 10000; +export const i18n = { + fetchError: s__('Pipeline|We are currently unable to fetch pipeline data'), + fetchLoading: s__('Pipeline|Checking pipeline status'), + pipelineInfo: s__( + `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`, + ), +}; + +export default { + i18n, + components: { + CiIcon, + GlIcon, + GlLink, + GlLoadingIcon, + GlSprintf, + }, + inject: ['projectFullPath'], + apollo: { + commitSha: { + query: getCommitSha, + }, + pipeline: { + query: getPipelineQuery, + variables() { + return { + fullPath: this.projectFullPath, + sha: this.commitSha, + }; + }, + update: (data) => { + const { id, commitPath = '', shortSha = '', detailedStatus = {} } = + data.project?.pipeline || {}; + + return { + id, + commitPath, + shortSha, + detailedStatus, + }; + }, + error() { + this.hasError = true; + }, + pollInterval: POLL_INTERVAL, + }, + }, + data() { + return { + hasError: false, + }; + }, + computed: { + hasPipelineData() { + return Boolean(this.$apollo.queries.pipeline?.id); + }, + isQueryLoading() { + return this.$apollo.queries.pipeline.loading && !this.hasPipelineData; + }, + status() { + return this.pipeline.detailedStatus; + }, + pipelineId() { + return getIdFromGraphQLId(this.pipeline.id); + }, + }, +}; +</script> + +<template> + <div class="gl-white-space-nowrap gl-max-w-full"> + <template v-if="isQueryLoading"> + <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> + <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span> + </template> + <template v-else-if="hasError"> + <gl-icon class="gl-mr-auto" name="warning-solid" /> + <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + </template> + <template v-else> + <a :href="status.detailsPath" class="gl-mr-auto"> + <ci-icon :status="status" :size="18" /> + </a> + <span class="gl-font-weight-bold"> + <gl-sprintf :message="$options.i18n.pipelineInfo"> + <template #id="{ content }"> + <gl-link + :href="status.detailsPath" + class="pipeline-id gl-font-weight-normal pipeline-number" + target="_blank" + data-testid="pipeline-id" + > + {{ content }}{{ pipelineId }}</gl-link + > + </template> + <template #status>{{ status.text }}</template> + <template #commit> + <gl-link + :href="pipeline.commitPath" + class="commit-sha gl-font-weight-normal" + target="_blank" + data-testid="pipeline-commit" + > + {{ pipeline.shortSha }} + </gl-link> + </template> + </gl-sprintf> + </span> + </template> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 94fb3a66fdd..541ab74b177 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -5,6 +5,9 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import { CI_CONFIG_STATUS_VALID } from '../../constants'; export const i18n = { + empty: __( + "We'll continuously validate your pipeline configuration. The validation results will appear here.", + ), learnMore: __('Learn more'), loading: s__('Pipelines|Validating GitLab CI configuration…'), invalid: s__('Pipelines|This GitLab CI configuration is invalid.'), @@ -26,6 +29,10 @@ export default { }, }, props: { + ciFileContent: { + type: String, + required: true, + }, ciConfig: { type: Object, required: false, @@ -38,17 +45,22 @@ export default { }, }, computed: { + isEmpty() { + return !this.ciFileContent; + }, isValid() { return this.ciConfig?.status === CI_CONFIG_STATUS_VALID; }, icon() { - if (this.isValid) { + if (this.isValid || this.isEmpty) { return 'check'; } return 'warning-solid'; }, message() { - if (this.isValid) { + if (this.isEmpty) { + return this.$options.i18n.empty; + } else if (this.isValid) { return this.$options.i18n.valid; } @@ -74,7 +86,7 @@ export default { <tooltip-on-truncate :title="message" class="gl-text-truncate"> <gl-icon :name="icon" /> <span data-testid="validationMsg">{{ message }}</span> </tooltip-on-truncate> - <span class="gl-flex-shrink-0 gl-pl-2"> + <span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2"> <gl-link data-testid="learnMoreLink" :href="ymlHelpPagePath"> {{ $options.i18n.learnMore }} </gl-link> diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue new file mode 100644 index 00000000000..d4f04a0d055 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -0,0 +1,56 @@ +<script> +import { GlButton, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + components: { + GlButton, + GlSprintf, + }, + i18n: { + title: __('Optimize your workflow with CI/CD Pipelines'), + body: __( + 'Create a new %{codeStart}.gitlab-ci.yml%{codeEnd} file at the root of the repository to get started.', + ), + btnText: __('Create new CI/CD pipeline'), + }, + mixins: [glFeatureFlagsMixin()], + inject: { + emptyStateIllustrationPath: { + default: '', + }, + }, + computed: { + showCTAButton() { + return this.glFeatures.pipelineEditorEmptyStateAction; + }, + }, + methods: { + createEmptyConfigFile() { + this.$emit('createEmptyConfigFile'); + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> + <img :src="emptyStateIllustrationPath" /> + <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1> + <p class="gl-mt-3"> + <gl-sprintf :message="$options.i18n.body"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <gl-button + v-if="showCTAButton" + variant="confirm" + class="gl-mt-3" + @click="createEmptyConfigFile" + > + {{ $options.i18n.btnText }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js index e676fdeae02..471de7e4f75 100644 --- a/app/assets/javascripts/pipeline_editor/constants.js +++ b/app/assets/javascripts/pipeline_editor/constants.js @@ -5,7 +5,6 @@ export const COMMIT_FAILURE = 'COMMIT_FAILURE'; export const COMMIT_SUCCESS = 'COMMIT_SUCCESS'; export const DEFAULT_FAILURE = 'DEFAULT_FAILURE'; -export const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE'; export const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; export const CREATE_TAB = 'CREATE_TAB'; diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql new file mode 100644 index 00000000000..acd46013f5b --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/current_branch.graphql @@ -0,0 +1,3 @@ +query getCurrentBranch { + currentBranch @client +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql new file mode 100644 index 00000000000..7cc7f92fb60 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/graphql/queries/client/pipeline.graphql @@ -0,0 +1,17 @@ +query getPipeline($fullPath: ID!, $sha: String!) { + project(fullPath: $fullPath) @client { + pipeline(sha: $sha) { + commitPath + id + iid + shortSha + status + detailedStatus { + detailsPath + icon + group + text + } + } + } +} diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js index 81e75c32846..13f6200693b 100644 --- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js +++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js @@ -11,6 +11,29 @@ export const resolvers = { }), }; }, + + /* eslint-disable @gitlab/require-i18n-strings */ + project() { + return { + __typename: 'Project', + pipeline: { + __typename: 'Pipeline', + commitPath: `/-/commit/aabbccdd`, + id: 'gid://gitlab/Ci::Pipeline/118', + iid: '28', + shortSha: 'aabbccdd', + status: 'SUCCESS', + detailedStatus: { + __typename: 'DetailedStatus', + detailsPath: '/root/sample-ci-project/-/pipelines/118"', + group: 'success', + icon: 'status_success', + text: 'passed', + }, + }, + }; + }, + /* eslint-enable @gitlab/require-i18n-strings */ }, Mutation: { lintCI: (_, { endpoint, content, dry_run }) => { diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index dc427f55b5f..b17ec2d5c25 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -22,9 +22,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { const { // Add to apollo cache as it can be updated by future queries commitSha, + initialBranchName, // Add to provide/inject API for static values ciConfigPath, defaultBranch, + emptyStateIllustrationPath, lintHelpPagePath, newMergeRequestPath, projectFullPath, @@ -41,6 +43,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { apolloProvider.clients.defaultClient.cache.writeData({ data: { + currentBranch: initialBranchName || defaultBranch, commitSha, }, }); @@ -51,6 +54,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { provide: { ciConfigPath, defaultBranch, + emptyStateIllustrationPath, lintHelpPagePath, newMergeRequestPath, projectFullPath, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index b4a818e2472..a402f0011e5 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,19 +1,15 @@ <script> import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import httpStatusCodes from '~/lib/utils/http_status'; -import { __, s__, sprintf } from '~/locale'; +import { __, s__ } from '~/locale'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; -import { - COMMIT_FAILURE, - COMMIT_SUCCESS, - DEFAULT_FAILURE, - LOAD_FAILURE_NO_FILE, - LOAD_FAILURE_UNKNOWN, -} from './constants'; +import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; +import { COMMIT_FAILURE, COMMIT_SUCCESS, DEFAULT_FAILURE, LOAD_FAILURE_UNKNOWN } from './constants'; import getBlobContent from './graphql/queries/blob_content.graphql'; import getCiConfigData from './graphql/queries/ci_config.graphql'; +import getCurrentBranch from './graphql/queries/client/current_branch.graphql'; import PipelineEditorHome from './pipeline_editor_home.vue'; export default { @@ -21,15 +17,13 @@ export default { ConfirmUnsavedChangesDialog, GlAlert, GlLoadingIcon, + PipelineEditorEmptyState, PipelineEditorHome, }, inject: { ciConfigPath: { default: '', }, - defaultBranch: { - default: null, - }, projectFullPath: { default: '', }, @@ -40,6 +34,8 @@ export default { // Success and failure state failureType: null, failureReasons: [], + showStartScreen: false, + isNewConfigFile: false, initialCiFileContent: '', lastCommittedContent: '', currentCiFileContent: '', @@ -51,11 +47,16 @@ export default { apollo: { initialCiFileContent: { query: getBlobContent, + // If we are working off a new file, we don't want to fetch + // the base data as there is nothing to fetch. + skip({ isNewConfigFile }) { + return isNewConfigFile; + }, variables() { return { projectPath: this.projectFullPath, path: this.ciConfigPath, - ref: this.defaultBranch, + ref: this.currentBranch, }; }, update(data) { @@ -94,6 +95,9 @@ export default { this.reportFailure(LOAD_FAILURE_UNKNOWN); }, }, + currentBranch: { + query: getCurrentBranch, + }, }, computed: { hasUnsavedChanges() { @@ -102,21 +106,11 @@ export default { isBlobContentLoading() { return this.$apollo.queries.initialCiFileContent.loading; }, - isBlobContentError() { - return this.failureType === LOAD_FAILURE_NO_FILE; - }, isCiConfigDataLoading() { return this.$apollo.queries.ciConfigData.loading; }, failure() { switch (this.failureType) { - case LOAD_FAILURE_NO_FILE: - return { - text: sprintf(this.$options.errorTexts[LOAD_FAILURE_NO_FILE], { - filePath: this.ciConfigPath, - }), - variant: 'danger', - }; case LOAD_FAILURE_UNKNOWN: return { text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN], @@ -154,9 +148,6 @@ export default { errorTexts: { [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'), [DEFAULT_FAILURE]: __('Something went wrong on our end.'), - [LOAD_FAILURE_NO_FILE]: s__( - 'Pipelines|There is no %{filePath} file in this repository, please add one and visit the Pipeline Editor again.', - ), [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'), }, successTexts: { @@ -173,7 +164,7 @@ export default { response?.status === httpStatusCodes.NOT_FOUND || response?.status === httpStatusCodes.BAD_REQUEST ) { - this.reportFailure(LOAD_FAILURE_NO_FILE); + this.showStartScreen = true; } else { this.reportFailure(LOAD_FAILURE_UNKNOWN); } @@ -186,17 +177,23 @@ export default { this.showSuccessAlert = false; }, reportFailure(type, reasons = []) { + window.scrollTo({ top: 0, behavior: 'smooth' }); this.showFailureAlert = true; this.failureType = type; this.failureReasons = reasons; }, reportSuccess(type) { + window.scrollTo({ top: 0, behavior: 'smooth' }); this.showSuccessAlert = true; this.successType = type; }, resetContent() { this.currentCiFileContent = this.lastCommittedContent; }, + setNewEmptyCiConfigFile() { + this.showStartScreen = false; + this.isNewConfigFile = true; + }, showErrorAlert({ type, reasons = [] }) { this.reportFailure(type, reasons); }, @@ -214,18 +211,22 @@ export default { </script> <template> - <div class="gl-mt-4"> - <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess"> - {{ success.text }} - </gl-alert> - <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure"> - {{ failure.text }} - <ul v-if="failureReasons.length" class="gl-mb-0"> - <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> - </ul> - </gl-alert> + <div class="gl-mt-4 gl-relative"> <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> - <div v-else-if="!isBlobContentError" class="gl-mt-4"> + <pipeline-editor-empty-state + v-else-if="showStartScreen" + @createEmptyConfigFile="setNewEmptyCiConfigFile" + /> + <div v-else> + <gl-alert v-if="showSuccessAlert" :variant="success.variant" @dismiss="dismissSuccess"> + {{ success.text }} + </gl-alert> + <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="dismissFailure"> + {{ failure.text }} + <ul v-if="failureReasons.length" class="gl-mb-0"> + <li v-for="reason in failureReasons" :key="reason">{{ reason }}</li> + </ul> + </gl-alert> <pipeline-editor-home :is-ci-config-data-loading="isCiConfigDataLoading" :ci-config-data="ciConfigData" @@ -235,7 +236,7 @@ export default { @showError="showErrorAlert" @updateCiConfig="updateCiConfig" /> + <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> </div> - <confirm-unsaved-changes-dialog :has-unsaved-changes="hasUnsavedChanges" /> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue index 8c9aad6ed87..ef46040153f 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue @@ -45,6 +45,7 @@ export default { <template> <div> <pipeline-editor-header + :ci-file-content="ciFileContent" :ci-config-data="ciConfigData" :is-ci-config-data-loading="isCiConfigDataLoading" /> 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 5070971c563..ff6a354f673 100644 --- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -9,14 +9,11 @@ import { GlFormSelect, GlFormTextarea, GlLink, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, GlSprintf, GlLoadingIcon, GlSafeHtmlDirective as SafeHtml, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; import axios from '~/lib/utils/axios_utils'; @@ -24,21 +21,27 @@ import { backOff } from '~/lib/utils/common_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__, __, n__ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants'; +import RefsDropdown from './refs_dropdown.vue'; + +const i18n = { + variablesDescription: s__( + 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', + ), + defaultError: __('Something went wrong on our end. Please try again.'), + refsLoadingErrorTitle: s__('Pipeline|Branches or tags could not be loaded.'), + submitErrorTitle: s__('Pipeline|Pipeline cannot be run.'), + warningTitle: __('The form contains the following warning:'), + maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), +}; export default { typeOptions: [ { value: VARIABLE_TYPE, text: __('Variable') }, { value: FILE_TYPE, text: __('File') }, ], - variablesDescription: s__( - 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', - ), + i18n, formElementClasses: 'gl-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', - errorTitle: __('Pipeline cannot be run.'), - warningTitle: __('The form contains the following warning:'), - maxWarningsSummary: __('%{total} warnings found: showing first %{warningsDisplayed}'), // this height value is used inline on the textarea to match the input field height // it's used to prevent the overwrite if 'gl-h-7' or 'gl-h-7!' were used textAreaStyle: { height: '32px' }, @@ -52,12 +55,9 @@ export default { GlFormSelect, GlFormTextarea, GlLink, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, GlSprintf, GlLoadingIcon, + RefsDropdown, }, directives: { SafeHtml }, props: { @@ -77,14 +77,6 @@ export default { type: String, required: true, }, - branches: { - type: Array, - required: true, - }, - tags: { - type: Array, - required: true, - }, settingsLink: { type: String, required: true, @@ -111,11 +103,11 @@ export default { }, data() { return { - searchTerm: '', refValue: { shortName: this.refParam, }, form: {}, + errorTitle: null, error: null, warnings: [], totalWarnings: 0, @@ -125,22 +117,6 @@ export default { }; }, computed: { - lowerCasedSearchTerm() { - return this.searchTerm.toLowerCase(); - }, - filteredBranches() { - return this.branches.filter((branch) => - branch.shortName.toLowerCase().includes(this.lowerCasedSearchTerm), - ); - }, - filteredTags() { - return this.tags.filter((tag) => - tag.shortName.toLowerCase().includes(this.lowerCasedSearchTerm), - ); - }, - hasTags() { - return this.tags.length > 0; - }, overMaxWarningsLimit() { return this.totalWarnings > this.maxWarnings; }, @@ -148,7 +124,7 @@ export default { return n__('%d warning found:', '%d warnings found:', this.warnings.length); }, summaryMessage() { - return this.overMaxWarningsLimit ? this.$options.maxWarningsSummary : this.warningsSummary; + return this.overMaxWarningsLimit ? i18n.maxWarningsSummary : this.warningsSummary; }, shouldShowWarning() { return this.warnings.length > 0 && !this.isWarningDismissed; @@ -166,6 +142,11 @@ export default { return this.form[this.refFullName]?.descriptions ?? {}; }, }, + watch: { + refValue() { + this.loadConfigVariablesForm(); + }, + }, created() { // this is needed until we add support for ref type in url query strings // ensure default branch is called with full ref on load @@ -174,7 +155,7 @@ export default { this.refValue.fullName = `refs/heads/${this.refValue.shortName}`; } - this.setRefSelected(this.refValue); + this.loadConfigVariablesForm(); }, methods: { addEmptyVariable(refValue) { @@ -213,49 +194,47 @@ export default { this.setVariable(refValue, type, key, value); }); }, - setRefSelected(refValue) { - this.refValue = refValue; - - if (!this.form[this.refFullName]) { - this.fetchConfigVariables(this.refFullName || this.refShortName) - .then(({ descriptions, params }) => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions, - }); - - // Add default variables from yml - this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); - }) - .catch(() => { - Vue.set(this.form, this.refFullName, { - variables: [], - descriptions: {}, - }); - }) - .finally(() => { - // Add/update variables, e.g. from query string - if (this.variableParams) { - this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); - } - if (this.fileParams) { - this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); - } - - // Adds empty var at the end of the form - this.addEmptyVariable(this.refFullName); - }); - } - }, - isSelected(ref) { - return ref.fullName === this.refValue.fullName; - }, removeVariable(index) { this.variables.splice(index, 1); }, canRemove(index) { return index < this.variables.length - 1; }, + loadConfigVariablesForm() { + // Skip when variables already cached in `form` + if (this.form[this.refFullName]) { + return; + } + + this.fetchConfigVariables(this.refFullName || this.refShortName) + .then(({ descriptions, params }) => { + Vue.set(this.form, this.refFullName, { + variables: [], + descriptions, + }); + + // Add default variables from yml + this.setVariableParams(this.refFullName, VARIABLE_TYPE, params); + }) + .catch(() => { + Vue.set(this.form, this.refFullName, { + variables: [], + descriptions: {}, + }); + }) + .finally(() => { + // Add/update variables, e.g. from query string + if (this.variableParams) { + this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams); + } + if (this.fileParams) { + this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams); + } + + // Adds empty var at the end of the form + this.addEmptyVariable(this.refFullName); + }); + }, fetchConfigVariables(refValue) { this.isLoading = true; @@ -330,11 +309,25 @@ export default { } = err?.response?.data; const [error] = errors; - this.error = error; - this.warnings = warnings; - this.totalWarnings = totalWarnings; + this.reportError({ + title: i18n.submitErrorTitle, + error, + warnings, + totalWarnings, + }); }); }, + onRefsLoadingError(error) { + this.reportError({ title: i18n.refsLoadingErrorTitle }); + + Sentry.captureException(error); + }, + reportError({ title = null, error = i18n.defaultError, warnings = [], totalWarnings = 0 }) { + this.errorTitle = title; + this.error = error; + this.warnings = warnings; + this.totalWarnings = totalWarnings; + }, }, }; </script> @@ -343,7 +336,7 @@ export default { <gl-form @submit.prevent="createPipeline"> <gl-alert v-if="error" - :title="$options.errorTitle" + :title="errorTitle" :dismissible="false" variant="danger" class="gl-mb-4" @@ -353,7 +346,7 @@ export default { </gl-alert> <gl-alert v-if="shouldShowWarning" - :title="$options.warningTitle" + :title="$options.i18n.warningTitle" variant="warning" class="gl-mb-4" data-testid="run-pipeline-warning-alert" @@ -380,31 +373,7 @@ export default { </details> </gl-alert> <gl-form-group :label="s__('Pipeline|Run for branch name or tag')"> - <gl-dropdown :text="refShortName" block> - <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search refs')" /> - <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="branch in filteredBranches" - :key="branch.fullName" - class="gl-font-monospace" - is-check-item - :is-checked="isSelected(branch)" - @click="setRefSelected(branch)" - > - {{ branch.shortName }} - </gl-dropdown-item> - <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="tag in filteredTags" - :key="tag.fullName" - class="gl-font-monospace" - is-check-item - :is-checked="isSelected(tag)" - @click="setRefSelected(tag)" - > - {{ tag.shortName }} - </gl-dropdown-item> - </gl-dropdown> + <refs-dropdown v-model="refValue" @loadingError="onRefsLoadingError" /> </gl-form-group> <gl-loading-icon v-if="isLoading" class="gl-mb-5" size="lg" /> @@ -465,7 +434,7 @@ export default { </div> <template #description - ><gl-sprintf :message="$options.variablesDescription"> + ><gl-sprintf :message="$options.i18n.variablesDescription"> <template #link="{ content }"> <gl-link :href="settingsLink">{{ content }}</gl-link> </template> diff --git a/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue new file mode 100644 index 00000000000..ed5c659d1df --- /dev/null +++ b/app/assets/javascripts/pipeline_new/components/refs_dropdown.vue @@ -0,0 +1,113 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownSectionHeader, GlSearchBoxByType } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { BRANCH_REF_TYPE, TAG_REF_TYPE, DEBOUNCE_REFS_SEARCH_MS } from '../constants'; +import formatRefs from '../utils/format_refs'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, + }, + inject: ['projectRefsEndpoint'], + props: { + value: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + isLoading: false, + searchTerm: '', + branches: [], + tags: [], + }; + }, + computed: { + lowerCasedSearchTerm() { + return this.searchTerm.toLowerCase(); + }, + refShortName() { + return this.value.shortName; + }, + hasTags() { + return this.tags.length > 0; + }, + }, + watch: { + searchTerm() { + this.debouncedLoadRefs(); + }, + }, + methods: { + loadRefs() { + this.isLoading = true; + + axios + .get(this.projectRefsEndpoint, { + params: { + search: this.lowerCasedSearchTerm, + }, + }) + .then(({ data }) => { + // Note: These keys are uppercase in API + const { Branches = [], Tags = [] } = data; + + this.branches = formatRefs(Branches, BRANCH_REF_TYPE); + this.tags = formatRefs(Tags, TAG_REF_TYPE); + }) + .catch((e) => { + this.$emit('loadingError', e); + }) + .finally(() => { + this.isLoading = false; + }); + }, + debouncedLoadRefs: debounce(function debouncedLoadRefs() { + this.loadRefs(); + }, DEBOUNCE_REFS_SEARCH_MS), + setRefSelected(ref) { + this.$emit('input', ref); + }, + isSelected(ref) { + return ref.fullName === this.value.fullName; + }, + }, +}; +</script> +<template> + <gl-dropdown :text="refShortName" block @show.once="loadRefs"> + <gl-search-box-by-type + v-model.trim="searchTerm" + :is-loading="isLoading" + :placeholder="__('Search refs')" + /> + <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="branch in branches" + :key="branch.fullName" + class="gl-font-monospace" + is-check-item + :is-checked="isSelected(branch)" + @click="setRefSelected(branch)" + > + {{ branch.shortName }} + </gl-dropdown-item> + <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="tag in tags" + :key="tag.fullName" + class="gl-font-monospace" + is-check-item + :is-checked="isSelected(tag)" + @click="setRefSelected(tag)" + > + {{ tag.shortName }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js index 004bbe7daf4..681755dc6ab 100644 --- a/app/assets/javascripts/pipeline_new/constants.js +++ b/app/assets/javascripts/pipeline_new/constants.js @@ -1,5 +1,6 @@ export const VARIABLE_TYPE = 'env_var'; export const FILE_TYPE = 'file'; +export const DEBOUNCE_REFS_SEARCH_MS = 250; export const CONFIG_VARIABLES_TIMEOUT = 5000; export const BRANCH_REF_TYPE = 'branch'; export const TAG_REF_TYPE = 'tag'; diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js index 0b85184ec90..a645ea8603b 100644 --- a/app/assets/javascripts/pipeline_new/index.js +++ b/app/assets/javascripts/pipeline_new/index.js @@ -1,10 +1,13 @@ import Vue from 'vue'; import PipelineNewForm from './components/pipeline_new_form.vue'; -import formatRefs from './utils/format_refs'; export default () => { const el = document.getElementById('js-new-pipeline'); const { + // provide/inject + projectRefsEndpoint, + + // props projectId, pipelinesPath, configVariablesPath, @@ -12,19 +15,18 @@ export default () => { refParam, varParam, fileParam, - branchRefs, - tagRefs, settingsLink, maxWarnings, } = el?.dataset; const variableParams = JSON.parse(varParam); const fileParams = JSON.parse(fileParam); - const branches = formatRefs(JSON.parse(branchRefs), 'branch'); - const tags = formatRefs(JSON.parse(tagRefs), 'tag'); return new Vue({ el, + provide: { + projectRefsEndpoint, + }, render(createElement) { return createElement(PipelineNewForm, { props: { @@ -35,8 +37,6 @@ export default () => { refParam, variableParams, fileParams, - branches, - tags, settingsLink, maxWarnings: Number(maxWarnings), }, diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 93156d5d05b..fc0a8b07e7f 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -4,7 +4,7 @@ import LinksLayer from '../graph_shared/links_layer.vue'; import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH } from './constants'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; -import { reportToSentry } from './utils'; +import { reportToSentry, validateConfigPaths } from './utils'; export default { name: 'PipelineGraph', @@ -15,15 +15,20 @@ export default { StageColumnComponent, }, props: { - isLinkedPipeline: { - type: Boolean, - required: false, - default: false, + configPaths: { + type: Object, + required: true, + validator: validateConfigPaths, }, pipeline: { type: Object, required: true, }, + isLinkedPipeline: { + type: Boolean, + required: false, + default: false, + }, type: { type: String, required: false, @@ -66,6 +71,12 @@ export default { hasUpstreamPipelines() { return Boolean(this.pipeline?.upstream?.length > 0); }, + metricsConfig() { + return { + path: this.configPaths.metricsPath, + collectMetrics: true, + }; + }, // The show downstream check prevents showing redundant linked columns showDownstreamPipelines() { return ( @@ -131,6 +142,7 @@ export default { <template #upstream> <linked-pipelines-column v-if="showUpstreamPipelines" + :config-paths="configPaths" :linked-pipelines="upstreamPipelines" :column-title="__('Upstream')" :type="$options.pipelineTypeConstants.UPSTREAM" @@ -145,6 +157,7 @@ export default { :container-id="containerId" :container-measurements="measurements" :highlighted-job="hoveredJobName" + :metrics-config="metricsConfig" default-link-color="gl-stroke-transparent" @error="onError" @highlightedJobsChange="updateHighlightedJobs" @@ -170,6 +183,7 @@ export default { <linked-pipelines-column v-if="showDownstreamPipelines" class="gl-mr-6" + :config-paths="configPaths" :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" :type="$options.pipelineTypeConstants.DOWNSTREAM" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index f596333237d..ed33a94af6e 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -2,9 +2,15 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { __ } from '~/locale'; -import { DEFAULT, LOAD_FAILURE } from '../../constants'; +import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; import PipelineGraph from './graph_component.vue'; -import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils'; +import { + getQueryHeaders, + reportToSentry, + serializeLoadErrors, + toggleQueryPollingByVisibility, + unwrapPipelineData, +} from './utils'; export default { name: 'PipelineGraphWrapper', @@ -14,6 +20,12 @@ export default { PipelineGraph, }, inject: { + graphqlResourceEtag: { + default: '', + }, + metricsPath: { + default: '', + }, pipelineIid: { default: '', }, @@ -29,11 +41,15 @@ export default { }; }, errorTexts: { + [DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'), [LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'), [DEFAULT]: __('An unknown error occurred while loading this graph.'), }, apollo: { pipeline: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, query: getPipelineDetails, pollInterval: 10000, variables() { @@ -43,16 +59,41 @@ export default { }; }, update(data) { + /* + This check prevents the pipeline from being overwritten + when a poll times out and the data returned is empty. + This can be removed once the timeout behavior is updated. + See: https://gitlab.com/gitlab-org/gitlab/-/issues/323213. + */ + + if (!data?.project?.pipeline) { + return this.pipeline; + } + return unwrapPipelineData(this.pipelineProjectPath, data); }, - error() { - this.reportFailure(LOAD_FAILURE); + error(err) { + this.reportFailure(LOAD_FAILURE, serializeLoadErrors(err)); + }, + result({ error }) { + /* + If there is a successful load after a failure, clear + the failure notification to avoid confusion. + */ + if (!error && this.alertType === LOAD_FAILURE) { + this.hideAlert(); + } }, }, }, computed: { alert() { switch (this.alertType) { + case DRAW_FAILURE: + return { + text: this.$options.errorTexts[DRAW_FAILURE], + variant: 'danger', + }; case LOAD_FAILURE: return { text: this.$options.errorTexts[LOAD_FAILURE], @@ -65,6 +106,12 @@ export default { }; } }, + configPaths() { + return { + graphqlResourceEtag: this.graphqlResourceEtag, + metricsPath: this.metricsPath, + }; + }, showLoadingIcon() { /* Shows the icon only when the graph is empty, not when it is is @@ -82,14 +129,15 @@ export default { methods: { hideAlert() { this.showAlert = false; + this.alertType = null; }, refreshPipelineGraph() { this.$apollo.queries.pipeline.refetch(); }, - reportFailure(type) { + reportFailure(type, err = '') { this.showAlert = true; - this.failureType = type; - reportToSentry(this.$options.name, this.failureType); + this.alertType = type; + reportToSentry(this.$options.name, `type: ${this.alertType}, info: ${err}`); }, }, }; @@ -102,6 +150,7 @@ export default { <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" /> <pipeline-graph v-if="pipeline" + :config-paths="configPaths" :pipeline="pipeline" @error="reportFailure" @refreshPipelineGraph="refreshPipelineGraph" 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 3ce77a1c60a..939400eb1c3 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -3,7 +3,14 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.qu import { LOAD_FAILURE } from '../../constants'; import { ONE_COL_WIDTH, UPSTREAM } from './constants'; import LinkedPipeline from './linked_pipeline.vue'; -import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils'; +import { + getQueryHeaders, + reportToSentry, + serializeLoadErrors, + toggleQueryPollingByVisibility, + unwrapPipelineData, + validateConfigPaths, +} from './utils'; export default { components: { @@ -15,6 +22,11 @@ export default { type: String, required: true, }, + configPaths: { + type: Object, + required: true, + validator: validateConfigPaths, + }, linkedPipelines: { type: Array, required: true, @@ -72,6 +84,9 @@ export default { this.$apollo.addSmartQuery('currentPipeline', { query: getPipelineDetails, pollInterval: 10000, + context() { + return getQueryHeaders(this.configPaths.graphqlResourceEtag); + }, variables() { return { projectPath, @@ -79,6 +94,17 @@ export default { }; }, update(data) { + /* + This check prevents the pipeline from being overwritten + when a poll times out and the data returned is empty. + This can be removed once the timeout behavior is updated. + See: https://gitlab.com/gitlab-org/gitlab/-/issues/323213. + */ + + if (!data?.project?.pipeline) { + return this.currentPipeline; + } + return unwrapPipelineData(projectPath, data); }, result() { @@ -90,7 +116,9 @@ export default { reportToSentry( 'linked_pipelines_column', - `error type: ${LOAD_FAILURE}, error: ${err}, apollo error type: ${type}`, + `error type: ${LOAD_FAILURE}, error: ${serializeLoadErrors( + err, + )}, apollo error type: ${type}`, ); }, }); @@ -175,6 +203,7 @@ export default { v-if="isExpanded(pipeline.id)" :type="type" class="d-inline-block gl-mt-n2" + :config-paths="configPaths" :pipeline="currentPipeline" :is-linked-pipeline="true" /> diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index 1a935599bfa..b9a8e2638bc 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -1,6 +1,6 @@ +import * as Sentry from '@sentry/browser'; import Visibility from 'visibilityjs'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import * as Sentry from '~/sentry/wrapper'; import { unwrapStagesWithNeeds } from '../unwrapping_utils'; const addMulti = (mainPipelineProjectPath, linkedPipeline) => { @@ -10,6 +10,73 @@ const addMulti = (mainPipelineProjectPath, linkedPipeline) => { }; }; +/* eslint-disable @gitlab/require-i18n-strings */ +const getQueryHeaders = (etagResource) => { + return { + fetchOptions: { + method: 'GET', + }, + headers: { + 'X-GITLAB-GRAPHQL-FEATURE-CORRELATION': 'verify/ci/pipeline-graph', + 'X-GITLAB-GRAPHQL-RESOURCE-ETAG': etagResource, + 'X-Requested-With': 'XMLHttpRequest', + }, + }; +}; + +const reportToSentry = (component, failureType) => { + Sentry.withScope((scope) => { + scope.setTag('component', component); + Sentry.captureException(failureType); + }); +}; + +const serializeGqlErr = (gqlError) => { + const { locations = [], message = '', path = [] } = gqlError; + + return ` + ${message}. + Locations: ${locations + .flatMap((loc) => Object.entries(loc)) + .flat(2) + .join(' ')}. + Path: ${path.join(', ')}. + `; +}; + +const serializeLoadErrors = (errors) => { + const { gqlError, graphQLErrors, networkError, message } = errors; + + if (graphQLErrors) { + return graphQLErrors.map((err) => serializeGqlErr(err)).join('; '); + } + + if (gqlError) { + return serializeGqlErr(gqlError); + } + + if (networkError) { + return `Network error: ${networkError.message}`; + } + + return message; +}; + +/* eslint-enable @gitlab/require-i18n-strings */ + +const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => { + const stopStartQuery = (query) => { + if (!Visibility.hidden()) { + query.startPolling(interval); + } else { + query.stopPolling(); + } + }; + + stopStartQuery(queryRef); + Visibility.change(stopStartQuery.bind(null, queryRef)); +}; + const transformId = (linkedPipeline) => { return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) }; }; @@ -42,24 +109,14 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => { }; }; -const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => { - const stopStartQuery = (query) => { - if (!Visibility.hidden()) { - query.startPolling(interval); - } else { - query.stopPolling(); - } - }; - - stopStartQuery(queryRef); - Visibility.change(stopStartQuery.bind(null, queryRef)); -}; - -export { unwrapPipelineData, toggleQueryPollingByVisibility }; +const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0; -export const reportToSentry = (component, failureType) => { - Sentry.withScope((scope) => { - scope.setTag('component', component); - Sentry.captureException(failureType); - }); +export { + getQueryHeaders, + reportToSentry, + serializeGqlErr, + serializeLoadErrors, + toggleQueryPollingByVisibility, + unwrapPipelineData, + validateConfigPaths, }; diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js new file mode 100644 index 00000000000..04ac15ae24c --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js @@ -0,0 +1,8 @@ +import axios from '~/lib/utils/axios_utils'; +import { reportToSentry } from '../graph/utils'; + +export const reportPerformance = (path, stats) => { + axios.post(path, stats).catch((err) => { + reportToSentry('links_inner_perf', `error: ${err}`); + }); +}; diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue index 289e04e02c5..84ca0bf1443 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -1,8 +1,19 @@ <script> import { isEmpty } from 'lodash'; +import { + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; +import { performanceMarkAndMeasure } from '~/performance/utils'; import { DRAW_FAILURE } from '../../constants'; import { createJobsHash, generateJobNeedsDict } from '../../utils'; +import { reportToSentry } from '../graph/utils'; import { parseData } from '../parsing_utils'; +import { reportPerformance } from './api'; import { generateLinksData } from './drawing_utils'; export default { @@ -25,6 +36,15 @@ export default { type: Array, required: true, }, + totalGroups: { + type: Number, + required: true, + }, + metricsConfig: { + type: Object, + required: false, + default: () => ({}), + }, defaultLinkColor: { type: String, required: false, @@ -43,6 +63,9 @@ export default { }; }, computed: { + shouldCollectMetrics() { + return this.metricsConfig.collectMetrics && this.metricsConfig.path; + }, hasHighlightedJob() { return Boolean(this.highlightedJob); }, @@ -87,23 +110,70 @@ export default { this.$emit('highlightedJobsChange', jobs); }, }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, mounted() { if (!isEmpty(this.pipelineData)) { this.prepareLinkData(); } }, methods: { + beginPerfMeasure() { + if (this.shouldCollectMetrics) { + performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); + } + }, + finishPerfMeasureAndSend() { + if (this.shouldCollectMetrics) { + performanceMarkAndMeasure({ + mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + measures: [ + { + name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + }, + ], + }); + } + + window.requestAnimationFrame(() => { + const duration = window.performance.getEntriesByName( + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + )[0]?.duration; + + if (!duration) { + return; + } + + const data = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: this.links.length }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: this.links.length / this.totalGroups, + }, + ], + }; + + reportPerformance(this.metricsConfig.path, data); + }); + }, isLinkHighlighted(linkRef) { return this.highlightedLinks.includes(linkRef); }, prepareLinkData() { + this.beginPerfMeasure(); try { const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); const parsedData = parseData(arrayOfJobs); this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`); - } catch { + } catch (err) { this.$emit('error', DRAW_FAILURE); + reportToSentry(this.$options.name, err); } + this.finishPerfMeasureAndSend(); }, getLinkClasses(link) { return [ diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue index 1c1bc7ecb2a..5db204712df 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -1,6 +1,7 @@ <script> import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; +import { reportToSentry } from '../graph/utils'; import LinksInner from './links_inner.vue'; export default { @@ -42,7 +43,7 @@ export default { }, 0); }, showAlert() { - return !this.showLinkedLayers && !this.alertDismissed; + return !this.containerZero && !this.showLinkedLayers && !this.alertDismissed; }, showLinkedLayers() { return ( @@ -50,6 +51,9 @@ export default { ); }, }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, methods: { dismissAlert() { this.alertDismissed = true; @@ -66,6 +70,7 @@ export default { v-if="showLinkedLayers" :container-measurements="containerMeasurements" :pipeline-data="pipelineData" + :total-groups="numGroups" v-bind="$attrs" v-on="$listeners" > 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 4a7ee3b2af7..707d6966e77 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -64,13 +64,6 @@ export default { hasHighlightedJob() { return Boolean(this.highlightedJob); }, - alert() { - if (this.hasError) { - return this.failure; - } - - return this.warning; - }, failure() { switch (this.failureType) { case DRAW_FAILURE: @@ -210,11 +203,11 @@ export default { <div> <gl-alert v-if="hasError" - :variant="alert.variant" - :dismissible="alert.dismissible" - @dismiss="alert.dismissible ? resetFailure : null" + :variant="failure.variant" + :dismissible="failure.dismissible" + @dismiss="resetFailure" > - {{ alert.text }} + {{ failure.text }} </gl-alert> <div v-if="!hideGraph" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index 8a656bb47f4..90c6acc9e6f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,5 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; export default { @@ -14,10 +15,6 @@ export default { GlButton, }, props: { - helpPagePath: { - type: String, - required: true, - }, emptyStateSvgPath: { type: String, required: true, @@ -27,6 +24,11 @@ export default { required: true, }, }, + computed: { + ciHelpPagePath() { + return helpPagePath('ci/quick_start/index.md'); + }, + }, }; </script> <template> @@ -47,7 +49,7 @@ export default { <div class="gl-text-center"> <gl-button - :href="helpPagePath" + :href="ciHelpPagePath" variant="info" category="primary" data-testid="get-started-pipelines" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue new file mode 100644 index 00000000000..05372010d0f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue @@ -0,0 +1,54 @@ +<script> +import PipelineStage from '~/pipelines/components/pipelines_list/pipeline_stage.vue'; +/** + * Renders the pipeline mini graph. + */ +export default { + components: { + PipelineStage, + }, + props: { + stages: { + type: Array, + required: true, + }, + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + stagesClass: { + type: [Array, Object, String], + required: false, + default: '', + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + onPipelineActionRequestComplete() { + this.$emit('pipelineActionRequestComplete'); + }, + }, +}; +</script> +<template> + <div data-testid="widget-mini-pipeline-graph"> + <div + v-for="stage in stages" + :key="stage.name" + :class="stagesClass" + class="stage-container dropdown" + > + <pipeline-stage + :stage="stage" + :update-dropdown="updateDropdown" + :is-merge-train="isMergeTrain" + @pipelineActionRequestComplete="onPipelineActionRequestComplete" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue new file mode 100644 index 00000000000..81eeead2171 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -0,0 +1,119 @@ +<script> +import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import eventHub from '../../event_hub'; +import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; +import PipelinesManualActions from './pipelines_manual_actions.vue'; + +export default { + i18n: { + cancelTitle: __('Cancel'), + redeployTitle: __('Retry'), + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModalDirective, + }, + components: { + GlButton, + PipelinesManualActions, + PipelinesArtifactsComponent, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + cancelingPipeline: { + type: Number, + required: false, + default: null, + }, + }, + data() { + return { + isRetrying: false, + }; + }, + computed: { + displayPipelineActions() { + return ( + this.pipeline.flags.retryable || + this.pipeline.flags.cancelable || + this.pipeline.details.manual_actions.length || + this.pipeline.details.artifacts.length + ); + }, + actions() { + if (!this.pipeline || !this.pipeline.details) { + return []; + } + const { details } = this.pipeline; + return [...(details.manual_actions || []), ...(details.scheduled_actions || [])]; + }, + isCancelling() { + return this.cancelingPipeline === this.pipeline.id; + }, + }, + watch: { + pipeline() { + this.isRetrying = false; + }, + }, + methods: { + handleCancelClick() { + eventHub.$emit('openConfirmationModal', { + pipeline: this.pipeline, + endpoint: this.pipeline.cancel_path, + }); + }, + handleRetryClick() { + this.isRetrying = true; + eventHub.$emit('retryPipeline', this.pipeline.retry_path); + }, + }, +}; +</script> + +<template> + <div v-if="displayPipelineActions" class="gl-text-right"> + <div class="btn-group"> + <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" /> + + <pipelines-artifacts-component + v-if="pipeline.details.artifacts.length" + :artifacts="pipeline.details.artifacts" + /> + + <gl-button + v-if="pipeline.flags.retryable" + v-gl-tooltip.hover + :aria-label="$options.i18n.redeployTitle" + :title="$options.i18n.redeployTitle" + :disabled="isRetrying" + :loading="isRetrying" + class="js-pipelines-retry-button" + data-qa-selector="pipeline_retry_button" + icon="repeat" + variant="default" + category="secondary" + @click="handleRetryClick" + /> + + <gl-button + v-if="pipeline.flags.cancelable" + v-gl-tooltip.hover + v-gl-modal-directive="'confirmation-modal'" + :aria-label="$options.i18n.cancelTitle" + :title="$options.i18n.cancelTitle" + :loading="isCancelling" + :disabled="isCancelling" + icon="close" + variant="danger" + category="primary" + class="js-pipelines-cancel-button" + @click="handleCancelClick" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue new file mode 100644 index 00000000000..50e60418f66 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue @@ -0,0 +1,152 @@ +<script> +/** + * Renders each stage of the pipeline mini graph. + * + * Given the provided endpoint will make a request to + * fetch the dropdown data when the stage is clicked. + * + * Request is made inside this component to make it reusable between: + * 1. Pipelines main table + * 2. Pipelines table in commit and Merge request views + * 3. Merge request widget + * 4. Commit widget + */ + +import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { deprecatedCreateFlash as Flash } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import eventHub from '../../event_hub'; +import JobItem from '../graph/job_item.vue'; + +export default { + components: { + GlIcon, + GlLoadingIcon, + GlDropdown, + JobItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + stage: { + type: Object, + required: true, + }, + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isLoading: false, + dropdownContent: [], + }; + }, + computed: { + triggerButtonClass() { + return `ci-status-icon-${this.stage.status.group}`; + }, + borderlessIcon() { + return `${this.stage.status.icon}_borderless`; + }, + }, + watch: { + updateDropdown() { + if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) { + this.fetchJobs(); + } + }, + }, + methods: { + onShowDropdown() { + eventHub.$emit('clickedDropdown'); + this.isLoading = true; + this.fetchJobs(); + }, + fetchJobs() { + axios + .get(this.stage.dropdown_path) + .then(({ data }) => { + this.dropdownContent = data.latest_statuses; + this.isLoading = false; + }) + .catch(() => { + this.$refs.stageGlDropdown.hide(); + this.isLoading = false; + + Flash(__('Something went wrong on our end.')); + }); + }, + isDropdownOpen() { + return this.$el.classList.contains('show'); + }, + pipelineActionRequestComplete() { + // close the dropdown in MR widget + this.$refs.stageGlDropdown.hide(); + + // warn the pipelines table to update + this.$emit('pipelineActionRequestComplete'); + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="stageGlDropdown" + v-gl-tooltip.hover + data-testid="mini-pipeline-graph-dropdown" + :title="stage.title" + variant="link" + :lazy="true" + :popper-opts="{ placement: 'bottom' }" + :toggle-class="['mini-pipeline-graph-dropdown-toggle', triggerButtonClass]" + menu-class="mini-pipeline-graph-dropdown-menu" + @show="onShowDropdown" + > + <template #button-content> + <span class="gl-pointer-events-none"> + <gl-icon :name="borderlessIcon" /> + </span> + </template> + <gl-loading-icon v-if="isLoading" /> + <ul + v-else + class="js-builds-dropdown-list scrollable-menu" + data-testid="mini-pipeline-graph-dropdown-menu-list" + > + <li v-for="job in dropdownContent" :key="job.id"> + <job-item + :dropdown-length="dropdownContent.length" + :job="job" + css-class-job-name="mini-pipeline-graph-dropdown-item" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + </li> + <template v-if="isMergeTrain"> + <li class="gl-new-dropdown-divider" role="presentation"> + <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" /> + </li> + <li> + <div + class="gl-display-flex gl-align-items-center" + data-testid="warning-message-merge-trains" + > + <div class="menu-item gl-font-sm gl-text-gray-300!"> + {{ s__('Pipeline|Merge train pipeline jobs can not be retried') }} + </div> + </div> + </li> + </template> + </ul> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue index 6ac60727f23..c707b395192 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue @@ -1,10 +1,12 @@ <script> import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { UserAvatarLink, }, + mixins: [glFeatureFlagMixin()], props: { pipeline: { type: Object, @@ -15,11 +17,19 @@ export default { user() { return this.pipeline.user; }, + classes() { + const triggererClass = 'pipeline-triggerer'; + + if (this.glFeatures.newPipelinesTable) { + return triggererClass; + } + return `table-section section-10 d-none d-md-block ${triggererClass}`; + }, }, }; </script> <template> - <div class="table-section section-10 d-none d-md-block pipeline-triggerer"> + <div :class="classes" data-testid="pipeline-triggerer"> <user-avatar-link v-if="user" :link-href="user.path" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 823ada133d2..0de520a2ca7 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,5 +1,7 @@ <script> import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { SCHEDULE_ORIGIN } from '../../constants'; export default { @@ -12,6 +14,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], inject: { targetProjectFullPath: { default: '', @@ -26,10 +29,6 @@ export default { type: String, required: true, }, - autoDevopsHelpPath: { - type: String, - required: true, - }, }, computed: { user() { @@ -44,11 +43,25 @@ export default { this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`, ); }, + autoDevopsTagId() { + return `pipeline-url-autodevops-${this.pipeline.id}`; + }, + autoDevopsHelpPath() { + return helpPagePath('topics/autodevops/index.md'); + }, + classes() { + const tagsClass = 'pipeline-tags'; + + if (this.glFeatures.newPipelinesTable) { + return tagsClass; + } + return `table-section section-10 d-none d-md-block ${tagsClass}`; + }, }, }; </script> <template> - <div class="table-section section-10 d-none d-md-block pipeline-tags"> + <div :class="classes" data-testid="pipeline-url-table-cell"> <gl-link :href="pipeline.path" data-testid="pipeline-url-link" @@ -103,38 +116,43 @@ export default { data-testid="pipeline-url-failure" >{{ __('error') }}</gl-badge > - <gl-link - v-if="pipeline.flags.auto_devops" - :id="`pipeline-url-autodevops-${pipeline.id}`" - tabindex="0" - data-testid="pipeline-url-autodevops" - role="button" - ><gl-badge variant="info" size="sm">{{ __('Auto DevOps') }}</gl-badge></gl-link - > - <gl-popover - :target="`pipeline-url-autodevops-${pipeline.id}`" - triggers="focus" - placement="top" - > - <template #title> - <div class="gl-font-weight-normal gl-line-height-normal"> - <gl-sprintf - :message=" - __( - 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}', - ) - " - > - <template #strong="{ content }"> - <b>{{ content }}</b> - </template> - </gl-sprintf> - </div> - </template> - <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">{{ - __('Learn more about Auto DevOps') - }}</gl-link> - </gl-popover> + <template v-if="pipeline.flags.auto_devops"> + <gl-link + :id="autoDevopsTagId" + tabindex="0" + data-testid="pipeline-url-autodevops" + role="button" + > + <gl-badge variant="info" size="sm"> + {{ __('Auto DevOps') }} + </gl-badge> + </gl-link> + <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top"> + <template #title> + <div class="gl-font-weight-normal gl-line-height-normal"> + <gl-sprintf + :message=" + __( + 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}', + ) + " + > + <template #strong="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + </div> + </template> + <gl-link + :href="autoDevopsHelpPath" + data-testid="pipeline-url-autodevops-link" + target="_blank" + > + {{ __('Learn more about Auto DevOps') }} + </gl-link> + </gl-popover> + </template> + <gl-badge v-if="pipeline.flags.stuck" variant="warning" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 48009a9fcb8..19d93e7d083 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -52,10 +52,6 @@ export default { required: false, default: '', }, - helpPagePath: { - type: String, - required: true, - }, emptyStateSvgPath: { type: String, required: true, @@ -68,10 +64,6 @@ export default { type: String, required: true, }, - autoDevopsHelpPath: { - type: String, - required: true, - }, hasGitlabCi: { type: Boolean, required: true, @@ -337,7 +329,6 @@ export default { <empty-state v-else-if="stateToRender === $options.stateMap.emptyState" - :help-page-path="helpPagePath" :empty-state-svg-path="emptyStateSvgPath" :can-set-ci="canCreatePipeline" /> @@ -362,7 +353,6 @@ export default { :pipelines="state.pipelines" :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" - :auto-devops-help-path="autoDevopsHelpPath" :view-type="viewType" /> </div> 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 b13460b4c68..9c3990f82df 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue @@ -31,6 +31,8 @@ export default { :text="$options.translations.artifacts" :aria-label="$options.translations.artifacts" icon="download" + right + lazy text-sr-only > <gl-dropdown-item diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue new file mode 100644 index 00000000000..cc676883c1d --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_commit.vue @@ -0,0 +1,85 @@ +<script> +import { CHILD_VIEW } from '~/pipelines/constants'; +import CommitComponent from '~/vue_shared/components/commit.vue'; + +export default { + components: { + CommitComponent, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + viewType: { + type: String, + required: true, + }, + }, + computed: { + commitAuthor() { + let commitAuthorInformation; + + if (!this.pipeline || !this.pipeline.commit) { + return null; + } + + // 1. person who is an author of a commit might be a GitLab user + if (this.pipeline.commit.author) { + // 2. if person who is an author of a commit is a GitLab user + // they can have a GitLab avatar + if (this.pipeline.commit.author.avatar_url) { + commitAuthorInformation = this.pipeline.commit.author; + + // 3. If GitLab user does not have avatar, they might have a Gravatar + } else if (this.pipeline.commit.author_gravatar_url) { + commitAuthorInformation = { + ...this.pipeline.commit.author, + avatar_url: this.pipeline.commit.author_gravatar_url, + }; + } + // 4. If committer is not a GitLab User, they can have a Gravatar + } else { + commitAuthorInformation = { + avatar_url: this.pipeline.commit.author_gravatar_url, + path: `mailto:${this.pipeline.commit.author_email}`, + username: this.pipeline.commit.author_name, + }; + } + + return commitAuthorInformation; + }, + commitTag() { + return this.pipeline?.ref?.tag; + }, + commitRef() { + return this.pipeline?.ref; + }, + commitUrl() { + return this.pipeline?.commit?.commit_path; + }, + commitShortSha() { + return this.pipeline?.commit?.short_id; + }, + commitTitle() { + return this.pipeline?.commit?.title; + }, + isChildView() { + return this.viewType === CHILD_VIEW; + }, + }, +}; +</script> + +<template> + <commit-component + :tag="commitTag" + :commit-ref="commitRef" + :commit-url="commitUrl" + :merge-request-ref="pipeline.merge_request" + :short-sha="commitShortSha" + :title="commitTitle" + :author="commitAuthor" + :show-ref-info="!isChildView" + /> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue index 6890cbb9bed..b94f1a42039 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue @@ -82,6 +82,7 @@ export default { :loading="isLoading" data-testid="pipelines-manual-actions-dropdown" right + lazy icon="play" > <gl-dropdown-item diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue new file mode 100644 index 00000000000..cc3c8d522b3 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_status_badge.vue @@ -0,0 +1,37 @@ +<script> +import { CHILD_VIEW } from '~/pipelines/constants'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; + +export default { + components: { + CiBadge, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + viewType: { + type: String, + required: true, + }, + }, + computed: { + pipelineStatus() { + return this.pipeline?.details?.status ?? {}; + }, + isChildView() { + return this.viewType === CHILD_VIEW; + }, + }, +}; +</script> + +<template> + <ci-badge + :status="pipelineStatus" + :show-text="!isChildView" + :icon-classes="'gl-vertical-align-middle!'" + data-qa-selector="pipeline_commit_status" + /> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 24c67184e56..aa27aa7e50d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -1,22 +1,97 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { GlTable, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; +import PipelineMiniGraph from './pipeline_mini_graph.vue'; +import PipelineOperations from './pipeline_operations.vue'; import PipelineStopModal from './pipeline_stop_modal.vue'; +import PipelineTriggerer from './pipeline_triggerer.vue'; +import PipelineUrl from './pipeline_url.vue'; +import PipelinesCommit from './pipelines_commit.vue'; +import PipelinesStatusBadge from './pipelines_status_badge.vue'; import PipelinesTableRowComponent from './pipelines_table_row.vue'; +import PipelinesTimeago from './time_ago.vue'; + +const DEFAULT_TD_CLASS = 'gl-p-5!'; +const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!'; +const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1! gl-font-sm!'; -/** - * Pipelines Table Component. - * - * Given an array of objects, renders a table. - */ export default { + fields: [ + { + key: 'status', + label: s__('Pipeline|Status'), + thClass: DEFAULT_TH_CLASSES, + columnClass: 'gl-w-10p', + tdClass: DEFAULT_TD_CLASS, + thAttr: { 'data-testid': 'status-th' }, + }, + { + key: 'pipeline', + label: s__('Pipeline|Pipeline'), + thClass: DEFAULT_TH_CLASSES, + tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, + columnClass: 'gl-w-10p', + thAttr: { 'data-testid': 'pipeline-th' }, + }, + { + key: 'triggerer', + label: s__('Pipeline|Triggerer'), + thClass: DEFAULT_TH_CLASSES, + tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, + columnClass: 'gl-w-10p', + thAttr: { 'data-testid': 'triggerer-th' }, + }, + { + key: 'commit', + label: s__('Pipeline|Commit'), + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-20p', + thAttr: { 'data-testid': 'commit-th' }, + }, + { + key: 'stages', + label: s__('Pipeline|Stages'), + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-15p', + thAttr: { 'data-testid': 'stages-th' }, + }, + { + key: 'timeago', + label: s__('Pipeline|Duration'), + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-15p', + thAttr: { 'data-testid': 'timeago-th' }, + }, + { + key: 'actions', + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-20p', + thAttr: { 'data-testid': 'actions-th' }, + }, + ], components: { - PipelinesTableRowComponent, + GlTable, + PipelinesCommit, + PipelineMiniGraph, + PipelineOperations, + PipelinesStatusBadge, PipelineStopModal, + PipelinesTableRowComponent, + PipelinesTimeago, + PipelineTriggerer, + PipelineUrl, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagMixin()], props: { pipelines: { type: Array, @@ -32,10 +107,6 @@ export default { required: false, default: false, }, - autoDevopsHelpPath: { - type: String, - required: true, - }, viewType: { type: String, required: true, @@ -70,42 +141,103 @@ export default { eventHub.$emit('postAction', this.endpoint); this.cancelingPipeline = this.pipelineId; }, + onPipelineActionRequestComplete() { + eventHub.$emit('refreshPipelinesTable'); + }, }, }; </script> <template> <div class="ci-table"> - <div class="gl-responsive-table-row table-row-header" role="row"> - <div class="table-section section-10 js-pipeline-status" role="rowheader"> - {{ s__('Pipeline|Status') }} - </div> - <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader"> - {{ s__('Pipeline|Pipeline') }} - </div> - <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader"> - {{ s__('Pipeline|Triggerer') }} - </div> - <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader"> - {{ s__('Pipeline|Commit') }} - </div> - <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader"> - {{ s__('Pipeline|Stages') }} - </div> - <div class="table-section section-15" role="rowheader"></div> - <div class="table-section section-20" role="rowheader"> - <slot name="table-header-actions"></slot> + <div v-if="!glFeatures.newPipelinesTable" data-testid="legacy-ci-table"> + <div class="gl-responsive-table-row table-row-header" role="row"> + <div class="table-section section-10 js-pipeline-status" role="rowheader"> + {{ s__('Pipeline|Status') }} + </div> + <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader"> + {{ s__('Pipeline|Pipeline') }} + </div> + <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader"> + {{ s__('Pipeline|Triggerer') }} + </div> + <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader"> + {{ s__('Pipeline|Commit') }} + </div> + <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader"> + {{ s__('Pipeline|Stages') }} + </div> + <div class="table-section section-15" role="rowheader"></div> + <div class="table-section section-20" role="rowheader"> + <slot name="table-header-actions"></slot> + </div> </div> + <pipelines-table-row-component + v-for="model in pipelines" + :key="model.id" + :pipeline="model" + :pipeline-schedule-url="pipelineScheduleUrl" + :update-graph-dropdown="updateGraphDropdown" + :view-type="viewType" + :canceling-pipeline="cancelingPipeline" + /> </div> - <pipelines-table-row-component - v-for="model in pipelines" - :key="model.id" - :pipeline="model" - :pipeline-schedule-url="pipelineScheduleUrl" - :update-graph-dropdown="updateGraphDropdown" - :auto-devops-help-path="autoDevopsHelpPath" - :view-type="viewType" - :canceling-pipeline="cancelingPipeline" - /> + + <gl-table + v-else + :fields="$options.fields" + :items="pipelines" + tbody-tr-class="commit" + :tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }" + stacked="lg" + fixed + > + <template #head(actions)> + <span class="gl-display-block gl-lg-display-none!">{{ s__('Pipeline|Actions') }}</span> + <slot name="table-header-actions"></slot> + </template> + + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(status)="{ item }"> + <pipelines-status-badge :pipeline="item" :view-type="viewType" /> + </template> + + <template #cell(pipeline)="{ item }"> + <pipeline-url :pipeline="item" :pipeline-schedule-url="pipelineScheduleUrl" /> + </template> + + <template #cell(triggerer)="{ item }"> + <pipeline-triggerer :pipeline="item" /> + </template> + + <template #cell(commit)="{ item }"> + <pipelines-commit :pipeline="item" :view-type="viewType" /> + </template> + + <template #cell(stages)="{ item }"> + <div class="stage-cell"> + <!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 --> + <div></div> + <pipeline-mini-graph + v-if="item.details && item.details.stages && item.details.stages.length > 0" + :stages="item.details.stages" + :update-dropdown="updateGraphDropdown" + @pipelineActionRequestComplete="onPipelineActionRequestComplete" + /> + </div> + </template> + + <template #cell(timeago)="{ item }"> + <pipelines-timeago :pipeline="item" /> + </template> + + <template #cell(actions)="{ item }"> + <pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" /> + </template> + </gl-table> + <pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue index 572abe2a24a..f684a0b0fcd 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue @@ -3,13 +3,12 @@ import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; import { __ } from '~/locale'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; import CommitComponent from '~/vue_shared/components/commit.vue'; -import { PIPELINES_TABLE } from '../../constants'; import eventHub from '../../event_hub'; +import PipelineMiniGraph from './pipeline_mini_graph.vue'; import PipelineTriggerer from './pipeline_triggerer.vue'; import PipelineUrl from './pipeline_url.vue'; -import PipelinesActionsComponent from './pipelines_actions.vue'; import PipelinesArtifactsComponent from './pipelines_artifacts.vue'; -import PipelineStage from './stage.vue'; +import PipelinesManualActionsComponent from './pipelines_manual_actions.vue'; import PipelinesTimeago from './time_ago.vue'; export default { @@ -22,10 +21,10 @@ export default { GlModalDirective, }, components: { - PipelinesActionsComponent, + PipelinesManualActionsComponent, PipelinesArtifactsComponent, CommitComponent, - PipelineStage, + PipelineMiniGraph, PipelineUrl, PipelineTriggerer, CiBadge, @@ -47,10 +46,6 @@ export default { required: false, default: false, }, - autoDevopsHelpPath: { - type: String, - required: true, - }, viewType: { type: String, required: true, @@ -61,7 +56,6 @@ export default { default: null, }, }, - pipelinesTable: PIPELINES_TABLE, data() { return { isRetrying: false, @@ -137,15 +131,12 @@ export default { commitTitle() { return this.pipeline?.commit?.title; }, - pipelineDuration() { - return this.pipeline?.details?.duration ?? 0; - }, - pipelineFinishedAt() { - return this.pipeline?.details?.finished_at ?? ''; - }, pipelineStatus() { return this.pipeline?.details?.status ?? {}; }, + hasStages() { + return this.pipeline?.details?.stages?.length > 0; + }, displayPipelineActions() { return ( this.pipeline.flags.retryable || @@ -177,6 +168,10 @@ export default { this.isRetrying = true; eventHub.$emit('retryPipeline', this.pipeline.retry_path); }, + handlePipelineActionRequestComplete() { + // warn the pipelines table to update + eventHub.$emit('refreshPipelinesTable'); + }, }, }; </script> @@ -194,11 +189,7 @@ export default { </div> </div> - <pipeline-url - :pipeline="pipeline" - :pipeline-schedule-url="pipelineScheduleUrl" - :auto-devops-help-path="autoDevopsHelpPath" - /> + <pipeline-url :pipeline="pipeline" :pipeline-schedule-url="pipelineScheduleUrl" /> <pipeline-triggerer :pipeline="pipeline" /> <div class="table-section section-wrap section-20"> @@ -220,35 +211,23 @@ export default { <div class="table-section section-wrap section-15 stage-cell"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div> <div class="table-mobile-content"> - <template v-if="pipeline.details.stages.length > 0"> - <div - v-for="(stage, index) in pipeline.details.stages" - :key="index" - class="stage-container dropdown" - data-testid="widget-mini-pipeline-graph" - > - <pipeline-stage - :type="$options.pipelinesTable" - :stage="stage" - :update-dropdown="updateGraphDropdown" - /> - </div> - </template> + <pipeline-mini-graph + v-if="hasStages" + :stages="pipeline.details.stages" + :update-dropdown="updateGraphDropdown" + @pipelineActionRequestComplete="handlePipelineActionRequestComplete" + /> </div> </div> - <pipelines-timeago - class="gl-text-right" - :duration="pipelineDuration" - :finished-time="pipelineFinishedAt" - /> + <pipelines-timeago class="gl-text-right" :pipeline="pipeline" /> <div v-if="displayPipelineActions" class="table-section section-20 table-button-footer pipeline-actions" > <div class="btn-group table-action-buttons"> - <pipelines-actions-component v-if="actions.length > 0" :actions="actions" /> + <pipelines-manual-actions-component v-if="actions.length > 0" :actions="actions" /> <pipelines-artifacts-component v-if="pipeline.details.artifacts.length" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue deleted file mode 100644 index f5dfb9e72d5..00000000000 --- a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue +++ /dev/null @@ -1,234 +0,0 @@ -<script> -/** - * Renders each stage of the pipeline mini graph. - * - * Given the provided endpoint will make a request to - * fetch the dropdown data when the stage is clicked. - * - * Request is made inside this component to make it reusable between: - * 1. Pipelines main table - * 2. Pipelines table in commit and Merge request views - * 3. Merge request widget - * 4. Commit widget - */ -import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; -import $ from 'jquery'; -import { deprecatedCreateFlash as Flash } from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { PIPELINES_TABLE } from '../../constants'; -import eventHub from '../../event_hub'; -import JobItem from '../graph/job_item.vue'; - -export default { - components: { - GlIcon, - GlLoadingIcon, - GlDropdown, - JobItem, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [glFeatureFlagsMixin()], - props: { - stage: { - type: Object, - required: true, - }, - - updateDropdown: { - type: Boolean, - required: false, - default: false, - }, - - type: { - type: String, - required: false, - default: '', - }, - }, - data() { - return { - isLoading: false, - dropdownContent: [], - }; - }, - computed: { - isCiMiniPipelineGlDropdown() { - // Feature flag ci_mini_pipeline_gl_dropdown - // See more at https://gitlab.com/gitlab-org/gitlab/-/issues/300400 - return this.glFeatures?.ciMiniPipelineGlDropdown; - }, - triggerButtonClass() { - return `ci-status-icon-${this.stage.status.group}`; - }, - borderlessIcon() { - return `${this.stage.status.icon}_borderless`; - }, - }, - watch: { - updateDropdown() { - if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) { - this.fetchJobs(); - } - }, - }, - updated() { - if (!this.isCiMiniPipelineGlDropdown && this.dropdownContent.length) { - this.stopDropdownClickPropagation(); - } - }, - methods: { - onShowDropdown() { - eventHub.$emit('clickedDropdown'); - this.isLoading = true; - this.fetchJobs(); - }, - onClickStage() { - if (!this.isDropdownOpen()) { - eventHub.$emit('clickedDropdown'); - this.isLoading = true; - this.fetchJobs(); - } - }, - fetchJobs() { - axios - .get(this.stage.dropdown_path) - .then(({ data }) => { - this.dropdownContent = data.latest_statuses; - this.isLoading = false; - }) - .catch(() => { - if (this.isCiMiniPipelineGlDropdown) { - this.$refs.stageGlDropdown.hide(); - } else { - this.closeDropdown(); - } - this.isLoading = false; - - Flash(__('Something went wrong on our end.')); - }); - }, - /** - * When the user right clicks or cmd/ctrl + click in the job name - * the dropdown should not be closed and the link should open in another tab, - * so we stop propagation of the click event inside the dropdown. - * - * Since this component is rendered multiple times per page we need to guarantee we only - * target the click event of this component. - * - * Note: This should be removed once ci_mini_pipeline_gl_dropdown FF is removed as true. - */ - stopDropdownClickPropagation() { - $( - '.js-builds-dropdown-list button, .js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item', - this.$el, - ).on('click', (e) => { - e.stopPropagation(); - }); - }, - closeDropdown() { - if (this.isDropdownOpen()) { - $(this.$refs.dropdown).dropdown('toggle'); - } - }, - isDropdownOpen() { - return this.$el.classList.contains('show'); - }, - pipelineActionRequestComplete() { - if (this.type === PIPELINES_TABLE) { - // warn the table to update - eventHub.$emit('refreshPipelinesTable'); - return; - } - // close the dropdown in mr widget - if (this.isCiMiniPipelineGlDropdown) { - this.$refs.stageGlDropdown.hide(); - } else { - $(this.$refs.dropdown).dropdown('toggle'); - } - }, - }, -}; -</script> - -<template> - <div class="dropdown"> - <gl-dropdown - v-if="isCiMiniPipelineGlDropdown" - ref="stageGlDropdown" - v-gl-tooltip.hover - data-testid="mini-pipeline-graph-dropdown" - :title="stage.title" - variant="link" - :lazy="true" - :popper-opts="{ placement: 'bottom' }" - :toggle-class="['mini-pipeline-graph-gl-dropdown-toggle', triggerButtonClass]" - menu-class="mini-pipeline-graph-dropdown-menu" - @show="onShowDropdown" - > - <template #button-content> - <span class="gl-pointer-events-none"> - <gl-icon :name="borderlessIcon" /> - </span> - </template> - <gl-loading-icon v-if="isLoading" /> - <ul - v-else - class="js-builds-dropdown-list scrollable-menu" - data-testid="mini-pipeline-graph-dropdown-menu-list" - > - <li v-for="job in dropdownContent" :key="job.id"> - <job-item - :dropdown-length="dropdownContent.length" - :job="job" - css-class-job-name="mini-pipeline-graph-dropdown-item" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </li> - </ul> - </gl-dropdown> - - <template v-else> - <button - id="stageDropdown" - ref="dropdown" - v-gl-tooltip.hover - :class="triggerButtonClass" - :title="stage.title" - class="mini-pipeline-graph-dropdown-toggle" - data-testid="mini-pipeline-graph-dropdown-toggle" - data-toggle="dropdown" - data-display="static" - type="button" - aria-haspopup="true" - aria-expanded="false" - @click="onClickStage" - > - <span :aria-label="stage.title" aria-hidden="true" class="gl-pointer-events-none"> - <gl-icon :name="borderlessIcon" /> - </span> - </button> - - <div - class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" - aria-labelledby="stageDropdown" - > - <gl-loading-icon v-if="isLoading" /> - <ul v-else class="js-builds-dropdown-list scrollable-menu"> - <li v-for="job in dropdownContent" :key="job.id"> - <job-item - :dropdown-length="dropdownContent.length" - :job="job" - css-class-job-name="mini-pipeline-graph-dropdown-item" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </li> - </ul> - </div> - </template> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue index 5548a1021f5..278089b6155 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { @@ -7,23 +8,19 @@ export default { GlTooltip: GlTooltipDirective, }, components: { GlIcon }, - mixins: [timeagoMixin], + mixins: [timeagoMixin, glFeatureFlagMixin()], props: { - finishedTime: { - type: String, - required: true, - }, - duration: { - type: Number, + pipeline: { + type: Object, required: true, }, }, computed: { - hasDuration() { - return this.duration > 0; + duration() { + return this.pipeline?.details?.duration; }, - hasFinishedTime() { - return this.finishedTime !== ''; + finishedTime() { + return this.pipeline?.details?.finished_at; }, durationFormatted() { const date = new Date(this.duration * 1000); @@ -45,20 +42,28 @@ export default { return `${hh}:${mm}:${ss}`; }, + legacySectionClass() { + return !this.glFeatures.newPipelinesTable ? 'table-section section-15' : ''; + }, + legacyTableMobileClass() { + return !this.glFeatures.newPipelinesTable ? 'table-mobile-content' : ''; + }, }, }; </script> <template> - <div class="table-section section-15"> - <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div> - <div class="table-mobile-content"> - <p v-if="hasDuration" class="duration"> - <gl-icon name="timer" class="gl-vertical-align-baseline!" /> + <div :class="legacySectionClass"> + <div v-if="!glFeatures.newPipelinesTable" class="table-mobile-header" role="rowheader"> + {{ s__('Pipeline|Duration') }} + </div> + <div :class="legacyTableMobileClass"> + <p v-if="duration" class="duration"> + <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" /> {{ durationFormatted }} </p> - <p v-if="hasFinishedTime" class="finished-at d-none d-md-block"> - <gl-icon name="calendar" class="gl-vertical-align-baseline!" /> + <p v-if="finishedTime" class="finished-at d-none d-md-block"> + <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" /> <time v-gl-tooltip diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 757d285ef19..21b114825a6 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -1,7 +1,6 @@ import { s__, __ } from '~/locale'; export const CANCEL_REQUEST = 'CANCEL_REQUEST'; -export const PIPELINES_TABLE = 'PIPELINES_TABLE'; export const LAYOUT_CHANGE_DELAY = 300; export const FILTER_PIPELINES_SEARCH_DELAY = 200; export const ANY_TRIGGER_AUTHOR = 'Any'; @@ -34,3 +33,5 @@ export const LOAD_FAILURE = 'load_failure'; export const PARSE_FAILURE = 'parse_failure'; export const POST_FAILURE = 'post_failure'; export const UNSUPPORTED_DATA = 'unsupported_data'; + +export const CHILD_VIEW = 'child'; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index f837851e5c1..c3444f38ea0 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -93,8 +93,7 @@ export default async function initPipelineDetailsBundle() { /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph' ); - const { pipelineProjectPath, pipelineIid } = dataset; - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid); + createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, dataset); } catch { Flash(__('An error occurred while loading the pipeline.')); } diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js index 55f3731a3ca..9eba39738dc 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js @@ -11,12 +11,15 @@ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( {}, { - batchMax: 2, + useGet: true, }, ), }); -const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => { +const createPipelinesDetailApp = ( + selector, + { pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {}, +) => { // eslint-disable-next-line no-new new Vue({ el: selector, @@ -25,8 +28,10 @@ const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => }, apolloProvider, provide: { + metricsPath, pipelineProjectPath, pipelineIid, + graphqlResourceEtag, dataMethod: GRAPHQL, }, errorCaptured(err, _vm, info) { diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 7bcc51e18e5..0e2e9785956 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -23,11 +23,9 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { const { endpoint, pipelineScheduleUrl, - helpPagePath, emptyStateSvgPath, errorStateSvgPath, noPipelinesSvgPath, - autoDevopsHelpPath, newPipelinePath, canCreatePipeline, hasGitlabCi, @@ -56,11 +54,9 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { store: this.store, endpoint, pipelineScheduleUrl, - helpPagePath, emptyStateSvgPath, errorStateSvgPath, noPipelinesSvgPath, - autoDevopsHelpPath, newPipelinePath, canCreatePipeline: parseBoolean(canCreatePipeline), hasGitlabCi: parseBoolean(hasGitlabCi), diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index c5478aa0226..f18c4d8f03e 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -90,7 +90,10 @@ Please update your Git repository remotes as soon as possible.`), this.isRequestPending = false; }) .catch((error) => { - Flash(error.response.data.message); + Flash( + error?.response?.data?.message || + s__('Profiles|An error occurred while updating your username, please try again.'), + ); this.isRequestPending = false; throw error; }); @@ -121,7 +124,8 @@ Please update your Git repository remotes as soon as possible.`), </div> <gl-button v-gl-modal-directive="$options.modalId" - :disabled="isRequestPending || newUsername === username" + :disabled="newUsername === username" + :loading="isRequestPending" category="primary" variant="warning" data-testid="username-change-confirmation-modal" diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index 184ee3810ac..07d8f3cc5f1 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -44,6 +44,8 @@ export default { data() { return { isSubmitEnabled: true, + darkModeOnCreate: null, + darkModeOnSubmit: null, }; }, computed: { @@ -58,6 +60,7 @@ export default { this.formEl.addEventListener('ajax:beforeSend', this.handleLoading); this.formEl.addEventListener('ajax:success', this.handleSuccess); this.formEl.addEventListener('ajax:error', this.handleError); + this.darkModeOnCreate = this.darkModeSelected(); }, beforeDestroy() { this.formEl.removeEventListener('ajax:beforeSend', this.handleLoading); @@ -65,16 +68,27 @@ export default { this.formEl.removeEventListener('ajax:error', this.handleError); }, methods: { + darkModeSelected() { + const theme = this.getSelectedTheme(); + return theme ? theme.css_class === 'gl-dark' : null; + }, + getSelectedTheme() { + const themeId = new FormData(this.formEl).get('user[theme_id]'); + return this.applicationThemes[themeId] ?? null; + }, handleLoading() { this.isSubmitEnabled = false; + this.darkModeOnSubmit = this.darkModeSelected(); }, handleSuccess(customEvent) { - const formData = new FormData(this.formEl); - updateClasses( - this.bodyClasses, - this.applicationThemes[formData.get('user[theme_id]')].css_class, - this.selectedLayout, - ); + // Reload the page if the theme has changed from light to dark mode or vice versa + // to correctly load all required styles. + const modeChanged = this.darkModeOnCreate ? !this.darkModeOnSubmit : this.darkModeOnSubmit; + if (modeChanged) { + window.location.reload(); + return; + } + updateClasses(this.bodyClasses, this.getSelectedTheme().css_class, this.selectedLayout); const { message = this.$options.i18n.defaultSuccess, type = FLASH_TYPES.NOTICE } = customEvent?.detail?.[0] || {}; createFlash({ message, type }); diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index a7332b81b9f..dad2c18fb18 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -95,6 +95,7 @@ export default class Profile { updateHeaderAvatar() { $('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL); + $('.js-sidebar-user-avatar').attr('src', this.avatarGlCrop.dataURL); } setRepoRadio() { diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index 72d4f0c31e5..741dc20b1f1 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,8 +1,8 @@ +import * as Sentry from '@sentry/browser'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import * as types from './mutation_types'; export default { diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index 05bd0f1370b..d2fb524489e 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -1,12 +1,12 @@ <script> import { GlButton } from '@gitlab/ui'; import csrf from '~/lib/utils/csrf'; -import RevisionDropdown from './revision_dropdown.vue'; +import RevisionCard from './revision_card.vue'; export default { csrf, components: { - RevisionDropdown, + RevisionCard, GlButton, }, props: { @@ -48,42 +48,53 @@ export default { <template> <form ref="form" - class="form-inline js-requires-input js-signature-container" + class="js-requires-input js-signature-container" method="POST" :action="projectCompareIndexPath" > <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> - <revision-dropdown - :refs-project-path="refsProjectPath" - revision-text="Source" - params-name="to" - :params-branch="paramsTo" - /> - <div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div> - <revision-dropdown - :refs-project-path="refsProjectPath" - revision-text="Target" - params-name="from" - :params-branch="paramsFrom" - /> - <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit"> - {{ s__('CompareRevisions|Compare') }} - </gl-button> - <a - v-if="projectMergeRequestPath" - :href="projectMergeRequestPath" - data-testid="projectMrButton" - class="btn btn-default gl-button gl-ml-3" + <div + class="gl-lg-flex-direction-row gl-lg-display-flex gl-align-items-center compare-revision-cards" > - {{ s__('CompareRevisions|View open merge request') }} - </a> - <a - v-else-if="createMrPath" - :href="createMrPath" - data-testid="createMrButton" - class="btn btn-default gl-button gl-ml-3" - > - {{ s__('CompareRevisions|Create merge request') }} - </a> + <revision-card + :refs-project-path="refsProjectPath" + revision-text="Source" + params-name="to" + :params-branch="paramsTo" + /> + <div + class="compare-ellipsis gl-display-flex gl-justify-content-center gl-align-items-center gl-my-4 gl-md-my-0" + data-testid="ellipsis" + > + ... + </div> + <revision-card + :refs-project-path="refsProjectPath" + revision-text="Target" + params-name="from" + :params-branch="paramsFrom" + /> + </div> + <div class="gl-mt-4"> + <gl-button category="primary" variant="success" @click="onSubmit"> + {{ s__('CompareRevisions|Compare') }} + </gl-button> + <gl-button + v-if="projectMergeRequestPath" + :href="projectMergeRequestPath" + data-testid="projectMrButton" + class="btn btn-default gl-button" + > + {{ s__('CompareRevisions|View open merge request') }} + </gl-button> + <gl-button + v-else-if="createMrPath" + :href="createMrPath" + data-testid="createMrButton" + class="btn btn-default gl-button" + > + {{ s__('CompareRevisions|Create merge request') }} + </gl-button> + </div> </form> </template> diff --git a/app/assets/javascripts/projects/compare/components/app_legacy.vue b/app/assets/javascripts/projects/compare/components/app_legacy.vue new file mode 100644 index 00000000000..c0ff58ee074 --- /dev/null +++ b/app/assets/javascripts/projects/compare/components/app_legacy.vue @@ -0,0 +1,89 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import RevisionDropdown from './revision_dropdown_legacy.vue'; + +export default { + csrf, + components: { + RevisionDropdown, + GlButton, + }, + props: { + projectCompareIndexPath: { + type: String, + required: true, + }, + refsProjectPath: { + type: String, + required: true, + }, + paramsFrom: { + type: String, + required: false, + default: null, + }, + paramsTo: { + type: String, + required: false, + default: null, + }, + projectMergeRequestPath: { + type: String, + required: true, + }, + createMrPath: { + type: String, + required: true, + }, + }, + methods: { + onSubmit() { + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <form + ref="form" + class="form-inline js-requires-input js-signature-container" + method="POST" + :action="projectCompareIndexPath" + > + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <revision-dropdown + :refs-project-path="refsProjectPath" + revision-text="Source" + params-name="to" + :params-branch="paramsTo" + /> + <div class="compare-ellipsis gl-display-inline" data-testid="ellipsis">...</div> + <revision-dropdown + :refs-project-path="refsProjectPath" + revision-text="Target" + params-name="from" + :params-branch="paramsFrom" + /> + <gl-button category="primary" variant="success" class="gl-ml-3" @click="onSubmit"> + {{ s__('CompareRevisions|Compare') }} + </gl-button> + <gl-button + v-if="projectMergeRequestPath" + :href="projectMergeRequestPath" + data-testid="projectMrButton" + class="btn btn-default gl-button gl-ml-3" + > + {{ s__('CompareRevisions|View open merge request') }} + </gl-button> + <gl-button + v-else-if="createMrPath" + :href="createMrPath" + data-testid="createMrButton" + class="btn btn-default gl-button gl-ml-3" + > + {{ s__('CompareRevisions|Create merge request') }} + </gl-button> + </form> +</template> diff --git a/app/assets/javascripts/projects/compare/components/repo_dropdown.vue b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue new file mode 100644 index 00000000000..822dfc09d81 --- /dev/null +++ b/app/assets/javascripts/projects/compare/components/repo_dropdown.vue @@ -0,0 +1,93 @@ +<script> +import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; + +const SOURCE_PARAM_NAME = 'to'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + }, + inject: ['projectTo', 'projectsFrom'], + props: { + paramsName: { + type: String, + required: true, + }, + }, + data() { + return { + searchTerm: '', + selectedRepo: {}, + }; + }, + computed: { + filteredRepos() { + const lowerCaseSearchTerm = this.searchTerm.toLowerCase(); + + return this?.projectsFrom.filter(({ name }) => + name.toLowerCase().includes(lowerCaseSearchTerm), + ); + }, + isSourceRevision() { + return this.paramsName === SOURCE_PARAM_NAME; + }, + inputName() { + return `${this.paramsName}_project_id`; + }, + }, + mounted() { + this.setDefaultRepo(); + }, + methods: { + onClick(repo) { + this.selectedRepo = repo; + this.emitTargetProject(repo.name); + }, + setDefaultRepo() { + if (this.isSourceRevision) { + this.selectedRepo = this.projectTo; + return; + } + + const [defaultTargetProject] = this.projectsFrom; + this.emitTargetProject(defaultTargetProject.name); + this.selectedRepo = defaultTargetProject; + }, + emitTargetProject(name) { + if (!this.isSourceRevision) { + this.$emit('changeTargetProject', name); + } + }, + }, +}; +</script> + +<template> + <div> + <input type="hidden" :name="inputName" :value="selectedRepo.id" /> + <gl-dropdown + :text="selectedRepo.name" + :header-text="s__(`CompareRevisions|Select target project`)" + class="gl-w-full gl-font-monospace gl-sm-pr-3" + toggle-class="gl-min-w-0" + :disabled="isSourceRevision" + > + <template #header> + <gl-search-box-by-type v-if="!isSourceRevision" v-model.trim="searchTerm" /> + </template> + <template v-if="!isSourceRevision"> + <gl-dropdown-item + v-for="repo in filteredRepos" + :key="repo.id" + is-check-item + :is-checked="selectedRepo.id === repo.id" + @click="onClick(repo)" + > + {{ repo.name }} + </gl-dropdown-item> + </template> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/projects/compare/components/revision_card.vue b/app/assets/javascripts/projects/compare/components/revision_card.vue new file mode 100644 index 00000000000..15d24792310 --- /dev/null +++ b/app/assets/javascripts/projects/compare/components/revision_card.vue @@ -0,0 +1,65 @@ +<script> +import { GlCard } from '@gitlab/ui'; +import RepoDropdown from './repo_dropdown.vue'; +import RevisionDropdown from './revision_dropdown.vue'; + +export default { + components: { + RepoDropdown, + RevisionDropdown, + GlCard, + }, + props: { + refsProjectPath: { + type: String, + required: true, + }, + revisionText: { + type: String, + required: true, + }, + paramsName: { + type: String, + required: true, + }, + paramsBranch: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + selectedRefsProjectPath: this.refsProjectPath, + }; + }, + methods: { + onChangeTargetProject(targetProjectName) { + if (this.paramsName === 'from') { + this.selectedRefsProjectPath = `/${targetProjectName}/refs`; + } + }, + }, +}; +</script> + +<template> + <gl-card header-class="gl-py-2 gl-px-3 gl-font-weight-bold" body-class="gl-px-3"> + <template #header> + {{ s__(`CompareRevisions|${revisionText}`) }} + </template> + <div class="gl-sm-display-flex gl-align-items-center"> + <repo-dropdown + class="gl-sm-w-half" + :params-name="paramsName" + @changeTargetProject="onChangeTargetProject" + /> + <revision-dropdown + class="gl-sm-w-half gl-mt-3 gl-sm-mt-0" + :refs-project-path="selectedRefsProjectPath" + :params-name="paramsName" + :params-branch="paramsBranch" + /> + </div> + </gl-card> +</template> diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue index 13d80b5ae0b..a175af2f32e 100644 --- a/app/assets/javascripts/projects/compare/components/revision_dropdown.vue +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown.vue @@ -4,6 +4,8 @@ import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { s__ } from '~/locale'; +const emptyDropdownText = s__('CompareRevisions|Select branch/tag'); + export default { components: { GlDropdown, @@ -16,10 +18,6 @@ export default { type: String, required: true, }, - revisionText: { - type: String, - required: true, - }, paramsName: { type: String, required: true, @@ -55,12 +53,24 @@ export default { return this.filteredTags.length; }, }, + watch: { + refsProjectPath(newRefsProjectPath, oldRefsProjectPath) { + if (newRefsProjectPath !== oldRefsProjectPath) { + this.fetchBranchesAndTags(true); + } + }, + }, mounted() { this.fetchBranchesAndTags(); }, methods: { - fetchBranchesAndTags() { + fetchBranchesAndTags(reset = false) { const endpoint = this.refsProjectPath; + this.loading = true; + + if (reset) { + this.selectedRevision = this.getDefaultBranch(); + } return axios .get(endpoint) @@ -70,9 +80,9 @@ export default { }) .catch(() => { createFlash({ - message: `${s__( - 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.', - )}`, + message: s__( + 'CompareRevisions|There was an error while loading the branch/tag list. Please try again.', + ), }); }) .finally(() => { @@ -80,7 +90,7 @@ export default { }); }, getDefaultBranch() { - return this.paramsBranch || s__('CompareRevisions|Select branch/tag'); + return this.paramsBranch || emptyDropdownText; }, onClick(revision) { this.selectedRevision = revision; @@ -93,53 +103,46 @@ export default { </script> <template> - <div class="form-group compare-form-group" :class="`js-compare-${paramsName}-dropdown`"> - <div class="input-group inline-input-group"> - <span class="input-group-prepend"> - <div class="input-group-text"> - {{ revisionText }} - </div> - </span> - <input type="hidden" :name="paramsName" :value="selectedRevision" /> - <gl-dropdown - class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace" - toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!" - :text="selectedRevision" - header-text="Select Git revision" - :loading="loading" + <div :class="`js-compare-${paramsName}-dropdown`"> + <input type="hidden" :name="paramsName" :value="selectedRevision" /> + <gl-dropdown + class="gl-w-full gl-font-monospace" + toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0" + :text="selectedRevision" + :header-text="s__('CompareRevisions|Select Git revision')" + :loading="loading" + > + <template #header> + <gl-search-box-by-type + v-model.trim="searchTerm" + :placeholder="s__('CompareRevisions|Filter by Git revision')" + @keyup.enter="onSearchEnter" + /> + </template> + <gl-dropdown-section-header v-if="hasFilteredBranches"> + {{ s__('CompareRevisions|Branches') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="(branch, index) in filteredBranches" + :key="`branch${index}`" + is-check-item + :is-checked="selectedRevision === branch" + @click="onClick(branch)" + > + {{ branch }} + </gl-dropdown-item> + <gl-dropdown-section-header v-if="hasFilteredTags"> + {{ s__('CompareRevisions|Tags') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="(tag, index) in filteredTags" + :key="`tag${index}`" + is-check-item + :is-checked="selectedRevision === tag" + @click="onClick(tag)" > - <template #header> - <gl-search-box-by-type - v-model.trim="searchTerm" - :placeholder="s__('CompareRevisions|Filter by Git revision')" - @keyup.enter="onSearchEnter" - /> - </template> - <gl-dropdown-section-header v-if="hasFilteredBranches"> - {{ s__('CompareRevisions|Branches') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="(branch, index) in filteredBranches" - :key="`branch${index}`" - is-check-item - :is-checked="selectedRevision === branch" - @click="onClick(branch)" - > - {{ branch }} - </gl-dropdown-item> - <gl-dropdown-section-header v-if="hasFilteredTags"> - {{ s__('CompareRevisions|Tags') }} - </gl-dropdown-section-header> - <gl-dropdown-item - v-for="(tag, index) in filteredTags" - :key="`tag${index}`" - is-check-item - :is-checked="selectedRevision === tag" - @click="onClick(tag)" - > - {{ tag }} - </gl-dropdown-item> - </gl-dropdown> - </div> + {{ tag }} + </gl-dropdown-item> + </gl-dropdown> </div> </template> diff --git a/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue new file mode 100644 index 00000000000..13d80b5ae0b --- /dev/null +++ b/app/assets/javascripts/projects/compare/components/revision_dropdown_legacy.vue @@ -0,0 +1,145 @@ +<script> +import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlDropdownSectionHeader } from '@gitlab/ui'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + GlDropdownSectionHeader, + GlSearchBoxByType, + }, + props: { + refsProjectPath: { + type: String, + required: true, + }, + revisionText: { + type: String, + required: true, + }, + paramsName: { + type: String, + required: true, + }, + paramsBranch: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + branches: [], + tags: [], + loading: true, + searchTerm: '', + selectedRevision: this.getDefaultBranch(), + }; + }, + computed: { + filteredBranches() { + return this.branches.filter((branch) => + branch.toLowerCase().includes(this.searchTerm.toLowerCase()), + ); + }, + hasFilteredBranches() { + return this.filteredBranches.length; + }, + filteredTags() { + return this.tags.filter((tag) => tag.toLowerCase().includes(this.searchTerm.toLowerCase())); + }, + hasFilteredTags() { + return this.filteredTags.length; + }, + }, + mounted() { + this.fetchBranchesAndTags(); + }, + methods: { + fetchBranchesAndTags() { + const endpoint = this.refsProjectPath; + + return axios + .get(endpoint) + .then(({ data }) => { + this.branches = data.Branches || []; + this.tags = data.Tags || []; + }) + .catch(() => { + createFlash({ + message: `${s__( + 'CompareRevisions|There was an error while updating the branch/tag list. Please try again.', + )}`, + }); + }) + .finally(() => { + this.loading = false; + }); + }, + getDefaultBranch() { + return this.paramsBranch || s__('CompareRevisions|Select branch/tag'); + }, + onClick(revision) { + this.selectedRevision = revision; + }, + onSearchEnter() { + this.selectedRevision = this.searchTerm; + }, + }, +}; +</script> + +<template> + <div class="form-group compare-form-group" :class="`js-compare-${paramsName}-dropdown`"> + <div class="input-group inline-input-group"> + <span class="input-group-prepend"> + <div class="input-group-text"> + {{ revisionText }} + </div> + </span> + <input type="hidden" :name="paramsName" :value="selectedRevision" /> + <gl-dropdown + class="gl-flex-grow-1 gl-flex-basis-0 gl-min-w-0 gl-font-monospace" + toggle-class="form-control compare-dropdown-toggle js-compare-dropdown gl-min-w-0 gl-rounded-top-left-none! gl-rounded-bottom-left-none!" + :text="selectedRevision" + header-text="Select Git revision" + :loading="loading" + > + <template #header> + <gl-search-box-by-type + v-model.trim="searchTerm" + :placeholder="s__('CompareRevisions|Filter by Git revision')" + @keyup.enter="onSearchEnter" + /> + </template> + <gl-dropdown-section-header v-if="hasFilteredBranches"> + {{ s__('CompareRevisions|Branches') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="(branch, index) in filteredBranches" + :key="`branch${index}`" + is-check-item + :is-checked="selectedRevision === branch" + @click="onClick(branch)" + > + {{ branch }} + </gl-dropdown-item> + <gl-dropdown-section-header v-if="hasFilteredTags"> + {{ s__('CompareRevisions|Tags') }} + </gl-dropdown-section-header> + <gl-dropdown-item + v-for="(tag, index) in filteredTags" + :key="`tag${index}`" + is-check-item + :is-checked="selectedRevision === tag" + @click="onClick(tag)" + > + {{ tag }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </div> +</template> diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js index 4337eecb667..4ba4e308cd4 100644 --- a/app/assets/javascripts/projects/compare/index.js +++ b/app/assets/javascripts/projects/compare/index.js @@ -1,8 +1,46 @@ import Vue from 'vue'; import CompareApp from './components/app.vue'; +import CompareAppLegacy from './components/app_legacy.vue'; export default function init() { const el = document.getElementById('js-compare-selector'); + + if (gon.features?.compareRepoDropdown) { + const { + refsProjectPath, + paramsFrom, + paramsTo, + projectCompareIndexPath, + projectMergeRequestPath, + createMrPath, + projectTo, + projectsFrom, + } = el.dataset; + + return new Vue({ + el, + components: { + CompareApp, + }, + provide: { + projectTo: JSON.parse(projectTo), + projectsFrom: JSON.parse(projectsFrom), + }, + render(createElement) { + return createElement(CompareApp, { + props: { + refsProjectPath, + paramsFrom, + paramsTo, + projectCompareIndexPath, + projectMergeRequestPath, + createMrPath, + }, + }); + }, + }); + } + const { refsProjectPath, paramsFrom, @@ -15,10 +53,10 @@ export default function init() { return new Vue({ el, components: { - CompareApp, + CompareAppLegacy, }, render(createElement) { - return createElement(CompareApp, { + return createElement(CompareAppLegacy, { props: { refsProjectPath, paramsFrom, diff --git a/app/assets/javascripts/projects/details/upload_button.vue b/app/assets/javascripts/projects/details/upload_button.vue new file mode 100644 index 00000000000..a89ea34c438 --- /dev/null +++ b/app/assets/javascripts/projects/details/upload_button.vue @@ -0,0 +1,49 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; + +const UPLOAD_BLOB_MODAL_ID = 'details-modal-upload-blob'; + +export default { + components: { + GlButton, + UploadBlobModal, + }, + directives: { + GlModal: GlModalDirective, + }, + inject: { + targetBranch: { + default: '', + }, + origionalBranch: { + default: '', + }, + canPushCode: { + default: false, + }, + path: { + default: '', + }, + projectPath: { + default: '', + }, + }, + uploadBlobModalId: UPLOAD_BLOB_MODAL_ID, +}; +</script> +<template> + <span> + <gl-button v-gl-modal="$options.uploadBlobModalId" icon="upload">{{ + __('Upload File') + }}</gl-button> + <upload-blob-modal + :modal-id="$options.uploadBlobModalId" + :commit-message="__('Upload New File')" + :target-branch="targetBranch" + :origional-branch="origionalBranch" + :can-push-code="canPushCode" + :path="path" + /> + </span> +</template> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue new file mode 100644 index 00000000000..e42d9154866 --- /dev/null +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/new_project_push_tip_popover.vue @@ -0,0 +1,66 @@ +<script> +import { GlPopover, GlFormInputGroup } from '@gitlab/ui'; +import { __ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + components: { + GlPopover, + GlFormInputGroup, + ClipboardButton, + }, + inject: ['pushToCreateProjectCommand', 'workingWithProjectsHelpPath'], + props: { + target: { + type: [Function, HTMLElement], + required: true, + }, + }, + i18n: { + clipboardButtonTitle: __('Copy command'), + commandInputAriaLabel: __('Push project from command line'), + helpLinkText: __('What does this command do?'), + labelText: __('Private projects can be created in your personal namespace with:'), + popoverTitle: __('Push to create a project'), + }, +}; +</script> +<template> + <gl-popover + :target="target" + :title="$options.i18n.popoverTitle" + triggers="click blur" + placement="top" + > + <p> + <label for="push-to-create-tip" class="gl-font-weight-normal"> + {{ $options.i18n.labelText }} + </label> + </p> + <p> + <gl-form-input-group + id="push-to-create-tip" + :value="pushToCreateProjectCommand" + readonly + select-on-click + :aria-label="$options.i18n.commandInputAriaLabel" + > + <template #append> + <clipboard-button + :text="pushToCreateProjectCommand" + :title="$options.i18n.clipboardButtonTitle" + tooltip-placement="right" + /> + </template> + </gl-form-input-group> + </p> + <p> + <a + :href="`${workingWithProjectsHelpPath}#push-to-create-a-new-project`" + class="gl-font-sm" + target="_blank" + >{{ $options.i18n.helpLinkText }}</a + > + </p> + </gl-popover> +</template> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue index 63a65975fff..ed82a635b1f 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue @@ -1,15 +1,13 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlPopover } from '@gitlab/ui'; import Tracking from '~/tracking'; -import LegacyContainer from './legacy_container.vue'; +import NewProjectPushTipPopover from './new_project_push_tip_popover.vue'; const trackingMixin = Tracking.mixin(gon.tracking_data); export default { components: { - GlPopover, - LegacyContainer, + NewProjectPushTipPopover, }, mixins: [trackingMixin], props: { @@ -52,19 +50,15 @@ export default { <p> {{ __('You can also create a project from the command line.') }} <a - id="cli-tip" + ref="clipTip" href="#" click.prevent class="push-new-project-tip" - data-title="Push to create a project" rel="noopener noreferrer" > {{ __('Show command') }} </a> - - <gl-popover target="cli-tip" triggers="click blur" placement="top"> - <legacy-container selector=".push-new-project-tip-template" /> - </gl-popover> + <new-project-push-tip-popover :target="() => $refs.clipTip" /> </p> </div> </div> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/index.js b/app/assets/javascripts/projects/experiment_new_project_creation/index.js index 0414f7ef6a5..ea686d4e1e8 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/index.js +++ b/app/assets/javascripts/projects/experiment_new_project_creation/index.js @@ -2,11 +2,17 @@ import Vue from 'vue'; import NewProjectCreationApp from './components/app.vue'; export default function initNewProjectCreation(el, props) { + const { pushToCreateProjectCommand, workingWithProjectsHelpPath } = el.dataset; + return new Vue({ el, components: { NewProjectCreationApp, }, + provide: { + workingWithProjectsHelpPath, + pushToCreateProjectCommand, + }, render(h) { return h(NewProjectCreationApp, { props }); }, diff --git a/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js new file mode 100644 index 00000000000..2bd3e57322d --- /dev/null +++ b/app/assets/javascripts/projects/feature_flags_user_lists/show/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import UserList from '~/user_lists/components/user_list.vue'; +import createStore from '~/user_lists/store/show'; + +Vue.use(Vuex); + +export default function featureFlagsUserListInit() { + const el = document.getElementById('js-edit-user-list'); + + if (!el) { + return null; + } + + return new Vue({ + el, + store: createStore(el.dataset), + render(h) { + const { emptyStatePath } = el.dataset; + return h(UserList, { props: { emptyStatePath } }); + }, + }); +} diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index 733f833d51a..6a963616224 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -252,10 +252,10 @@ export default { }, errorTexts: { [LOAD_ANALYTICS_FAILURE]: s__( - 'PipelineCharts|An error has ocurred when retrieving the analytics data', + 'PipelineCharts|An error has occurred when retrieving the analytics data', ), [LOAD_PIPELINES_FAILURE]: s__( - 'PipelineCharts|An error has ocurred when retrieving the pipelines data', + 'PipelineCharts|An error has occurred when retrieving the pipelines data', ), [PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'), [DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'), @@ -292,7 +292,7 @@ export default { failure.text }}</gl-alert> <div class="gl-mb-3"> - <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3> + <h3>{{ s__('PipelineCharts|CI/CD Analytics') }}</h3> </div> <h4 class="gl-my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4> <div class="row"> diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index e3ba84102a8..04ea6f760f6 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,6 +1,5 @@ import $ from 'jquery'; import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; -import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; import { convertToTitleCase, humanize, @@ -81,7 +80,6 @@ const bindEvents = () => { const $selectedTemplateText = $('.selected-template'); const $changeTemplateBtn = $('.change-template'); const $selectedIcon = $('.selected-icon'); - const $pushNewProjectTipTrigger = $('.push-new-project-tip'); const $projectTemplateButtons = $('.project-templates-buttons'); const $projectName = $('.tab-pane.active #project_name'); @@ -108,39 +106,6 @@ const bindEvents = () => { ); }); - if ($pushNewProjectTipTrigger) { - $pushNewProjectTipTrigger - .removeAttr('rel') - .removeAttr('target') - .on('click', (e) => { - e.preventDefault(); - }) - .popover({ - title: $pushNewProjectTipTrigger.data('title'), - placement: 'bottom', - html: true, - content: $('.push-new-project-tip-template').html(), - }) - .on('shown.bs.popover', () => { - $(document).on('click.popover touchstart.popover', (event) => { - if ($(event.target).closest('.popover').length === 0) { - $pushNewProjectTipTrigger.trigger('click'); - } - }); - - const target = $(`#${$pushNewProjectTipTrigger.attr('aria-describedby')}`).find( - '.js-select-on-focus', - ); - addSelectOnFocusBehaviour(target); - - target.focus(); - }) - .on('hide.bs.popover', () => { - // eslint-disable-next-line @gitlab/no-global-event-off - $(document).off('click.popover touchstart.popover'); - }); - } - function chooseTemplate() { $projectTemplateButtons.addClass('hidden'); $projectFieldsForm.addClass('selected'); 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 9b3c0dd2755..fb00f58abae 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 @@ -95,7 +95,7 @@ export default { }) .catch((err) => { this.showAlert( - sprintf(__('An error occured while saving changes: %{error}'), { + sprintf(__('An error occurred while saving changes: %{error}'), { error: err?.response?.data?.message, }), ); 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 39d9a6a4239..ece4e271200 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 @@ -162,6 +162,7 @@ export default { <gl-form-select id="service-desk-template-select" v-model="selectedTemplate" + data-qa-selector="service_desk_template_dropdown" :options="templateOptions" /> <label for="service-desk-email-from-name" class="mt-3"> @@ -175,6 +176,7 @@ export default { <gl-button variant="success" class="gl-mt-5" + data-qa-selector="save_service_desk_settings_button" :disabled="isTemplateSaving" @click="onSaveTemplate" > diff --git a/app/assets/javascripts/projects/upload_file_experiment.js b/app/assets/javascripts/projects/upload_file_experiment.js new file mode 100644 index 00000000000..7d61df36a75 --- /dev/null +++ b/app/assets/javascripts/projects/upload_file_experiment.js @@ -0,0 +1,24 @@ +import ExperimentTracking from '~/experimentation/experiment_tracking'; + +function trackEvent(eventName) { + const isEmpty = Boolean(document.querySelector('.project-home-panel.empty-project')); + const property = isEmpty ? 'empty' : 'nonempty'; + const label = 'blob-upload-modal'; + const Tracking = new ExperimentTracking('empty_repo_upload', { label, property }); + + Tracking.event(eventName); +} + +export function initUploadFileTrigger() { + const uploadFileTriggerEl = document.querySelector('.js-upload-file-experiment-trigger'); + + if (uploadFileTriggerEl) { + uploadFileTriggerEl.addEventListener('click', () => { + trackEvent('click_upload_modal_trigger'); + }); + } +} + +export function trackUploadFileFormSubmitted() { + trackEvent('click_upload_modal_form_submit'); +} diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index c9474f0516c..726ddba1014 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -15,17 +15,23 @@ export default class ProtectedBranchCreate { this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); this.currentProjectUserDefaults = {}; this.buildDropdowns(); + this.$forcePushToggle = this.$form.find('.js-force-push-toggle'); this.$codeOwnerToggle = this.$form.find('.js-code-owner-toggle'); this.bindEvents(); } bindEvents() { + this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this)); if (this.hasLicense) { this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this)); } this.$form.on('submit', this.onFormSubmit.bind(this)); } + onForcePushToggleClick() { + this.$forcePushToggle.toggleClass('is-checked'); + } + onCodeOwnerToggleClick() { this.$codeOwnerToggle.toggleClass('is-checked'); } @@ -86,6 +92,7 @@ export default class ProtectedBranchCreate { authenticity_token: this.$form.find('input[name="authenticity_token"]').val(), protected_branch: { name: this.$form.find('input[name="protected_branch[name]"]').val(), + allow_force_push: this.$forcePushToggle.hasClass('is-checked'), code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'), }, }; diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 986abeecafa..bd2694e0cf7 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -14,6 +14,7 @@ export default class ProtectedBranchEdit { this.$wrap = options.$wrap; this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); + this.$forcePushToggle = this.$wrap.find('.js-force-push-toggle'); this.$codeOwnerToggle = this.$wrap.find('.js-code-owner-toggle'); this.$wraps[ACCESS_LEVELS.MERGE] = this.$allowedToMergeDropdown.closest( @@ -28,11 +29,23 @@ export default class ProtectedBranchEdit { } bindEvents() { + this.$forcePushToggle.on('click', this.onForcePushToggleClick.bind(this)); if (this.hasLicense) { this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this)); } } + onForcePushToggleClick() { + this.$forcePushToggle.toggleClass('is-checked'); + this.$forcePushToggle.prop('disabled', true); + + const formData = { + allow_force_push: this.$forcePushToggle.hasClass('is-checked'), + }; + + this.updateProtectedBranch(formData, () => this.$forcePushToggle.prop('disabled', false)); + } + onCodeOwnerToggleClick() { this.$codeOwnerToggle.toggleClass('is-checked'); this.$codeOwnerToggle.prop('disabled', true); @@ -41,17 +54,15 @@ export default class ProtectedBranchEdit { code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'), }; - this.updateCodeOwnerApproval(formData); + this.updateProtectedBranch(formData, () => this.$codeOwnerToggle.prop('disabled', false)); } - updateCodeOwnerApproval(formData) { + updateProtectedBranch(formData, callback) { axios .patch(this.$wrap.data('url'), { protected_branch: formData, }) - .then(() => { - this.$codeOwnerToggle.prop('disabled', false); - }) + .then(callback) .catch(() => { flash(__('Failed to update branch!')); }); diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue index 87ce4f1a49c..4fa2a92ff03 100644 --- a/app/assets/javascripts/ref/components/ref_results_section.vue +++ b/app/assets/javascripts/ref/components/ref_results_section.vue @@ -11,6 +11,12 @@ export default { GlIcon, }, props: { + showHeader: { + type: Boolean, + required: false, + default: true, + }, + sectionTitle: { type: String, required: true, @@ -84,7 +90,7 @@ export default { <template> <div> - <gl-dropdown-section-header> + <gl-dropdown-section-header v-if="showHeader"> <div class="gl-display-flex align-items-center" data-testid="section-header"> <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 8f2805b36f6..82963fe98fd 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -2,32 +2,48 @@ import { GlDropdown, GlDropdownDivider, - GlDropdownSectionHeader, GlSearchBoxByType, GlSprintf, - GlIcon, GlLoadingIcon, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, isArray } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; -import { SEARCH_DEBOUNCE_MS, DEFAULT_I18N } from '../constants'; +import { + ALL_REF_TYPES, + SEARCH_DEBOUNCE_MS, + DEFAULT_I18N, + REF_TYPE_BRANCHES, + REF_TYPE_TAGS, + REF_TYPE_COMMITS, +} from '../constants'; import createStore from '../stores'; import RefResultsSection from './ref_results_section.vue'; export default { name: 'RefSelector', - store: createStore(), components: { GlDropdown, GlDropdownDivider, - GlDropdownSectionHeader, GlSearchBoxByType, GlSprintf, - GlIcon, GlLoadingIcon, RefResultsSection, }, props: { + enabledRefTypes: { + type: Array, + required: false, + default: () => ALL_REF_TYPES, + validator: (val) => + // It has to be an arrray + isArray(val) && + // with at least one item + val.length > 0 && + // and only "REF_TYPE_BRANCHES", "REF_TYPE_TAGS", and "REF_TYPE_COMMITS" are allowed + val.every((item) => ALL_REF_TYPES.includes(item)) && + // and no duplicates are allowed + val.length === new Set(val).size, + }, value: { type: String, required: false, @@ -42,6 +58,13 @@ export default { required: false, default: () => ({}), }, + + /** The validation state of this component. */ + state: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -62,17 +85,45 @@ export default { }; }, showBranchesSection() { - return Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error); + return ( + this.enabledRefTypes.includes(REF_TYPE_BRANCHES) && + Boolean(this.matches.branches.totalCount > 0 || this.matches.branches.error) + ); }, showTagsSection() { - return Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error); + return ( + this.enabledRefTypes.includes(REF_TYPE_TAGS) && + Boolean(this.matches.tags.totalCount > 0 || this.matches.tags.error) + ); }, showCommitsSection() { - return Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error); + return ( + this.enabledRefTypes.includes(REF_TYPE_COMMITS) && + Boolean(this.matches.commits.totalCount > 0 || this.matches.commits.error) + ); }, showNoResults() { return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection; }, + showSectionHeaders() { + return this.enabledRefTypes.length > 1; + }, + toggleButtonClass() { + return { + 'gl-inset-border-1-red-500!': !this.state, + 'gl-font-monospace': Boolean(this.selectedRef), + }; + }, + footerSlotProps() { + return { + isLoading: this.isLoading, + matches: this.matches, + query: this.lastQuery, + }; + }, + buttonText() { + return this.selectedRef || this.i18n.noRefSelected; + }, }, watch: { // Keep the Vuex store synchronized if the parent @@ -86,6 +137,14 @@ export default { }, }, }, + beforeCreate() { + // Setting the store here instead of using + // the built in `store` component option because + // we need each new `RefSelector` instance to + // create a new Vuex store instance. + // See https://github.com/vuejs/vuex/issues/414#issue-184491718. + this.$store = createStore(); + }, created() { // This method is defined here instead of in `methods` // because we need to access the .cancel() method @@ -93,20 +152,29 @@ export default { // made inaccessible by Vue. More info: // https://stackoverflow.com/a/52988020/1063392 this.debouncedSearch = debounce(function search() { - this.search(this.query); + this.search(); }, SEARCH_DEBOUNCE_MS); this.setProjectId(this.projectId); - this.search(this.query); + + this.$watch( + 'enabledRefTypes', + () => { + this.setEnabledRefTypes(this.enabledRefTypes); + this.search(); + }, + { immediate: true }, + ); }, methods: { - ...mapActions(['setProjectId', 'setSelectedRef', 'search']), + ...mapActions(['setEnabledRefTypes', 'setProjectId', 'setSelectedRef']), + ...mapActions({ storeSearch: 'search' }), focusSearchBox() { this.$refs.searchBox.$el.querySelector('input').focus(); }, onSearchBoxEnter() { this.debouncedSearch.cancel(); - this.search(this.query); + this.search(); }, onSearchBoxInput() { this.debouncedSearch(); @@ -115,97 +183,96 @@ export default { this.setSelectedRef(ref); this.$emit('input', this.selectedRef); }, + search() { + this.storeSearch(this.query); + }, }, }; </script> <template> - <gl-dropdown v-bind="$attrs" class="ref-selector" @shown="focusSearchBox"> - <template slot="button-content"> - <span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-400" data-testid="button-content"> - <span v-if="selectedRef" class="gl-font-monospace">{{ selectedRef }}</span> - <span v-else>{{ i18n.noRefSelected }}</span> - </span> - <gl-icon name="chevron-down" /> - </template> - - <div class="gl-display-flex gl-flex-direction-column ref-selector-dropdown-content"> - <gl-dropdown-section-header> - <span class="gl-text-center gl-display-block">{{ i18n.dropdownHeader }}</span> - </gl-dropdown-section-header> - - <gl-dropdown-divider /> - + <gl-dropdown + :header-text="i18n.dropdownHeader" + :toggle-class="toggleButtonClass" + :text="buttonText" + class="ref-selector" + v-bind="$attrs" + v-on="$listeners" + @shown="focusSearchBox" + > + <template #header> <gl-search-box-by-type ref="searchBox" v-model.trim="query" :placeholder="i18n.searchPlaceholder" + autocomplete="off" @input="onSearchBoxInput" @keydown.enter.prevent="onSearchBoxEnter" /> + </template> - <div class="gl-flex-grow-1 gl-overflow-y-auto"> - <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-my-3" /> - <div - v-else-if="showNoResults" - class="gl-text-center gl-mx-3 gl-py-3" - data-testid="no-results" - > - <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> - <template #query> - <b class="gl-word-break-all">{{ lastQuery }}</b> - </template> - </gl-sprintf> + <div v-else-if="showNoResults" class="gl-text-center gl-mx-3 gl-py-3" data-testid="no-results"> + <gl-sprintf v-if="lastQuery" :message="i18n.noResultsWithQuery"> + <template #query> + <b class="gl-word-break-all">{{ lastQuery }}</b> + </template> + </gl-sprintf> - <span v-else>{{ i18n.noResults }}</span> - </div> + <span v-else>{{ i18n.noResults }}</span> + </div> - <template v-else> - <template v-if="showBranchesSection"> - <ref-results-section - :section-title="i18n.branches" - :total-count="matches.branches.totalCount" - :items="matches.branches.list" - :selected-ref="selectedRef" - :error="matches.branches.error" - :error-message="i18n.branchesErrorMessage" - data-testid="branches-section" - @selected="selectRef($event)" - /> + <template v-else> + <template v-if="showBranchesSection"> + <ref-results-section + :section-title="i18n.branches" + :total-count="matches.branches.totalCount" + :items="matches.branches.list" + :selected-ref="selectedRef" + :error="matches.branches.error" + :error-message="i18n.branchesErrorMessage" + :show-header="showSectionHeaders" + data-testid="branches-section" + @selected="selectRef($event)" + /> - <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" /> - </template> + <gl-dropdown-divider v-if="showTagsSection || showCommitsSection" /> + </template> - <template v-if="showTagsSection"> - <ref-results-section - :section-title="i18n.tags" - :total-count="matches.tags.totalCount" - :items="matches.tags.list" - :selected-ref="selectedRef" - :error="matches.tags.error" - :error-message="i18n.tagsErrorMessage" - data-testid="tags-section" - @selected="selectRef($event)" - /> + <template v-if="showTagsSection"> + <ref-results-section + :section-title="i18n.tags" + :total-count="matches.tags.totalCount" + :items="matches.tags.list" + :selected-ref="selectedRef" + :error="matches.tags.error" + :error-message="i18n.tagsErrorMessage" + :show-header="showSectionHeaders" + data-testid="tags-section" + @selected="selectRef($event)" + /> - <gl-dropdown-divider v-if="showCommitsSection" /> - </template> + <gl-dropdown-divider v-if="showCommitsSection" /> + </template> - <template v-if="showCommitsSection"> - <ref-results-section - :section-title="i18n.commits" - :total-count="matches.commits.totalCount" - :items="matches.commits.list" - :selected-ref="selectedRef" - :error="matches.commits.error" - :error-message="i18n.commitsErrorMessage" - data-testid="commits-section" - @selected="selectRef($event)" - /> - </template> - </template> - </div> - </div> + <template v-if="showCommitsSection"> + <ref-results-section + :section-title="i18n.commits" + :total-count="matches.commits.totalCount" + :items="matches.commits.list" + :selected-ref="selectedRef" + :error="matches.commits.error" + :error-message="i18n.commitsErrorMessage" + :show-header="showSectionHeaders" + data-testid="commits-section" + @selected="selectRef($event)" + /> + </template> + </template> + + <template #footer> + <slot name="footer" v-bind="footerSlotProps"></slot> + </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/ref/constants.js b/app/assets/javascripts/ref/constants.js index ca82b951377..44d0f50b832 100644 --- a/app/assets/javascripts/ref/constants.js +++ b/app/assets/javascripts/ref/constants.js @@ -1,5 +1,10 @@ import { __ } from '~/locale'; +export const REF_TYPE_BRANCHES = 'REF_TYPE_BRANCHES'; +export const REF_TYPE_TAGS = 'REF_TYPE_TAGS'; +export const REF_TYPE_COMMITS = 'REF_TYPE_COMMITS'; +export const ALL_REF_TYPES = Object.freeze([REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS]); + export const X_TOTAL_HEADER = 'x-total'; export const SEARCH_DEBOUNCE_MS = 250; diff --git a/app/assets/javascripts/ref/stores/actions.js b/app/assets/javascripts/ref/stores/actions.js index d9bdd64ace5..3832cc0c21d 100644 --- a/app/assets/javascripts/ref/stores/actions.js +++ b/app/assets/javascripts/ref/stores/actions.js @@ -1,17 +1,26 @@ import Api from '~/api'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS, REF_TYPE_COMMITS } from '../constants'; import * as types from './mutation_types'; +export const setEnabledRefTypes = ({ commit }, refTypes) => + commit(types.SET_ENABLED_REF_TYPES, refTypes); + export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId); export const setSelectedRef = ({ commit }, selectedRef) => commit(types.SET_SELECTED_REF, selectedRef); -export const search = ({ dispatch, commit }, query) => { +export const search = ({ state, dispatch, commit }, query) => { commit(types.SET_QUERY, query); - dispatch('searchBranches'); - dispatch('searchTags'); - dispatch('searchCommits'); + const dispatchIfRefTypeEnabled = (refType, action) => { + if (state.enabledRefTypes.includes(refType)) { + dispatch(action); + } + }; + dispatchIfRefTypeEnabled(REF_TYPE_BRANCHES, 'searchBranches'); + dispatchIfRefTypeEnabled(REF_TYPE_TAGS, 'searchTags'); + dispatchIfRefTypeEnabled(REF_TYPE_COMMITS, 'searchCommits'); }; export const searchBranches = ({ commit, state }) => { diff --git a/app/assets/javascripts/ref/stores/mutation_types.js b/app/assets/javascripts/ref/stores/mutation_types.js index 9f6195f5f3f..c26f4fa00c7 100644 --- a/app/assets/javascripts/ref/stores/mutation_types.js +++ b/app/assets/javascripts/ref/stores/mutation_types.js @@ -1,3 +1,5 @@ +export const SET_ENABLED_REF_TYPES = 'SET_ENABLED_REF_TYPES'; + export const SET_PROJECT_ID = 'SET_PROJECT_ID'; export const SET_SELECTED_REF = 'SET_SELECTED_REF'; export const SET_QUERY = 'SET_QUERY'; diff --git a/app/assets/javascripts/ref/stores/mutations.js b/app/assets/javascripts/ref/stores/mutations.js index 4dc73dabfe2..f91cbae8462 100644 --- a/app/assets/javascripts/ref/stores/mutations.js +++ b/app/assets/javascripts/ref/stores/mutations.js @@ -4,6 +4,9 @@ import { X_TOTAL_HEADER } from '../constants'; import * as types from './mutation_types'; export default { + [types.SET_ENABLED_REF_TYPES](state, refTypes) { + state.enabledRefTypes = refTypes; + }, [types.SET_PROJECT_ID](state, projectId) { state.projectId = projectId; }, diff --git a/app/assets/javascripts/ref/stores/state.js b/app/assets/javascripts/ref/stores/state.js index 65b9d6449d7..3affa8f8d03 100644 --- a/app/assets/javascripts/ref/stores/state.js +++ b/app/assets/javascripts/ref/stores/state.js @@ -1,23 +1,18 @@ +const createRefTypeState = () => ({ + list: [], + totalCount: 0, + error: null, +}); + export default () => ({ + enabledRefTypes: [], projectId: null, query: '', matches: { - branches: { - list: [], - totalCount: 0, - error: null, - }, - tags: { - list: [], - totalCount: 0, - error: null, - }, - commits: { - list: [], - totalCount: 0, - error: null, - }, + branches: createRefTypeState(), + tags: createRefTypeState(), + commits: createRefTypeState(), }, selectedRef: null, requestCount: 0, diff --git a/app/assets/javascripts/registry/explorer/components/delete_button.vue b/app/assets/javascripts/registry/explorer/components/delete_button.vue index ee856a3e546..e4a1a1a8266 100644 --- a/app/assets/javascripts/registry/explorer/components/delete_button.vue +++ b/app/assets/javascripts/registry/explorer/components/delete_button.vue @@ -48,6 +48,7 @@ export default { :title="title" :aria-label="title" variant="danger" + category="secondary" icon="remove" @click="$emit('delete')" /> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue index a4b4c08bc34..f46068acd68 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -1,11 +1,10 @@ <script> -import { GlSprintf, GlButton } from '@gitlab/ui'; +import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { sprintf, n__ } from '~/locale'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { - DETAILS_PAGE_TITLE, UPDATED_AT, CLEANUP_UNSCHEDULED_TEXT, CLEANUP_SCHEDULED_TEXT, @@ -20,11 +19,16 @@ import { UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS, + ROOT_IMAGE_TEXT, + ROOT_IMAGE_TOOLTIP, } from '../../constants/index'; export default { name: 'DetailsHeader', - components: { GlSprintf, GlButton, TitleArea, MetadataItem }, + components: { GlButton, GlIcon, TitleArea, MetadataItem }, + directives: { + GlTooltip: GlTooltipDirective, + }, mixins: [timeagoMixin], props: { image: { @@ -73,9 +77,12 @@ export default { deleteButtonDisabled() { return this.disabled || !this.image.canDelete; }, - }, - i18n: { - DETAILS_PAGE_TITLE, + rootImageTooltip() { + return !this.image.name ? ROOT_IMAGE_TOOLTIP : ''; + }, + imageName() { + return this.image.name || ROOT_IMAGE_TEXT; + }, }, }; </script> @@ -84,12 +91,15 @@ export default { <title-area :metadata-loading="metadataLoading"> <template #title> <span data-testid="title"> - <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> - <template #imageName> - {{ image.name }} - </template> - </gl-sprintf> + {{ imageName }} </span> + <gl-icon + v-if="rootImageTooltip" + v-gl-tooltip="rootImageTooltip" + class="gl-text-blue-600" + name="information-o" + :aria-label="rootImageTooltip" + /> </template> <template #metadata-tags-count> <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" /> 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 c66f92bdd67..74027a376a7 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 @@ -166,6 +166,7 @@ export default { :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE" :tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP" :tooltip-disabled="tag.canDelete" + data-qa-selector="tag_delete_button" data-testid="single-delete-button" @delete="$emit('delete')" /> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue index 10ad99d5956..5bd13322ebb 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue @@ -37,7 +37,6 @@ export default { v-for="(listItem, index) in images" :key="index" :item="listItem" - :first="index === 0" :metadata-loading="metadataLoading" @delete="$emit('delete', $event)" /> 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 9ae5b0f9eb1..0373a84b271 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 @@ -13,6 +13,7 @@ import { CLEANUP_TIMED_OUT_ERROR_MESSAGE, IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS, + ROOT_IMAGE_TEXT, } from '../../constants/index'; import DeleteButton from '../delete_button.vue'; @@ -74,6 +75,9 @@ export default { } return null; }, + imageName() { + return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`; + }, }, }; </script> @@ -92,9 +96,10 @@ export default { <router-link class="gl-text-body gl-font-weight-bold" data-testid="details-link" + data-qa-selector="registry_image_content" :to="{ name: 'details', params: { id } }" > - {{ item.path }} + {{ imageName }} </router-link> <clipboard-button v-if="item.location" diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue index 8d7505dfbae..6d2ff9ea7b6 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue @@ -9,7 +9,6 @@ import { LIST_INTRO_TEXT, EXPIRATION_POLICY_WILL_RUN_IN, EXPIRATION_POLICY_DISABLED_TEXT, - EXPIRATION_POLICY_DISABLED_MESSAGE, } from '../../constants/index'; export default { @@ -34,11 +33,6 @@ export default { default: '', required: false, }, - expirationPolicyHelpPagePath: { - type: String, - default: '', - required: false, - }, hideExpirationPolicyData: { type: Boolean, required: false, @@ -79,19 +73,8 @@ export default { ? sprintf(EXPIRATION_POLICY_WILL_RUN_IN, { time: this.timeTillRun }) : EXPIRATION_POLICY_DISABLED_TEXT; }, - showExpirationPolicyTip() { - return ( - !this.expirationPolicyEnabled && this.imagesCount > 0 && !this.hideExpirationPolicyData - ); - }, infoMessages() { - const base = [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; - return this.showExpirationPolicyTip - ? [ - ...base, - { text: EXPIRATION_POLICY_DISABLED_MESSAGE, link: this.expirationPolicyHelpPagePath }, - ] - : base; + return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }]; }, }, }; diff --git a/app/assets/javascripts/registry/explorer/constants/common.js b/app/assets/javascripts/registry/explorer/constants/common.js new file mode 100644 index 00000000000..dc71ef8450b --- /dev/null +++ b/app/assets/javascripts/registry/explorer/constants/common.js @@ -0,0 +1,3 @@ +import { s__ } from '~/locale'; + +export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image'); diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js index 3f04538a18b..7220f9646db 100644 --- a/app/assets/javascripts/registry/explorer/constants/details.js +++ b/app/assets/javascripts/registry/explorer/constants/details.js @@ -2,7 +2,6 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { s__, __ } from '~/locale'; // Translations strings -export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); export const DELETE_TAG_ERROR_MESSAGE = s__( 'ContainerRegistry|Something went wrong while marking the tag for deletion.', ); @@ -53,7 +52,8 @@ export const MISSING_OR_DELETED_IMAGE_TITLE = s__( export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__( 'ContainerRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.', ); -export const MISSING_OR_DELETE_IMAGE_BREADCRUMB = s__( + +export const MISSING_OR_DELETED_IMAGE_BREADCRUMB = s__( 'ContainerRegistry|Image repository not found', ); @@ -112,6 +112,10 @@ export const FAILED_DELETION_STATUS_MESSAGE = s__( 'ContainerRegistry|This image repository has failed to be deleted', ); +export const ROOT_IMAGE_TOOLTIP = s__( + 'ContainerRegistry|Image repository with no name located at the project URL.', +); + // Parameters export const DEFAULT_PAGE = 1; diff --git a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js index 48a6a015461..40f9b09a982 100644 --- a/app/assets/javascripts/registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/registry/explorer/constants/expiration_policies.js @@ -6,9 +6,6 @@ export const EXPIRATION_POLICY_WILL_RUN_IN = s__( export const EXPIRATION_POLICY_DISABLED_TEXT = s__( 'ContainerRegistry|Expiration policy is disabled', ); -export const EXPIRATION_POLICY_DISABLED_MESSAGE = s__( - 'ContainerRegistry|Expiration policies help manage the storage space used by the Container Registry, but the expiration policies for this registry are disabled. Contact your administrator to enable. %{docLinkStart}More information%{docLinkEnd}', -); export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted'); export const DELETE_ALERT_LINK_TEXT = s__( 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}', diff --git a/app/assets/javascripts/registry/explorer/constants/index.js b/app/assets/javascripts/registry/explorer/constants/index.js index 10816e12ead..6886356d8e2 100644 --- a/app/assets/javascripts/registry/explorer/constants/index.js +++ b/app/assets/javascripts/registry/explorer/constants/index.js @@ -1,3 +1,4 @@ +export * from './common'; export * from './expiration_policies'; export * from './quick_start'; export * from './list'; diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 0403467468a..2f515356fa7 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -24,7 +24,8 @@ import { GRAPHQL_PAGE_SIZE, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, - MISSING_OR_DELETE_IMAGE_BREADCRUMB, + MISSING_OR_DELETED_IMAGE_BREADCRUMB, + ROOT_IMAGE_TEXT, } from '../constants/index'; import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; @@ -116,7 +117,9 @@ export default { }, methods: { updateBreadcrumb() { - const name = this.image?.name || MISSING_OR_DELETE_IMAGE_BREADCRUMB; + const name = this.image?.id + ? this.image?.name || ROOT_IMAGE_TEXT + : MISSING_OR_DELETED_IMAGE_BREADCRUMB; this.breadCrumbState.updateName(name); }, deleteTags(toBeDeleted) { diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 8cad9b4ecfc..625d491db6a 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -288,7 +288,6 @@ export default { :images-count="containerRepositoriesCount" :expiration-policy="config.expirationPolicy" :help-page-path="config.helpPagePath" - :expiration-policy-help-page-path="config.expirationPolicyHelpPagePath" :hide-expiration-policy-data="config.isGroupPage" > <template #commands> diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue index 2dc56c3110b..46b97370d66 100644 --- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue +++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue @@ -223,6 +223,7 @@ export default { type="text" class="js-add-issuable-form-input add-issuable-form-input" data-qa-selector="add_issue_field" + autocomplete="off" @input="onInput" @focus="onFocus" @blur="onBlur" diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index b16bb76c305..a8c7b7c857a 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -86,12 +86,11 @@ export default { ]; }, }, - mounted() { - // eslint-disable-next-line promise/catch-or-return - this.initializeRelease().then(() => { - // Focus the first non-disabled input element - this.$el.querySelector('input:enabled').focus(); - }); + async mounted() { + await this.initializeRelease(); + + // Focus the first non-disabled input or button element + this.$el.querySelector('input:enabled, button:enabled').focus(); }, methods: { ...mapActions('detail', [ diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index 9e095c8a9c2..cfcb9f6978d 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -141,6 +141,7 @@ export default { :value="link.url" type="text" class="form-control" + name="asset-url" :state="isUrlValid(link)" @change="updateUrl(link, $event)" @keydown.ctrl.enter="updateUrl(link, $event.target.value)" @@ -180,6 +181,7 @@ export default { :value="link.name" type="text" class="form-control" + name="asset-link-name" :state="isNameValid(link)" @change="updateName(link, $event)" @keydown.ctrl.enter="updateName(link, $event.target.value)" @@ -202,6 +204,7 @@ export default { ref="typeSelect" :value="link.linkType || $options.defaultTypeOptionValue" class="form-control pr-4" + name="asset-type" :options="$options.typeOptions" @change="updateAssetLinkType({ linkIdToUpdate: link.id, newType: $event })" /> diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue index 660fd7ac950..21360a5c6cb 100644 --- a/app/assets/javascripts/releases/components/tag_field_new.vue +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -1,20 +1,29 @@ <script> -import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { GlFormGroup, GlDropdownItem, GlSprintf } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { mapState, mapActions, mapGetters } from 'vuex'; import { __ } from '~/locale'; import RefSelector from '~/ref/components/ref_selector.vue'; +import { REF_TYPE_TAGS } from '~/ref/constants'; import FormFieldContainer from './form_field_container.vue'; export default { name: 'TagFieldNew', - components: { GlFormGroup, GlFormInput, RefSelector, FormFieldContainer }, + components: { + GlFormGroup, + RefSelector, + FormFieldContainer, + GlDropdownItem, + GlSprintf, + }, data() { return { // Keeps track of whether or not the user has interacted with // the input field. This is used to avoid showing validation // errors immediately when the page loads. isInputDirty: false, + + showCreateFrom: true, }; }, computed: { @@ -26,6 +35,12 @@ export default { }, set(tagName) { this.updateReleaseTagName(tagName); + + // This setter is used by the `v-model` on the `RefSelector`. + // When this is called, the selection originated from the + // dropdown list of existing tag names, so we know the tag + // already exists and don't need to show the "create from" input + this.showCreateFrom = false; }, }, createFromModel: { @@ -51,12 +66,28 @@ export default { markInputAsDirty() { this.isInputDirty = true; }, + createTagClicked(newTagName) { + this.updateReleaseTagName(newTagName); + + // This method is called when the user selects the "create tag" + // option, so the tag does not already exist. Because of this, + // we need to show the "create from" input. + this.showCreateFrom = true; + }, }, translations: { - noRefSelected: __('No source selected'), - searchPlaceholder: __('Search branches, tags, and commits'), - dropdownHeader: __('Select source'), + tagName: { + noRefSelected: __('No tag selected'), + dropdownHeader: __('Tag name'), + searchPlaceholder: __('Search or create tag'), + }, + createFrom: { + noRefSelected: __('No source selected'), + searchPlaceholder: __('Search branches, tags, and commits'), + dropdownHeader: __('Select source'), + }, }, + tagNameEnabledRefTypes: [REF_TYPE_TAGS], }; </script> <template> @@ -69,17 +100,34 @@ export default { :invalid-feedback="__('Tag name is required')" > <form-field-container> - <gl-form-input + <ref-selector :id="tagNameInputId" v-model="tagName" + :project-id="projectId" + :translations="$options.translations.tagName" + :enabled-ref-types="$options.tagNameEnabledRefTypes" :state="!showTagNameValidationError" - type="text" - class="form-control" - @blur.once="markInputAsDirty" - /> + @hide.once="markInputAsDirty" + > + <template #footer="{ isLoading, matches, query }"> + <gl-dropdown-item + v-if="!isLoading && matches && matches.tags.totalCount === 0" + is-check-item + :is-checked="tagName === query" + @click="createTagClicked(query)" + > + <gl-sprintf :message="__('Create tag %{tagName}')"> + <template #tagName> + <b>{{ query }}</b> + </template> + </gl-sprintf> + </gl-dropdown-item> + </template> + </ref-selector> </form-field-container> </gl-form-group> <gl-form-group + v-if="showCreateFrom" :label="__('Create from')" :label-for="createFromSelectorId" data-testid="create-from-field" @@ -89,7 +137,7 @@ export default { :id="createFromSelectorId" v-model="createFromModel" :project-id="projectId" - :translations="$options.translations" + :translations="$options.translations.createFrom" /> </form-field-container> <template #description> diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/reports/components/grouped_issues_list.vue index 4d3c5f48e94..585127f901e 100644 --- a/app/assets/javascripts/reports/components/grouped_issues_list.vue +++ b/app/assets/javascripts/reports/components/grouped_issues_list.vue @@ -14,6 +14,12 @@ export default { required: false, default: '', }, + nestedLevel: { + type: Number, + required: false, + default: 0, + validator: (value) => [0, 1, 2].includes(value), + }, resolvedIssues: { type: Array, required: false, @@ -58,6 +64,12 @@ export default { return groupsCount + issuesCount; }, + listClasses() { + return { + 'gl-pl-7': this.nestedLevel === 1, + 'gl-pl-9': this.nestedLevel === 2, + }; + }, }, }; </script> @@ -67,6 +79,7 @@ export default { :length="listLength" :remain="$options.maxShownReportItems" :size="$options.typicalReportItemHeight" + :class="listClasses" class="report-block-container" wtag="ul" wclass="report-block-list" 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 033b8798473..752071d02c6 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,5 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { once } from 'lodash'; import { mapActions, mapGetters, mapState } from 'vuex'; import { sprintf, s__ } from '~/locale'; @@ -11,8 +11,8 @@ import { statusIcon, recentFailuresTextBuilder, } from '../store/utils'; +import GroupedIssuesList from './grouped_issues_list.vue'; import { componentNames } from './issue_body'; -import IssuesList from './issues_list.vue'; import Modal from './modal.vue'; import ReportSection from './report_section.vue'; import SummaryRow from './summary_row.vue'; @@ -23,9 +23,10 @@ export default { components: { ReportSection, SummaryRow, - IssuesList, + GroupedIssuesList, Modal, GlButton, + GlIcon, }, mixins: [Tracking.mixin()], props: { @@ -86,7 +87,7 @@ export default { } if (!report.name) { - return s__('Reports|An error occured while loading report'); + return s__('Reports|An error occurred while loading report'); } return reportTextBuilder(name, summary); @@ -111,10 +112,12 @@ export default { ); }, unresolvedIssues(report) { - return report.existing_failures.concat(report.existing_errors); - }, - newIssues(report) { - return report.new_failures.concat(report.new_errors); + return [ + ...report.new_failures, + ...report.new_errors, + ...report.existing_failures, + ...report.existing_errors, + ]; }, resolvedIssues(report) { return report.resolved_failures.concat(report.resolved_errors); @@ -151,24 +154,39 @@ export default { <template #body> <div class="mr-widget-grouped-section report-block"> <template v-for="(report, i) in reports"> - <summary-row :key="`summary-row-${i}`" :status-icon="getReportIcon(report)"> + <summary-row + :key="`summary-row-${i}`" + :status-icon="getReportIcon(report)" + nested-summary + > <template #summary> <div class="gl-display-inline-flex gl-flex-direction-column"> <div>{{ reportText(report) }}</div> + <div v-if="report.suite_errors"> + <div v-if="report.suite_errors.head"> + <gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" /> + {{ s__('Reports|Head report parsing error:') }} + {{ report.suite_errors.head }} + </div> + <div v-if="report.suite_errors.base"> + <gl-icon name="warning" class="gl-mx-2 gl-text-orange-500" /> + {{ s__('Reports|Base report parsing error:') }} + {{ report.suite_errors.base }} + </div> + </div> <div v-if="hasRecentFailures(report.summary)"> {{ recentFailuresText(report.summary) }} </div> </div> </template> </summary-row> - <issues-list + <grouped-issues-list v-if="shouldRenderIssuesList(report)" :key="`issues-list-${i}`" :unresolved-issues="unresolvedIssues(report)" - :new-issues="newIssues(report)" :resolved-issues="resolvedIssues(report)" :component="$options.componentNames.TestIssueBody" - class="report-block-group-list" + :nested-level="2" /> </template> <modal diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue index 16d5b14d3e9..ea3f0d78d8c 100644 --- a/app/assets/javascripts/reports/components/issues_list.vue +++ b/app/assets/javascripts/reports/components/issues_list.vue @@ -67,6 +67,12 @@ export default { required: false, default: null, }, + nestedLevel: { + type: Number, + required: false, + default: 0, + validator: (value) => [0, 1, 2].includes(value), + }, }, computed: { issuesWithState() { @@ -80,6 +86,12 @@ export default { wclass() { return `report-block-list ${this.issuesUlElementClass}`; }, + listClasses() { + return { + 'gl-pl-7': this.nestedLevel === 1, + 'gl-pl-8': this.nestedLevel === 2, + }; + }, }, }; </script> @@ -89,6 +101,7 @@ export default { :remain="$options.maxShownReportItems" :size="$options.typicalReportItemHeight" class="report-block-container" + :class="listClasses" wtag="ul" :wclass="wclass" > diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue index 9d0631fbc01..4e59d0d81c1 100644 --- a/app/assets/javascripts/reports/components/report_section.vue +++ b/app/assets/javascripts/reports/components/report_section.vue @@ -184,7 +184,7 @@ export default { <slot name="sub-heading"></slot> </div> - <slot name="action-buttons"></slot> + <slot name="action-buttons" :is-collapsible="isCollapsible"></slot> <button v-if="isCollapsible" diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue index 3232c0edf96..8eb43bcf1ba 100644 --- a/app/assets/javascripts/reports/components/summary_row.vue +++ b/app/assets/javascripts/reports/components/summary_row.vue @@ -2,6 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Popover from '~/vue_shared/components/help_popover.vue'; +import { ICON_WARNING } from '../constants'; /** * Renders the summary row for each report @@ -19,6 +20,11 @@ export default { GlLoadingIcon, }, props: { + nestedSummary: { + type: Boolean, + required: false, + default: false, + }, summary: { type: String, required: false, @@ -41,24 +47,36 @@ export default { icon: `status_${this.statusIcon}`, }; }, + rowClasses() { + if (!this.nestedSummary) { + return ['gl-px-5']; + } + return ['gl-pl-7', 'gl-pr-5', { 'gl-bg-gray-10': this.statusIcon === ICON_WARNING }]; + }, + statusIconSize() { + if (!this.nestedSummary) { + return 24; + } + return 16; + }, }, }; </script> <template> - <div class="report-block-list-issue report-block-list-issue-parent align-items-center"> - <div class="report-block-list-icon gl-mr-3"> + <div + class="gl-border-t-solid gl-border-t-gray-100 gl-border-t-1 gl-py-3 gl-display-flex gl-align-items-center" + :class="rowClasses" + > + <div class="gl-mr-3"> <gl-loading-icon v-if="statusIcon === 'loading'" css-class="report-block-list-loading-icon" size="md" /> - <ci-icon v-else :status="iconStatus" :size="24" /> + <ci-icon v-else :status="iconStatus" :size="statusIconSize" data-testid="summary-row-icon" /> </div> <div class="report-block-list-issue-description"> - <div - class="report-block-list-issue-description-text" - data-testid="test-summary-row-description" - > + <div class="report-block-list-issue-description-text" data-testid="summary-row-description"> <slot name="summary">{{ summary }}</slot ><span v-if="popoverOptions" class="text-nowrap" > <popover v-if="popoverOptions" :options="popoverOptions" class="align-top" /> diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue index 7508e1d1f0d..0ab9c10ac0d 100644 --- a/app/assets/javascripts/reports/components/test_issue_body.vue +++ b/app/assets/javascripts/reports/components/test_issue_body.vue @@ -1,33 +1,40 @@ <script> -import { GlBadge, GlSprintf } from '@gitlab/ui'; +import { GlBadge, GlButton } from '@gitlab/ui'; import { mapActions } from 'vuex'; +import { sprintf, n__ } from '~/locale'; +import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; +import { STATUS_NEUTRAL } from '../constants'; export default { name: 'TestIssueBody', components: { GlBadge, - GlSprintf, + GlButton, + IssueStatusIcon, }, props: { issue: { type: Object, required: true, }, - // failed || success - status: { - type: String, - required: true, - }, - isNew: { - type: Boolean, - required: false, - default: false, - }, }, computed: { + recentFailureMessage() { + return sprintf( + n__( + 'Reports|Failed %{count} time in %{base_branch} in the last 14 days', + 'Reports|Failed %{count} times in %{base_branch} in the last 14 days', + this.issue.recent_failures.count, + ), + this.issue.recent_failures, + ); + }, showRecentFailures() { return this.issue.recent_failures?.count && this.issue.recent_failures?.base_branch; }, + status() { + return this.issue.status || STATUS_NEUTRAL; + }, }, methods: { ...mapActions(['openModal']), @@ -35,30 +42,23 @@ export default { }; </script> <template> - <div class="report-block-list-issue-description gl-mt-2 gl-mb-2"> - <div class="report-block-list-issue-description-text" data-testid="test-issue-body-description"> - <button - type="button" - class="btn-link btn-blank text-left break-link vulnerability-name-button" - @click="openModal({ issue })" - > - <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"> - <gl-sprintf - :message=" - n__( - 'Reports|Failed %{count} time in %{base_branch} in the last 14 days', - 'Reports|Failed %{count} times in %{base_branch} in the last 14 days', - issue.recent_failures.count, - ) - " - > - <template #count>{{ issue.recent_failures.count }}</template> - <template #base_branch>{{ issue.recent_failures.base_branch }}</template> - </gl-sprintf> - </gl-badge> - {{ issue.name }} - </button> - </div> + <div class="gl-display-flex gl-mt-2 gl-mb-2"> + <issue-status-icon :status="status" :status-icon-size="24" class="gl-mr-3" /> + <gl-badge + v-if="showRecentFailures" + variant="warning" + class="gl-mr-2" + data-testid="test-issue-body-recent-failures" + > + {{ recentFailureMessage }} + </gl-badge> + <gl-button + button-text-classes="gl-white-space-normal! gl-word-break-all gl-text-left" + variant="link" + data-testid="test-issue-body-description" + @click="openModal({ issue })" + > + {{ issue.name }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/repository/components/upload_blob_modal.vue b/app/assets/javascripts/repository/components/upload_blob_modal.vue new file mode 100644 index 00000000000..4cdfc5e947a --- /dev/null +++ b/app/assets/javascripts/repository/components/upload_blob_modal.vue @@ -0,0 +1,218 @@ +<script> +import { + GlModal, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlToggle, + GlButton, + GlAlert, +} from '@gitlab/ui'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { ContentTypeMultipartFormData } from '~/lib/utils/headers'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; + +const PRIMARY_OPTIONS_TEXT = __('Upload file'); +const SECONDARY_OPTIONS_TEXT = __('Cancel'); +const MODAL_TITLE = __('Upload New File'); +const COMMIT_LABEL = __('Commit message'); +const TARGET_BRANCH_LABEL = __('Target branch'); +const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); +const REMOVE_FILE_TEXT = __('Remove file'); +const NEW_BRANCH_IN_FORK = __( + 'A new branch will be created in your fork and a new merge request will be started.', +); +const ERROR_MESSAGE = __('Error uploading file. Please try again.'); + +export default { + components: { + GlModal, + GlForm, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlToggle, + GlButton, + UploadDropzone, + GlAlert, + }, + i18n: { + MODAL_TITLE, + COMMIT_LABEL, + TARGET_BRANCH_LABEL, + TOGGLE_CREATE_MR_LABEL, + REMOVE_FILE_TEXT, + NEW_BRANCH_IN_FORK, + }, + props: { + modalId: { + type: String, + required: true, + }, + commitMessage: { + type: String, + required: true, + }, + targetBranch: { + type: String, + required: true, + }, + origionalBranch: { + type: String, + required: true, + }, + canPushCode: { + type: Boolean, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + data() { + return { + commit: this.commitMessage, + target: this.targetBranch, + createNewMr: true, + file: null, + filePreviewURL: null, + fileBinary: null, + loading: false, + }; + }, + computed: { + primaryOptions() { + return { + text: PRIMARY_OPTIONS_TEXT, + attributes: [ + { + variant: 'success', + loading: this.loading, + disabled: !this.formCompleted || this.loading, + }, + ], + }; + }, + cancelOptions() { + return { + text: SECONDARY_OPTIONS_TEXT, + attributes: [ + { + disabled: this.loading, + }, + ], + }; + }, + formattedFileSize() { + return numberToHumanSize(this.file.size); + }, + showCreateNewMrToggle() { + return this.canPushCode && this.target !== this.origionalBranch; + }, + formCompleted() { + return this.file && this.commit && this.target; + }, + }, + methods: { + setFile(file) { + this.file = file; + + const fileUurlReader = new FileReader(); + + fileUurlReader.readAsDataURL(this.file); + + fileUurlReader.onload = (e) => { + this.filePreviewURL = e.target?.result; + }; + }, + removeFile() { + this.file = null; + this.filePreviewURL = null; + }, + uploadFile() { + this.loading = true; + + const { + $route: { + params: { path }, + }, + } = this; + const uploadPath = joinPaths(this.path, path); + + const formData = new FormData(); + formData.append('branch_name', this.target); + formData.append('create_merge_request', this.createNewMr); + formData.append('commit_message', this.commit); + formData.append('file', this.file); + + return axios + .post(uploadPath, formData, { + headers: { + ...ContentTypeMultipartFormData, + }, + }) + .then((response) => { + visitUrl(response.data.filePath); + }) + .catch(() => { + this.loading = false; + createFlash(ERROR_MESSAGE); + }); + }, + }, +}; +</script> +<template> + <gl-form> + <gl-modal + :modal-id="modalId" + :title="$options.i18n.MODAL_TITLE" + :action-primary="primaryOptions" + :action-cancel="cancelOptions" + @primary.prevent="uploadFile" + > + <upload-dropzone class="gl-h-200! gl-mb-4" single-file-selection @change="setFile"> + <div + v-if="file" + class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3" + > + <img v-if="filePreviewURL" :src="filePreviewURL" class="gl-h-11" /> + <div>{{ formattedFileSize }}</div> + <div>{{ file.name }}</div> + <gl-button + category="tertiary" + variant="confirm" + :disabled="loading" + @click="removeFile" + >{{ $options.i18n.REMOVE_FILE_TEXT }}</gl-button + > + </div> + </upload-dropzone> + <gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message"> + <gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" /> + </gl-form-group> + <gl-form-group + v-if="canPushCode" + :label="$options.i18n.TARGET_BRANCH_LABEL" + label-for="branch_name" + > + <gl-form-input v-model="target" :disabled="loading" name="branch_name" /> + </gl-form-group> + <gl-toggle + v-if="showCreateNewMrToggle" + v-model="createNewMr" + :disabled="loading" + :label="$options.i18n.TOGGLE_CREATE_MR_LABEL" + /> + <gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.NEW_BRANCH_IN_FORK }} + </gl-alert> + </gl-modal> + </gl-form> +</template> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 747b85f5c1c..e6969b7c8b2 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,3 +1,4 @@ +import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import { parseBoolean } from '../lib/utils/common_utils'; @@ -7,7 +8,6 @@ import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue'; import LastCommit from './components/last_commit.vue'; -import TreeActionLink from './components/tree_action_link.vue'; import apolloProvider from './graphql'; import createRouter from './router'; import { updateFormAction } from './utils/dom'; @@ -101,14 +101,17 @@ export default function setupVueRepositoryList() { el: treeHistoryLinkEl, router, render(h) { - return h(TreeActionLink, { - props: { - path: `${historyLink}/${ - this.$route.params.path ? escapeFileUrl(this.$route.params.path) : '' - }`, - text: __('History'), + return h( + GlButton, + { + attrs: { + href: `${historyLink}/${ + this.$route.params.path ? escapeFileUrl(this.$route.params.path) : '' + }`, + }, }, - }); + [__('History')], + ); }, }); diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 52ff4e7b100..6cdd89ad431 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -26,6 +26,8 @@ Sidebar.prototype.removeListeners = function () { // eslint-disable-next-line @gitlab/no-global-event-off this.sidebar.off('hidden.gl.dropdown'); // eslint-disable-next-line @gitlab/no-global-event-off + this.sidebar.off('hiddenGlDropdown'); + // eslint-disable-next-line @gitlab/no-global-event-off $('.dropdown').off('loading.gl.dropdown'); // eslint-disable-next-line @gitlab/no-global-event-off $('.dropdown').off('loaded.gl.dropdown'); @@ -37,6 +39,7 @@ Sidebar.prototype.addEventListeners = function () { this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked); this.sidebar.on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden); + this.sidebar.on('hiddenGlDropdown', this, this.onSidebarDropdownHidden); $document.on('click', '.js-sidebar-toggle', this.sidebarToggleClicked); return $(document) diff --git a/app/assets/javascripts/security_configuration/components/configuration_table.vue b/app/assets/javascripts/security_configuration/components/configuration_table.vue index 9475cc1781f..4a3f988296c 100644 --- a/app/assets/javascripts/security_configuration/components/configuration_table.vue +++ b/app/assets/javascripts/security_configuration/components/configuration_table.vue @@ -1,16 +1,18 @@ <script> -import { GlLink, GlSprintf, GlTable, GlAlert } from '@gitlab/ui'; +import { GlLink, GlTable, GlAlert } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import { REPORT_TYPE_SAST, REPORT_TYPE_DAST, + REPORT_TYPE_DAST_PROFILES, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, REPORT_TYPE_COVERAGE_FUZZING, + REPORT_TYPE_API_FUZZING, REPORT_TYPE_LICENSE_COMPLIANCE, } from '~/vue_shared/security_reports/constants'; -import { features } from './features_constants'; import ManageSast from './manage_sast.vue'; +import { scanners } from './scanners_constants'; import Upgrade from './upgrade.vue'; const borderClasses = 'gl-border-b-1! gl-border-b-solid! gl-border-gray-100!'; @@ -19,14 +21,14 @@ const thClass = `gl-text-gray-900 gl-bg-transparent! ${borderClasses}`; export default { components: { GlLink, - GlSprintf, GlTable, GlAlert, }, - data: () => ({ - features, - errorMessage: '', - }), + data() { + return { + errorMessage: '', + }; + }, methods: { getFeatureDocumentationLinkLabel(item) { return sprintf(s__('SecurityConfiguration|Feature documentation for %{featureName}'), { @@ -40,9 +42,11 @@ export default { const COMPONENTS = { [REPORT_TYPE_SAST]: ManageSast, [REPORT_TYPE_DAST]: Upgrade, + [REPORT_TYPE_DAST_PROFILES]: Upgrade, [REPORT_TYPE_DEPENDENCY_SCANNING]: Upgrade, [REPORT_TYPE_CONTAINER_SCANNING]: Upgrade, [REPORT_TYPE_COVERAGE_FUZZING]: Upgrade, + [REPORT_TYPE_API_FUZZING]: Upgrade, [REPORT_TYPE_LICENSE_COMPLIANCE]: Upgrade, }; @@ -62,7 +66,7 @@ export default { thClass, }, ], - items: features, + items: scanners, }, }; </script> @@ -81,7 +85,8 @@ export default { {{ item.description }} <gl-link target="_blank" - :href="item.link" + data-testid="help-link" + :href="item.helpPath" :aria-label="getFeatureDocumentationLinkLabel(item)" > {{ s__('SecurityConfiguration|More information') }} diff --git a/app/assets/javascripts/security_configuration/components/manage_sast.vue b/app/assets/javascripts/security_configuration/components/manage_sast.vue index 5169096d563..a2528edd914 100644 --- a/app/assets/javascripts/security_configuration/components/manage_sast.vue +++ b/app/assets/javascripts/security_configuration/components/manage_sast.vue @@ -14,9 +14,11 @@ export default { default: '', }, }, - data: () => ({ - isLoading: false, - }), + data() { + return { + isLoading: false, + }; + }, methods: { async mutate() { this.isLoading = true; diff --git a/app/assets/javascripts/security_configuration/components/features_constants.js b/app/assets/javascripts/security_configuration/components/scanners_constants.js index d846a2761d9..9846df0b4bf 100644 --- a/app/assets/javascripts/security_configuration/components/features_constants.js +++ b/app/assets/javascripts/security_configuration/components/scanners_constants.js @@ -1,61 +1,73 @@ import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; import { REPORT_TYPE_SAST, REPORT_TYPE_DAST, + REPORT_TYPE_DAST_PROFILES, REPORT_TYPE_SECRET_DETECTION, REPORT_TYPE_DEPENDENCY_SCANNING, REPORT_TYPE_CONTAINER_SCANNING, REPORT_TYPE_COVERAGE_FUZZING, + REPORT_TYPE_API_FUZZING, REPORT_TYPE_LICENSE_COMPLIANCE, } from '~/vue_shared/security_reports/constants'; /** * Translations & helpPagePaths for Static Security Configuration Page */ -export const SAST_NAME = s__('Static Application Security Testing (SAST)'); -export const SAST_DESCRIPTION = s__('Analyze your source code for known vulnerabilities.'); +export const SAST_NAME = __('Static Application Security Testing (SAST)'); +export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.'); export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index'); -export const DAST_NAME = s__('Dynamic Application Security Testing (DAST)'); -export const DAST_DESCRIPTION = s__('Analyze a review version of your web application.'); +export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); +export const DAST_DESCRIPTION = __('Analyze a review version of your web application.'); export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); -export const SECRET_DETECTION_NAME = s__('Secret Detection'); -export const SECRET_DETECTION_DESCRIPTION = s__( +export const DAST_PROFILES_NAME = __('DAST Scans'); +export const DAST_PROFILES_DESCRIPTION = __( + 'Saved scan settings and target site settings which are reusable.', +); +export const DAST_PROFILES_HELP_PATH = helpPagePath('user/application_security/dast/index'); + +export const SECRET_DETECTION_NAME = __('Secret Detection'); +export const SECRET_DETECTION_DESCRIPTION = __( 'Analyze your source code and git history for secrets.', ); export const SECRET_DETECTION_HELP_PATH = helpPagePath( 'user/application_security/secret_detection/index', ); -export const DEPENDENCY_SCANNING_NAME = s__('Dependency Scanning'); -export const DEPENDENCY_SCANNING_DESCRIPTION = s__( +export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning'); +export const DEPENDENCY_SCANNING_DESCRIPTION = __( 'Analyze your dependencies for known vulnerabilities.', ); export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath( 'user/application_security/dependency_scanning/index', ); -export const CONTAINER_SCANNING_NAME = s__('Container Scanning'); -export const CONTAINER_SCANNING_DESCRIPTION = s__( +export const CONTAINER_SCANNING_NAME = __('Container Scanning'); +export const CONTAINER_SCANNING_DESCRIPTION = __( 'Check your Docker images for known vulnerabilities.', ); export const CONTAINER_SCANNING_HELP_PATH = helpPagePath( 'user/application_security/container_scanning/index', ); -export const COVERAGE_FUZZING_NAME = s__('Coverage Fuzzing'); -export const COVERAGE_FUZZING_DESCRIPTION = s__( +export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing'); +export const COVERAGE_FUZZING_DESCRIPTION = __( 'Find bugs in your code with coverage-guided fuzzing.', ); export const COVERAGE_FUZZING_HELP_PATH = helpPagePath( 'user/application_security/coverage_fuzzing/index', ); -export const LICENSE_COMPLIANCE_NAME = s__('License Compliance'); -export const LICENSE_COMPLIANCE_DESCRIPTION = s__( +export const API_FUZZING_NAME = __('API Fuzzing'); +export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.'); +export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index'); + +export const LICENSE_COMPLIANCE_NAME = __('License Compliance'); +export const LICENSE_COMPLIANCE_DESCRIPTION = __( 'Search your project dependencies for their licenses and apply policies.', ); export const LICENSE_COMPLIANCE_HELP_PATH = helpPagePath( @@ -66,7 +78,7 @@ export const UPGRADE_CTA = s__( 'SecurityConfiguration|Available with %{linkStart}upgrade or free trial%{linkEnd}', ); -export const features = [ +export const scanners = [ { name: SAST_NAME, description: SAST_DESCRIPTION, @@ -80,10 +92,10 @@ export const features = [ type: REPORT_TYPE_DAST, }, { - name: SECRET_DETECTION_NAME, - description: SECRET_DETECTION_DESCRIPTION, - helpPath: SECRET_DETECTION_HELP_PATH, - type: REPORT_TYPE_SECRET_DETECTION, + name: DAST_PROFILES_NAME, + description: DAST_PROFILES_DESCRIPTION, + helpPath: DAST_PROFILES_HELP_PATH, + type: REPORT_TYPE_DAST_PROFILES, }, { name: DEPENDENCY_SCANNING_NAME, @@ -98,12 +110,24 @@ export const features = [ type: REPORT_TYPE_CONTAINER_SCANNING, }, { + name: SECRET_DETECTION_NAME, + description: SECRET_DETECTION_DESCRIPTION, + helpPath: SECRET_DETECTION_HELP_PATH, + type: REPORT_TYPE_SECRET_DETECTION, + }, + { name: COVERAGE_FUZZING_NAME, description: COVERAGE_FUZZING_DESCRIPTION, helpPath: COVERAGE_FUZZING_HELP_PATH, type: REPORT_TYPE_COVERAGE_FUZZING, }, { + name: API_FUZZING_NAME, + description: API_FUZZING_DESCRIPTION, + helpPath: API_FUZZING_HELP_PATH, + type: REPORT_TYPE_API_FUZZING, + }, + { name: LICENSE_COMPLIANCE_NAME, description: LICENSE_COMPLIANCE_DESCRIPTION, helpPath: LICENSE_COMPLIANCE_HELP_PATH, diff --git a/app/assets/javascripts/security_configuration/components/upgrade.vue b/app/assets/javascripts/security_configuration/components/upgrade.vue index 166ee4ff194..518eb57731d 100644 --- a/app/assets/javascripts/security_configuration/components/upgrade.vue +++ b/app/assets/javascripts/security_configuration/components/upgrade.vue @@ -1,12 +1,18 @@ <script> import { GlLink, GlSprintf } from '@gitlab/ui'; -import { UPGRADE_CTA } from './features_constants'; +import { UPGRADE_CTA } from './scanners_constants'; export default { components: { GlLink, GlSprintf, }, + inject: { + upgradePath: { + from: 'upgradePath', + default: '#', + }, + }, i18n: { UPGRADE_CTA, }, @@ -17,7 +23,7 @@ export default { <span> <gl-sprintf :message="$options.i18n.UPGRADE_CTA"> <template #link="{ content }"> - <gl-link target="_blank" href="https://about.gitlab.com/pricing/"> + <gl-link target="_blank" :href="upgradePath"> {{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index c98fa46b32b..1134a1ffb44 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -14,13 +14,14 @@ export const initStaticSecurityConfiguration = (el) => { defaultClient: createDefaultClient(), }); - const { projectPath } = el.dataset; + const { projectPath, upgradePath } = el.dataset; return new Vue({ el, apolloProvider, provide: { projectPath, + upgradePath, }, render(createElement) { return createElement(SecurityConfigurationApp); diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js index 4277ffec545..bc3b2f16a6a 100644 --- a/app/assets/javascripts/sentry/sentry_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -1,6 +1,6 @@ +import * as Sentry from '@sentry/browser'; import $ from 'jquery'; import { __ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; const IGNORE_ERRORS = [ // Random plugins/extensions diff --git a/app/assets/javascripts/sentry/wrapper.js b/app/assets/javascripts/sentry/wrapper.js deleted file mode 100644 index 24039e6141c..00000000000 --- a/app/assets/javascripts/sentry/wrapper.js +++ /dev/null @@ -1,26 +0,0 @@ -// Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179 -// export * from '@sentry/browser'; - -export function init(...args) { - return args; -} - -export function setUser(...args) { - return args; -} - -export function captureException(...args) { - return args; -} - -export function captureMessage(...args) { - return args; -} - -export function withScope(fn) { - fn({ - setTag(...args) { - return args; - }, - }); -} diff --git a/app/assets/javascripts/shared/popover.js b/app/assets/javascripts/shared/popover.js deleted file mode 100644 index 435ee8fb968..00000000000 --- a/app/assets/javascripts/shared/popover.js +++ /dev/null @@ -1,33 +0,0 @@ -import $ from 'jquery'; -import { debounce } from 'lodash'; - -export function togglePopover(show) { - const isAlreadyShown = this.hasClass('js-popover-show'); - if ((show && isAlreadyShown) || (!show && !isAlreadyShown)) { - return false; - } - this.popover(show ? 'show' : 'hide'); - this.toggleClass('disable-animation js-popover-show', show); - - return true; -} - -export function mouseleave() { - if (!$('.popover:hover').length > 0) { - const $popover = $(this); - togglePopover.call($popover, false); - } -} - -export function mouseenter() { - const $popover = $(this); - - const showedPopover = togglePopover.call($popover, true); - if (showedPopover) { - $('.popover').on('mouseleave', mouseleave.bind($popover)); - } -} - -export function debouncedMouseleave(debounceTimeout = 300) { - return debounce(mouseleave, debounceTimeout); -} diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index e2dc37a0ac2..b53b7039018 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -31,13 +31,18 @@ export default { </script> <template> - <div class="gl-display-flex gl-flex-direction-column"> - <div v-if="emptyUsers" data-testid="none"> + <div class="gl-display-flex gl-flex-direction-column issuable-assignees"> + <div + v-if="emptyUsers" + class="gl-display-flex gl-align-items-center gl-text-gray-500" + data-testid="none" + > <span> {{ __('None') }} -</span> <gl-button data-testid="assign-yourself" category="tertiary" variant="link" + class="gl-ml-2" @click="$emit('assign-self')" > <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 8f3f77cb5f0..cc2201ad359 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -15,13 +15,12 @@ import { IssuableType } from '~/issue_show/constants'; import { __, n__ } from '~/locale'; import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { assigneesQueries } from '~/sidebar/constants'; +import { assigneesQueries, ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants'; import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue'; export const assigneesWidget = Vue.observable({ updateAssignees: null, }); - export default { i18n: { unassigned: __('Unassigned'), @@ -88,10 +87,10 @@ export default { return this.queryVariables; }, update(data) { - return data.issuable || data.project?.issuable; + return data.workspace?.issuable; }, result({ data }) { - const issuable = data.issuable || data.project?.issuable; + const issuable = data.workspace?.issuable; if (issuable) { this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes)); } @@ -104,13 +103,24 @@ export default { query: searchUsers, variables() { return { + fullPath: this.fullPath, search: this.search, }; }, update(data) { - return data.users?.nodes || []; + const searchResults = data.workspace?.users?.nodes.map(({ user }) => user) || []; + const mergedSearchResults = this.participants.reduce((acc, current) => { + if ( + !acc.some((user) => current.username === user.username) && + (current.name.includes(this.search) || current.username.includes(this.search)) + ) { + acc.push(current); + } + return acc; + }, searchResults); + return mergedSearchResults; }, - debounce: 250, + debounce: ASSIGNEES_DEBOUNCE_DELAY, skip() { return this.isSearchEmpty; }, @@ -185,7 +195,7 @@ export default { return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser); }, noUsersFound() { - return !this.isSearchEmpty && this.unselectedFiltered.length === 0; + return !this.isSearchEmpty && this.searchUsers.length === 0; }, showCurrentUser() { return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching); @@ -218,7 +228,7 @@ export default { }, }) .then(({ data }) => { - this.$emit('assignees-updated', data); + this.$emit('assignees-updated', data.issuableSetAssignees.issuable.assignees.nodes); return data; }) .catch(() => { @@ -281,6 +291,9 @@ export default { collapseWidget() { this.$refs.toggle.collapse(); }, + showDivider(list) { + return list.length > 0 && this.isSearchEmpty; + }, }, }; </script> @@ -306,6 +319,7 @@ export default { <issuable-assignees :users="assignees" :issuable-type="issuableType" + class="gl-mt-2" @assign-self="assignSelf" /> </template> @@ -334,12 +348,14 @@ export default { data-testid="unassign" @click="selectAssignee()" > - <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'">{{ - $options.i18n.unassigned - }}</span></gl-dropdown-item + <span + :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" + class="gl-font-weight-bold" + >{{ $options.i18n.unassigned }}</span + ></gl-dropdown-item > - <gl-dropdown-divider data-testid="unassign-divider" /> </template> + <gl-dropdown-divider v-if="showDivider(selectedFiltered)" /> <gl-dropdown-item v-for="item in selectedFiltered" :key="item.id" @@ -358,10 +374,10 @@ export default { /> </gl-avatar-link> </gl-dropdown-item> - <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" /> <template v-if="showCurrentUser"> + <gl-dropdown-divider /> <gl-dropdown-item - data-testid="unselected-participant" + data-testid="current-user" @click.stop="selectAssignee(currentUser)" > <gl-avatar-link> @@ -370,12 +386,12 @@ export default { :label="currentUser.name" :sub-label="currentUser.username" :src="currentUser.avatarUrl" - class="gl-align-items-center" + class="gl-align-items-center gl-pl-6!" /> </gl-avatar-link> </gl-dropdown-item> - <gl-dropdown-divider /> </template> + <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> <gl-dropdown-item v-for="unselectedUser in unselectedFiltered" :key="unselectedUser.id" @@ -392,7 +408,7 @@ export default { /> </gl-avatar-link> </gl-dropdown-item> - <gl-dropdown-item v-if="noUsersFound && !isSearching"> + <gl-dropdown-item v-if="noUsersFound && !isSearching" data-testid="empty-results"> {{ __('No matching results') }} </gl-dropdown-item> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue index 36775648809..d0da4a9c75a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -83,7 +83,7 @@ export default { <assignee-avatar-link :user="user" :issuable-type="issuableType" /> </div> </div> - <div v-if="renderShowMoreSection" class="user-list-more"> + <div v-if="renderShowMoreSection" class="user-list-more gl-hover-text-blue-800"> <button type="button" class="btn-link" diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue deleted file mode 100644 index 57b3705e803..00000000000 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ /dev/null @@ -1,113 +0,0 @@ -<script> -import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { mapState } from 'vuex'; -import { __, sprintf } from '~/locale'; -import eventHub from '~/sidebar/event_hub'; -import EditForm from './edit_form.vue'; - -export default { - components: { - EditForm, - GlIcon, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - fullPath: { - required: true, - type: String, - }, - isEditable: { - required: true, - type: Boolean, - }, - issuableType: { - required: false, - type: String, - default: 'issue', - }, - }, - data() { - return { - edit: false, - }; - }, - computed: { - ...mapState({ - confidential: ({ noteableData, confidential }) => { - if (noteableData) { - return noteableData.confidential; - } - return Boolean(confidential); - }, - }), - confidentialityIcon() { - return this.confidential ? 'eye-slash' : 'eye'; - }, - tooltipLabel() { - return this.confidential ? __('Confidential') : __('Not confidential'); - }, - confidentialText() { - return sprintf(__('This %{issuableType} is confidential'), { - issuableType: this.issuableType, - }); - }, - }, - created() { - eventHub.$on('closeConfidentialityForm', this.toggleForm); - }, - beforeDestroy() { - eventHub.$off('closeConfidentialityForm', this.toggleForm); - }, - methods: { - toggleForm() { - this.edit = !this.edit; - }, - }, -}; -</script> - -<template> - <div class="block issuable-sidebar-item confidentiality"> - <div - ref="collapseIcon" - v-gl-tooltip.viewport.left - :title="tooltipLabel" - class="sidebar-collapsed-icon" - @click="toggleForm" - > - <gl-icon :name="confidentialityIcon" /> - </div> - <div class="title hide-collapsed"> - {{ __('Confidentiality') }} - <a - v-if="isEditable" - ref="editLink" - class="float-right confidential-edit" - href="#" - data-track-event="click_edit_button" - data-track-label="right_sidebar" - data-track-property="confidentiality" - @click.prevent="toggleForm" - >{{ __('Edit') }}</a - > - </div> - <div class="value sidebar-item-value hide-collapsed"> - <edit-form - v-if="edit" - :confidential="confidential" - :full-path="fullPath" - :issuable-type="issuableType" - /> - <div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential"> - <gl-icon :size="16" name="eye" class="sidebar-item-icon inline" /> - {{ __('Not confidential') }} - </div> - <div v-else class="value sidebar-item-value hide-collapsed"> - <gl-icon :size="16" name="eye-slash" class="sidebar-item-icon inline is-active" /> - {{ confidentialText }} - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue deleted file mode 100644 index 057224d5918..00000000000 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ /dev/null @@ -1,64 +0,0 @@ -<script> -import { GlSprintf } from '@gitlab/ui'; -import { __ } from '../../../locale'; -import editFormButtons from './edit_form_buttons.vue'; - -export default { - components: { - editFormButtons, - GlSprintf, - }, - props: { - confidential: { - required: true, - type: Boolean, - }, - fullPath: { - required: true, - type: String, - }, - issuableType: { - required: true, - type: String, - }, - }, - computed: { - confidentialityOnWarning() { - return __( - 'You are going to turn on the confidentiality. This means that only team members with %{strongStart}at least Reporter access%{strongEnd} are able to see and leave comments on the %{issuableType}.', - ); - }, - confidentialityOffWarning() { - return __( - 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.', - ); - }, - }, -}; -</script> - -<template> - <div class="dropdown show"> - <div class="dropdown-menu sidebar-item-warning-message"> - <div> - <p v-if="!confidential"> - <gl-sprintf :message="confidentialityOnWarning"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - <template #issuableType>{{ issuableType }}</template> - </gl-sprintf> - </p> - <p v-else> - <gl-sprintf :message="confidentialityOffWarning"> - <template #strong="{ content }"> - <strong>{{ content }}</strong> - </template> - <template #issuableType>{{ issuableType }}</template> - </gl-sprintf> - </p> - <edit-form-buttons :full-path="fullPath" :confidential="confidential" /> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue deleted file mode 100644 index 154a228c978..00000000000 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import $ from 'jquery'; -import { mapActions } from 'vuex'; -import { deprecatedCreateFlash as Flash } from '~/flash'; -import { __ } from '~/locale'; -import eventHub from '../../event_hub'; - -export default { - components: { - GlButton, - }, - props: { - fullPath: { - required: true, - type: String, - }, - confidential: { - required: true, - type: Boolean, - }, - }, - data() { - return { - isLoading: false, - }; - }, - computed: { - toggleButtonText() { - if (this.isLoading) { - return __('Applying'); - } - - return this.confidential ? __('Turn Off') : __('Turn On'); - }, - }, - methods: { - ...mapActions(['updateConfidentialityOnIssuable']), - closeForm() { - eventHub.$emit('closeConfidentialityForm'); - $(this.$el).trigger('hidden.gl.dropdown'); - }, - submitForm() { - this.isLoading = true; - const confidential = !this.confidential; - - this.updateConfidentialityOnIssuable({ confidential, fullPath: this.fullPath }) - .then(() => { - eventHub.$emit('updateIssuableConfidentiality', confidential); - }) - .catch((err) => { - Flash( - err || __('Something went wrong trying to change the confidentiality of this issue'), - ); - }) - .finally(() => { - this.closeForm(); - this.isLoading = false; - }); - }, - }, -}; -</script> - -<template> - <div class="sidebar-item-warning-message-actions"> - <gl-button class="gl-mr-3" @click="closeForm"> - {{ __('Cancel') }} - </gl-button> - <gl-button - category="secondary" - variant="warning" - :disabled="isLoading" - :loading="isLoading" - data-testid="confidential-toggle" - @click.prevent="submitForm" - > - {{ toggleButtonText }} - </gl-button> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue new file mode 100644 index 00000000000..37a44eb8f01 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_content.vue @@ -0,0 +1,64 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; + +export default { + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + confidential: { + type: Boolean, + required: true, + }, + issuableType: { + type: String, + required: true, + }, + }, + computed: { + confidentialText() { + return this.confidential + ? sprintf(__('This %{issuableType} is confidential'), { + issuableType: this.issuableType, + }) + : __('Not confidential'); + }, + confidentialIcon() { + return this.confidential ? 'eye-slash' : 'eye'; + }, + tooltipLabel() { + return this.confidential ? __('Confidential') : __('Not confidential'); + }, + }, +}; +</script> + +<template> + <div> + <div + v-gl-tooltip.viewport.left + :title="tooltipLabel" + class="sidebar-collapsed-icon" + data-testid="sidebar-collapsed-icon" + @click="$emit('expandSidebar')" + > + <gl-icon + :size="16" + :name="confidentialIcon" + class="sidebar-item-icon inline" + :class="{ 'is-active': confidential }" + /> + </div> + <gl-icon + :size="16" + :name="confidentialIcon" + class="sidebar-item-icon inline hide-collapsed" + :class="{ 'is-active': confidential }" + /> + <span class="hide-collapsed" data-testid="confidential-text">{{ confidentialText }}</span> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue new file mode 100644 index 00000000000..a21ac73f131 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_form.vue @@ -0,0 +1,136 @@ +<script> +import { GlSprintf, GlButton } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { IssuableType } from '~/issue_show/constants'; +import { __, sprintf } from '~/locale'; +import { confidentialityQueries } from '~/sidebar/constants'; + +export default { + i18n: { + confidentialityOnWarning: __( + 'You are going to turn on confidentiality. Only team members with %{strongStart}at least Reporter access%{strongEnd} will be able to see and leave comments on the %{issuableType}.', + ), + confidentialityOffWarning: __( + 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.', + ), + }, + components: { + GlSprintf, + GlButton, + }, + inject: ['fullPath', 'iid'], + props: { + confidential: { + required: true, + type: Boolean, + }, + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + loading: false, + }; + }, + computed: { + toggleButtonText() { + if (this.loading) { + return __('Applying'); + } + return this.confidential ? __('Turn off') : __('Turn on'); + }, + warningMessage() { + return this.confidential + ? this.$options.i18n.confidentialityOffWarning + : this.$options.i18n.confidentialityOnWarning; + }, + workspacePath() { + return this.issuableType === IssuableType.Issue + ? { + projectPath: this.fullPath, + } + : { + groupPath: this.fullPath, + }; + }, + }, + methods: { + submitForm() { + this.loading = true; + this.$apollo + .mutate({ + mutation: confidentialityQueries[this.issuableType].mutation, + variables: { + input: { + ...this.workspacePath, + iid: this.iid, + confidential: !this.confidential, + }, + }, + }) + .then( + ({ + data: { + issuableSetConfidential: { errors }, + }, + }) => { + if (errors.length) { + createFlash({ + message: errors[0], + }); + } else { + this.$emit('closeForm'); + } + }, + ) + .catch(() => { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} confidentiality.'), + { + issuableType: this.issuableType, + }, + ), + }); + }) + .finally(() => { + this.loading = false; + }); + }, + }, +}; +</script> + +<template> + <div class="dropdown show"> + <div class="dropdown-menu sidebar-item-warning-message"> + <div> + <p data-testid="warning-message"> + <gl-sprintf :message="warningMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #issuableType>{{ issuableType }}</template> + </gl-sprintf> + </p> + <div class="sidebar-item-warning-message-actions"> + <gl-button class="gl-mr-3" data-testid="confidential-cancel" @click="$emit('closeForm')"> + {{ __('Cancel') }} + </gl-button> + <gl-button + category="secondary" + variant="warning" + :disabled="loading" + :loading="loading" + data-testid="confidential-toggle" + @click.prevent="submitForm" + > + {{ toggleButtonText }} + </gl-button> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue new file mode 100644 index 00000000000..1db68d3d5b1 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/confidential/sidebar_confidentiality_widget.vue @@ -0,0 +1,143 @@ +<script> +import produce from 'immer'; +import Vue from 'vue'; +import createFlash from '~/flash'; +import { __, sprintf } from '~/locale'; +import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; +import { confidentialityQueries } from '~/sidebar/constants'; +import SidebarConfidentialityContent from './sidebar_confidentiality_content.vue'; +import SidebarConfidentialityForm from './sidebar_confidentiality_form.vue'; + +export const confidentialWidget = Vue.observable({ + setConfidentiality: null, +}); + +const hideDropdownEvent = new CustomEvent('hiddenGlDropdown', { + bubbles: true, +}); + +export default { + tracking: { + event: 'click_edit_button', + label: 'right_sidebar', + property: 'confidentiality', + }, + components: { + SidebarEditableItem, + SidebarConfidentialityContent, + SidebarConfidentialityForm, + }, + inject: ['fullPath', 'iid'], + props: { + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + confidential: false, + }; + }, + apollo: { + confidential: { + query() { + return confidentialityQueries[this.issuableType].query; + }, + variables() { + return { + fullPath: this.fullPath, + iid: String(this.iid), + }; + }, + update(data) { + return data.workspace?.issuable?.confidential || false; + }, + result({ data }) { + this.$emit('confidentialityUpdated', data.workspace?.issuable?.confidential); + }, + error() { + createFlash({ + message: sprintf( + __('Something went wrong while setting %{issuableType} confidentiality.'), + { + issuableType: this.issuableType, + }, + ), + }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.confidential.loading; + }, + }, + mounted() { + confidentialWidget.setConfidentiality = this.setConfidentiality; + }, + destroyed() { + confidentialWidget.setConfidentiality = null; + }, + methods: { + closeForm() { + this.$refs.editable.collapse(); + this.$el.dispatchEvent(hideDropdownEvent); + this.$emit('closeForm'); + }, + // synchronizing the quick action with the sidebar widget + // this is a temporary solution until we have confidentiality real-time updates + setConfidentiality() { + const { defaultClient: client } = this.$apollo.provider.clients; + const sourceData = client.readQuery({ + query: confidentialityQueries[this.issuableType].query, + variables: { fullPath: this.fullPath, iid: this.iid }, + }); + + const data = produce(sourceData, (draftData) => { + // eslint-disable-next-line no-param-reassign + draftData.workspace.issuable.confidential = !this.confidential; + }); + + client.writeQuery({ + query: confidentialityQueries[this.issuableType].query, + variables: { fullPath: this.fullPath, iid: this.iid }, + data, + }); + }, + expandSidebar() { + this.$refs.editable.expand(); + this.$emit('expandSidebar'); + }, + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="__('Confidentiality')" + :tracking="$options.tracking" + :loading="isLoading" + class="block confidentiality" + > + <template #collapsed> + <div> + <sidebar-confidentiality-content + v-if="!isLoading" + :confidential="confidential" + :issuable-type="issuableType" + @expandSidebar="expandSidebar" + /> + </div> + </template> + <template #default> + <sidebar-confidentiality-content :confidential="confidential" :issuable-type="issuableType" /> + <sidebar-confidentiality-form + :confidential="confidential" + :issuable-type="issuableType" + @closeForm="closeForm" + /> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue index cbd68f2513a..dd1d54d67f2 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/uncollapsed_reviewer_list.vue @@ -1,5 +1,6 @@ <script> import { GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; import ReviewerAvatarLink from './reviewer_avatar_link.vue'; const LOADING_STATE = 'loading'; @@ -50,6 +51,9 @@ export default { }, }, methods: { + approvedByTooltipTitle(user) { + return sprintf(s__('MergeRequest|Approved by @%{username}'), user); + }, toggleShowLess() { this.showLess = !this.showLess; }, @@ -57,6 +61,7 @@ export default { this.loadingStates[userId] = LOADING_STATE; this.$emit('request-review', { userId, callback: this.requestReviewComplete }); }, + requestReviewComplete(userId, success) { if (success) { this.loadingStates[userId] = SUCCESS_STATE; @@ -86,10 +91,19 @@ export default { <div class="gl-ml-3">@{{ user.username }}</div> </reviewer-avatar-link> <gl-icon + v-if="user.approved" + v-gl-tooltip.left + :size="16" + :title="approvedByTooltipTitle(user)" + name="status-success" + class="float-right gl-my-2 gl-ml-2 gl-text-green-500" + data-testid="re-approved" + /> + <gl-icon v-if="loadingStates[user.id] === $options.SUCCESS_STATE" :size="24" name="check" - class="float-right gl-text-green-500" + class="float-right gl-py-2 gl-mr-2 gl-text-green-500" data-testid="re-request-success" /> <gl-button diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue index 9da839cd133..4ab4606ac1c 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -3,7 +3,12 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; export default { components: { GlButton, GlLoadingIcon }, - inject: ['canUpdate'], + inject: { + canUpdate: {}, + isClassicSidebar: { + default: false, + }, + }, props: { title: { type: String, @@ -15,6 +20,15 @@ export default { required: false, default: false, }, + tracking: { + type: Object, + required: false, + default: () => ({ + event: null, + label: null, + property: null, + }), + }, }, data() { return { @@ -71,24 +85,33 @@ export default { <template> <div> - <div class="gl-display-flex gl-align-items-center gl-mb-3" @click.self="collapse"> - <span data-testid="title">{{ title }}</span> - <gl-loading-icon v-if="loading" inline class="gl-ml-2" /> + <div class="gl-display-flex gl-align-items-center" @click.self="collapse"> + <span class="hide-collapsed" data-testid="title">{{ title }}</span> + <gl-loading-icon v-if="loading" inline class="gl-ml-2 hide-collapsed" /> + <gl-loading-icon + v-if="loading && isClassicSidebar" + inline + class="gl-mx-auto gl-my-0 hide-expanded" + /> <gl-button v-if="canUpdate" variant="link" - class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto js-sidebar-dropdown-toggle" + class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto hide-collapsed" data-testid="edit-button" + :data-track-event="tracking.event" + :data-track-label="tracking.label" + :data-track-property="tracking.property" + data-qa-selector="edit_link" @keyup.esc="toggle" @click="toggle" > {{ __('Edit') }} </gl-button> </div> - <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content"> + <div v-show="!edit" data-testid="collapsed-content"> <slot name="collapsed">{{ __('None') }}</slot> </div> - <div v-show="edit" data-testid="expanded-content"> + <div v-show="edit" data-testid="expanded-content" :class="{ 'gl-mt-3': !isClassicSidebar }"> <slot :edit="edit"></slot> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index 9b06c20a6f3..c0424dc2873 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -122,6 +122,8 @@ export default { :value="subscribed" class="hide-collapsed" data-testid="subscription-toggle" + :label="__('Notifications')" + label-position="hidden" @change="toggleSubscription" /> </div> 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 e0f60b9af08..d1a5685fdd3 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.vue @@ -1,10 +1,14 @@ <script> /* eslint-disable vue/no-v-html */ +import { GlButton } from '@gitlab/ui'; import { joinPaths } from '~/lib/utils/url_utility'; import { sprintf, s__ } from '../../../locale'; export default { name: 'TimeTrackingHelpState', + components: { + GlButton, + }, computed: { href() { return joinPaths(gon.relative_url_root || '', '/help/user/project/time_tracking.md'); @@ -40,7 +44,7 @@ export default { <p>{{ __('Quick actions can be used in the issues description and comment boxes.') }}</p> <p v-html="estimateText"></p> <p v-html="spendText"></p> - <a :href="href" class="btn btn-default learn-more-button"> {{ __('Learn more') }} </a> + <gl-button :href="href">{{ __('Learn more') }}</gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 274aa237aea..e3929499009 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,9 +1,15 @@ import { IssuableType } from '~/issue_show/constants'; +import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; +import issueConfidentialQuery from '~/sidebar/queries/issue_confidential.query.graphql'; +import updateEpicMutation from '~/sidebar/queries/update_epic_confidential.mutation.graphql'; +import updateIssueConfidentialMutation from '~/sidebar/queries/update_issue_confidential.mutation.graphql'; import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql'; import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; +export const ASSIGNEES_DEBOUNCE_DELAY = 250; + export const assigneesQueries = { [IssuableType.Issue]: { query: getIssueParticipants, @@ -14,3 +20,14 @@ export const assigneesQueries = { mutation: updateMergeRequestParticipantsMutation, }, }; + +export const confidentialityQueries = { + [IssuableType.Issue]: { + query: issueConfidentialQuery, + mutation: updateIssueConfidentialMutation, + }, + [IssuableType.Epic]: { + query: epicConfidentialQuery, + mutation: updateEpicMutation, + }, +}; diff --git a/app/assets/javascripts/sidebar/graphql.js b/app/assets/javascripts/sidebar/graphql.js new file mode 100644 index 00000000000..aa139540a51 --- /dev/null +++ b/app/assets/javascripts/sidebar/graphql.js @@ -0,0 +1,8 @@ +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +export const defaultClient = createDefaultClient(); + +export const apolloProvider = new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 662edbc4f8d..3c56e95ef40 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -2,7 +2,6 @@ import $ from 'jquery'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createFlash from '~/flash'; -import createDefaultClient from '~/lib/graphql'; import { isInIssuePage, isInDesignPage, @@ -10,9 +9,10 @@ import { parseBoolean, } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; +import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; +import { apolloProvider } from '~/sidebar/graphql'; import Translate from '../vue_shared/translate'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; -import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; import SidebarLabels from './components/labels/sidebar_labels.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; @@ -54,9 +54,6 @@ function getSidebarAssigneeAvailabilityData() { function mountAssigneesComponent(mediator) { const el = document.getElementById('js-vue-sidebar-assignees'); - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); if (!el) return; @@ -87,9 +84,6 @@ function mountAssigneesComponent(mediator) { function mountReviewersComponent(mediator) { const el = document.getElementById('js-vue-sidebar-reviewers'); - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); if (!el) return; @@ -121,10 +115,6 @@ export function mountSidebarLabels() { return false; } - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); - return new Vue({ el, apolloProvider, @@ -139,39 +129,37 @@ export function mountSidebarLabels() { }); } -function mountConfidentialComponent(mediator) { +function mountConfidentialComponent() { const el = document.getElementById('js-confidential-entry-point'); + if (!el) { + return; + } const { fullPath, iid } = getSidebarOptions(); - - if (!el) return; - const dataNode = document.getElementById('js-confidential-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - import(/* webpackChunkName: 'notesStore' */ '~/notes/stores') - .then( - ({ store }) => - new Vue({ - el, - store, - components: { - ConfidentialIssueSidebar, - }, - render: (createElement) => - createElement('confidential-issue-sidebar', { - props: { - iid: String(iid), - fullPath, - isEditable: initialData.is_editable, - service: mediator.service, - }, - }), - }), - ) - .catch(() => { - createFlash({ message: __('Failed to load sidebar confidential toggle') }); - }); + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + components: { + SidebarConfidentialityWidget, + }, + provide: { + iid: String(iid), + fullPath, + canUpdate: initialData.is_editable, + }, + + render: (createElement) => + createElement('sidebar-confidentiality-widget', { + props: { + issuableType: + isInIssuePage() || isInIncidentPage() || isInDesignPage() ? 'issue' : 'merge_request', + }, + }), + }); } function mountLockComponent() { @@ -280,9 +268,6 @@ function mountSeverityComponent() { if (!severityContainerEl) { return false; } - const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), - }); const { fullPath, iid, severity } = getSidebarOptions(); diff --git a/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql new file mode 100644 index 00000000000..7a1fdb40e93 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/epic_confidential.query.graphql @@ -0,0 +1,10 @@ +query epicConfidential($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + __typename + issuable: epic(iid: $iid) { + __typename + id + confidential + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql new file mode 100644 index 00000000000..92cabf46af7 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issue_confidential.query.graphql @@ -0,0 +1,10 @@ +query issueConfidential($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + __typename + issuable: issue(iid: $iid) { + __typename + id + confidential + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql new file mode 100644 index 00000000000..02498b18832 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/sidebarDetailsMR.query.graphql @@ -0,0 +1,7 @@ +query mergeRequestSidebarDetails($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + mergeRequest(iid: $iid) { + iid # currently unused. + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql new file mode 100644 index 00000000000..69927ddd205 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_epic_confidential.mutation.graphql @@ -0,0 +1,9 @@ +mutation updateEpic($input: UpdateEpicInput!) { + issuableSetConfidential: updateEpic(input: $input) { + issuable: epic { + id + confidential + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_issue_confidential.mutation.graphql index 5caf5f6b555..8f716c882d6 100644 --- a/app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql +++ b/app/assets/javascripts/sidebar/queries/update_issue_confidential.mutation.graphql @@ -1,6 +1,7 @@ mutation updateIssueConfidential($input: IssueSetConfidentialInput!) { - issueSetConfidential(input: $input) { - issue { + issuableSetConfidential: issueSetConfidential(input: $input) { + issuable: issue { + id confidential } errors diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index f31e4a3e0dd..88501f2c305 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -1,8 +1,14 @@ -import sidebarDetailsQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql'; +import sidebarDetailsIssueQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import axios from '~/lib/utils/axios_utils'; import reviewerRereviewMutation from '../queries/reviewer_rereview.mutation.graphql'; +import sidebarDetailsMRQuery from '../queries/sidebarDetailsMR.query.graphql'; + +const queries = { + merge_request: sidebarDetailsMRQuery, + issue: sidebarDetailsIssueQuery, +}; export const gqClient = createGqClient( {}, @@ -20,6 +26,7 @@ export default class SidebarService { this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; this.fullPath = endpointMap.fullPath; this.iid = endpointMap.iid; + this.issuableType = endpointMap.issuableType; SidebarService.singleton = this; } @@ -31,7 +38,7 @@ export default class SidebarService { return Promise.all([ axios.get(this.endpoint), gqClient.query({ - query: sidebarDetailsQuery, + query: this.sidebarDetailsQuery(), variables: { fullPath: this.fullPath, iid: this.iid.toString(), @@ -40,6 +47,10 @@ export default class SidebarService { ]); } + sidebarDetailsQuery() { + return queries[this.issuableType]; + } + update(key, data) { return axios.put(this.endpoint, { [key]: data }); } diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index bd382ed0fdb..3595354da80 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -22,6 +22,7 @@ export default class SidebarMediator { projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, fullPath: options.fullPath, iid: options.iid, + issuableType: options.issuableType, }); SidebarMediator.singleton = this; } diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 687289b6675..2c4928fc338 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -73,7 +73,7 @@ export default class SingleFileDiff { this.collapsedContent.hide(); this.loadingContent.show(); - axios + return axios .get(this.diffForPath) .then(({ data }) => { this.loadingContent.hide(); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 9f43ac36df7..bee9d7b8c2a 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -221,7 +221,10 @@ export default { this.captchaResponse = captchaResponse; if (this.captchaResponse) { - // If the user solved the captcha resubmit the form. + // If the user solved the captcha, resubmit the form. + // NOTE: we do not need to clear out the captchaResponse and spamLogId + // data values after submit, because this component always does a full page reload. + // Otherwise, we would need to. this.handleFormSubmit(); } else { // If the user didn't solve the captcha (e.g. they just closed the modal), diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue index 18a7d4ad218..e6aa3be0371 100644 --- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue @@ -55,7 +55,12 @@ export default { > <div class="d-flex align-items-center"> <gl-icon :size="16" :name="option.icon" /> - <span class="font-weight-bold ml-1 js-visibility-option">{{ option.label }}</span> + <span + class="font-weight-bold ml-1 js-visibility-option" + data-qa-selector="visibility_content" + :data-qa-visibility="option.label" + >{{ option.label }}</span + > </div> <template #help>{{ isProjectSnippet && option.description_project diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js index 4cabd943e22..5fb20b00705 100644 --- a/app/assets/javascripts/static_site_editor/constants.js +++ b/app/assets/javascripts/static_site_editor/constants.js @@ -12,7 +12,7 @@ export const SUBMIT_CHANGES_MERGE_REQUEST_ERROR = s__( 'StaticSiteEditor|Could not create merge request.', ); export const LOAD_CONTENT_ERROR = __( - 'An error ocurred while loading your content. Please try again.', + 'An error occurred while loading your content. Please try again.', ); export const DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE = s__( diff --git a/app/assets/javascripts/tooltips/components/tooltips.vue b/app/assets/javascripts/tooltips/components/tooltips.vue index 90bdf06bc4c..1ad18508294 100644 --- a/app/assets/javascripts/tooltips/components/tooltips.vue +++ b/app/assets/javascripts/tooltips/components/tooltips.vue @@ -82,9 +82,10 @@ export default { }, triggerEvent(target, event) { const tooltip = this.findTooltipByTarget(target); + const tooltipRef = this.$refs[tooltip?.id]; - if (tooltip) { - this.$refs[tooltip.id][0].$emit(event); + if (tooltipRef) { + tooltipRef[0].$emit(event); } }, tooltipExists(element) { @@ -113,6 +114,7 @@ export default { :boundary="tooltip.boundary" :disabled="tooltip.disabled" :show="tooltip.show" + @hidden="$emit('hidden', tooltip)" > <span v-if="tooltip.html" v-safe-html:[$options.safeHtmlConfig]="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 a9978c03a6e..f60c0759c72 100644 --- a/app/assets/javascripts/tooltips/index.js +++ b/app/assets/javascripts/tooltips/index.js @@ -92,6 +92,7 @@ export const hide = createTooltipApiInvoker((element) => export const show = createTooltipApiInvoker((element) => tooltipsApp().triggerEvent(element, 'open'), ); +export const once = (event, cb) => tooltipsApp().$once(event, cb); export const destroy = () => { tooltipsApp().$destroy(); app = null; diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 5d82d56f4ba..01de034417e 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -1,4 +1,16 @@ import { omitBy, isUndefined } from 'lodash'; +import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants'; +import { getExperimentData } from '~/experimentation/utils'; + +const standardContext = { ...window.gl?.snowplowStandardContext }; + +export const STANDARD_CONTEXT = { + schema: standardContext.schema, + data: { + ...(standardContext.data || {}), + source: 'gitlab-javascript', + }, +}; const DEFAULT_SNOWPLOW_OPTIONS = { namespace: 'gl', @@ -20,11 +32,17 @@ const createEventPayload = (el, { suffix = '' } = {}) => { let value = el.dataset.trackValue || el.value || undefined; if (el.type === 'checkbox' && !el.checked) value = false; + let context = el.dataset.trackContext; + if (el.dataset.trackExperiment) { + const data = getExperimentData(el.dataset.trackExperiment); + if (data) context = { schema: TRACKING_CONTEXT_SCHEMA, data }; + } + const data = { label: el.dataset.trackLabel, property: el.dataset.trackProperty, value, - context: el.dataset.trackContext, + context, }; return { @@ -51,25 +69,51 @@ const eventHandlers = (category, func) => { return handlers; }; +const dispatchEvent = (category = document.body.dataset.page, action = 'generic', data = {}) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + if (!category) throw new Error('Tracking: no category provided for tracking.'); + + const { label, property, value } = data; + const contexts = [STANDARD_CONTEXT]; + + if (data.context) { + contexts.push(data.context); + } + + return window.snowplow('trackStructEvent', category, action, label, property, value, contexts); +}; + export default class Tracking { + static queuedEvents = []; + static initialized = false; + static trackable() { return !['1', 'yes'].includes( window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack, ); } + static flushPendingEvents() { + this.initialized = true; + + while (this.queuedEvents.length) { + dispatchEvent(...this.queuedEvents.shift()); + } + } + static enabled() { return typeof window.snowplow === 'function' && this.trackable(); } - static event(category = document.body.dataset.page, action = 'generic', data = {}) { + static event(...eventData) { if (!this.enabled()) return false; - // eslint-disable-next-line @gitlab/require-i18n-strings - if (!category) throw new Error('Tracking: no category provided for tracking.'); - const { label, property, value, context } = data; - const contexts = context ? [context] : undefined; - return window.snowplow('trackStructEvent', category, action, label, property, value, contexts); + if (!this.initialized) { + this.queuedEvents.push(eventData); + return false; + } + + return dispatchEvent(...eventData); } static bindDocument(category = document.body.dataset.page, parent = document) { @@ -128,13 +172,15 @@ export function initUserTracking() { window.snowplow('newTracker', opts.namespace, opts.hostname, opts); document.dispatchEvent(new Event('SnowplowInitialized')); + Tracking.flushPendingEvents(); } export function initDefaultTrackers() { if (!Tracking.enabled()) return; window.snowplow('enableActivityTracking', 30, 30); - window.snowplow('trackPageView'); // must be after enableActivityTracking + // must be after enableActivityTracking + window.snowplow('trackPageView', null, [STANDARD_CONTEXT]); if (window.snowplowOptions.formTracking) window.snowplow('enableFormTracking'); if (window.snowplowOptions.linkClickTracking) window.snowplow('enableLinkClickTracking'); diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index c18f4fb46cc..682932f6750 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -59,11 +59,33 @@ const populateUserInfo = (user) => { }; const initializedPopovers = new Map(); +let domObservedForChanges = false; -export default (elements = document.querySelectorAll('.js-user-link')) => { +const addPopoversToModifiedTree = new MutationObserver(() => { + const userLinks = document?.querySelectorAll('.js-user-link, .gfm-project_member'); + + if (userLinks) { + addPopovers(userLinks); /* eslint-disable-line no-use-before-define */ + } +}); + +function observeBody() { + if (!domObservedForChanges) { + addPopoversToModifiedTree.observe(document.body, { + subtree: true, + childList: true, + }); + + domObservedForChanges = true; + } +} + +export default function addPopovers(elements = document.querySelectorAll('.js-user-link')) { const userLinks = Array.from(elements); const UserPopoverComponent = Vue.extend(UserPopover); + observeBody(); + return userLinks .filter(({ dataset }) => dataset.user || dataset.userId) .map((el) => { @@ -105,4 +127,4 @@ export default (elements = document.querySelectorAll('.js-user-link')) => { return renderedPopover; }); -}; +} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue new file mode 100644 index 00000000000..d23c7f016fb --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_list.vue @@ -0,0 +1,92 @@ +<script> +import { GlSprintf } from '@gitlab/ui'; +import { n__ } from '~/locale'; +import MrCollapsibleExtension from '../mr_collapsible_extension.vue'; + +export default { + components: { + Deployment: () => import('./deployment.vue'), + GlSprintf, + MrCollapsibleExtension, + }, + props: { + deployments: { + type: Array, + required: true, + }, + deploymentClass: { + type: String, + required: true, + }, + hasDeploymentMetrics: { + type: Boolean, + required: true, + }, + visualReviewAppMeta: { + type: Object, + required: false, + default: () => ({ + sourceProjectId: '', + sourceProjectPath: '', + mergeRequestId: '', + appUrl: '', + }), + }, + showVisualReviewAppLink: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + showCollapsedDeployments() { + return this.deployments.length > 3; + }, + multipleDeploymentsTitle() { + return n__( + 'Deployments|%{deployments} environment impacted.', + 'Deployments|%{deployments} environments impacted.', + this.deployments.length, + ); + }, + }, +}; +</script> +<template> + <mr-collapsible-extension + v-if="showCollapsedDeployments" + :title="__('View all environments.')" + data-testid="mr-collapsed-deployments" + > + <template #header> + <div class="gl-mr-3 gl-line-height-normal"> + <gl-sprintf :message="multipleDeploymentsTitle"> + <template #deployments> + <span class="gl-font-weight-bold gl-mr-2">{{ deployments.length }}</span> + </template> + </gl-sprintf> + </div> + </template> + <deployment + v-for="deployment in deployments" + :key="deployment.id" + :class="deploymentClass" + class="gl-bg-gray-50" + :deployment="deployment" + :show-metrics="hasDeploymentMetrics" + :show-visual-review-app="showVisualReviewAppLink" + :visual-review-app-meta="visualReviewAppMeta" + /> + </mr-collapsible-extension> + <div v-else class="mr-widget-extension"> + <deployment + v-for="deployment in deployments" + :key="deployment.id" + :class="deploymentClass" + :deployment="deployment" + :show-metrics="hasDeploymentMetrics" + :show-visual-review-app="showVisualReviewAppLink" + :visual-review-app-meta="visualReviewAppMeta" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue index b6b5b56e5aa..a619ae9c351 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue @@ -50,9 +50,9 @@ export default { <div class="mr-widget-extension d-flex align-items-center pl-3"> <div v-if="hasError" class="ci-widget media"> <div class="media-body"> - <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">{{ - title - }}</span> + <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state"> + {{ title }} + </span> </div> </div> @@ -67,16 +67,27 @@ export default { <gl-loading-icon v-if="isLoading" /> <gl-icon v-else :name="arrowIconName" class="js-icon" /> </button> + <template v-if="isCollapsed"> + <slot name="header"></slot> + <gl-button + variant="link" + data-testid="mr-collapsible-title" + :disabled="isLoading" + :class="{ 'border-0': isLoading }" + @click="toggleCollapsed" + > + {{ title }} + </gl-button> + </template> <gl-button + v-else variant="link" - class="js-title" + data-testid="mr-collapsible-title" :disabled="isLoading" :class="{ 'border-0': isLoading }" @click="toggleCollapsed" + >{{ __('Collapse') }}</gl-button > - <template v-if="isCollapsed">{{ title }}</template> - <template v-else>{{ __('Collapse') }}</template> - </gl-button> </template> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue index 7c50df5f104..7532eabee8a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -108,9 +108,7 @@ export default { {{ $options.i18n.steps.step1.help }} </p> <div class="gl-display-flex"> - <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{ - mergeInfo1 - }}</pre> + <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo1 }}</pre> <clipboard-button :text="mergeInfo1" :title="$options.i18n.copyCommands" @@ -131,9 +129,7 @@ export default { {{ $options.i18n.steps.step3.help }} </p> <div class="gl-display-flex"> - <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{ - mergeInfo2 - }}</pre> + <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo2 }}</pre> <clipboard-button :text="mergeInfo2" :title="$options.i18n.copyCommands" @@ -147,9 +143,7 @@ export default { {{ $options.i18n.steps.step4.help }} </p> <div class="gl-display-flex"> - <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{ - mergeInfo3 - }}</pre> + <pre class="gl-w-full" data-testid="how-to-merge-instructions">{{ mergeInfo3 }}</pre> <clipboard-button :text="mergeInfo3" :title="$options.i18n.copyCommands" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index d022579ef54..3419abd4738 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -11,10 +11,11 @@ import { } from '@gitlab/ui'; import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; import { s__, n__ } from '~/locale'; +import PipelineMiniGraph from '~/pipelines/components/pipelines_list/pipeline_mini_graph.vue'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; -import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import { MT_MERGE_STRATEGY } from '../constants'; export default { name: 'MRWidgetPipeline', @@ -26,7 +27,7 @@ export default { GlSprintf, GlTooltip, PipelineArtifacts, - PipelineStage, + PipelineMiniGraph, TooltipOnTruncate, LinkedPipelinesMiniList: () => import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), @@ -80,6 +81,11 @@ export default { type: String, required: true, }, + mergeStrategy: { + type: String, + required: false, + default: '', + }, }, computed: { hasPipeline() { @@ -94,9 +100,7 @@ export default { : {}; }, hasStages() { - return ( - this.pipeline.details && this.pipeline.details.stages && this.pipeline.details.stages.length - ); + return this.pipeline?.details?.stages?.length > 0; }, hasCommitInfo() { return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; @@ -130,6 +134,9 @@ export default { this.buildsWithCoverage.length, ); }, + isMergeTrain() { + return this.mergeStrategy === MT_MERGE_STRATEGY; + }, }, errorText: s__( 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.', @@ -242,19 +249,13 @@ export default { <span class="mr-widget-pipeline-graph"> <span class="stage-cell"> <linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" /> - <template v-if="hasStages"> - <div - v-for="(stage, i) in pipeline.details.stages" - :key="i" - :class="{ - 'has-downstream': hasDownstream(i), - }" - class="stage-container dropdown mr-widget-pipeline-stages" - data-testid="widget-mini-pipeline-graph" - > - <pipeline-stage :stage="stage" /> - </div> - </template> + <pipeline-mini-graph + v-if="hasStages" + class="gl-display-inline-block" + stages-class="mr-widget-pipeline-stages" + :stages="pipeline.details.stages" + :is-merge-train="isMergeTrain" + /> </span> <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> <pipeline-artifacts diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index 2bf86c1863a..c24ae92db4f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -1,8 +1,11 @@ <script> import { isNumber } from 'lodash'; import { sanitize } from '~/lib/dompurify'; +import { n__ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import MergeRequestStore from '../stores/mr_widget_store'; import ArtifactsApp from './artifacts_list_app.vue'; +import DeploymentList from './deployment/deployment_list.vue'; import MrWidgetContainer from './mr_widget_container.vue'; import MrWidgetPipeline from './mr_widget_pipeline.vue'; @@ -18,7 +21,7 @@ export default { name: 'MrWidgetPipelineContainer', components: { ArtifactsApp, - Deployment: () => import('./deployment/deployment.vue'), + DeploymentList, MrWidgetContainer, MrWidgetPipeline, MergeTrainPositionIndicator: () => @@ -64,11 +67,32 @@ export default { return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline; }, showVisualReviewAppLink() { - return this.mr.visualReviewAppAvailable && this.glFeatures.anonymousVisualReviewFeedback; + return Boolean( + this.mr.visualReviewAppAvailable && this.glFeatures.anonymousVisualReviewFeedback, + ); }, showMergeTrainPositionIndicator() { return isNumber(this.mr.mergeTrainIndex); }, + showCollapsedDeployments() { + return this.deployments.length > 3; + }, + multipleDeploymentsTitle() { + return n__( + 'Deployments|%{deployments} environment impacted.', + 'Deployments|%{deployments} environments impacted.', + this.deployments.length, + ); + }, + preferredAutoMergeStrategy() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + return MergeRequestStore.getPreferredAutoMergeStrategy( + this.mr.availableAutoMergeStrategies, + ); + } + + return this.mr.preferredAutoMergeStrategy; + }, }, }; </script> @@ -85,22 +109,20 @@ export default { :source-branch-link="branchLink" :mr-troubleshooting-docs-path="mr.mrTroubleshootingDocsPath" :ci-troubleshooting-docs-path="mr.ciTroubleshootingDocsPath" + :merge-strategy="preferredAutoMergeStrategy" /> <template #footer> <div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts"> <artifacts-app :endpoint="mr.exposedArtifactsPath" /> </div> - <div v-if="deployments.length" class="mr-widget-extension"> - <deployment - v-for="deployment in deployments" - :key="deployment.id" - :class="deploymentClass" - :deployment="deployment" - :show-metrics="hasDeploymentMetrics" - :show-visual-review-app="showVisualReviewAppLink" - :visual-review-app-meta="visualReviewAppMeta" - /> - </div> + <deployment-list + v-if="deployments.length" + :deployments="deployments" + :deployment-class="deploymentClass" + :has-deployment-metrics="hasDeploymentMetrics" + :visual-review-app-meta="visualReviewAppMeta" + :show-visual-review-app-link="showVisualReviewAppLink" + /> <merge-train-position-indicator v-if="showMergeTrainPositionIndicator" class="mr-widget-extension" 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 428641a1109..84a21a25552 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 @@ -154,7 +154,7 @@ export default { <status-icon status="success" /> <div class="media-body"> <h4 class="gl-display-flex"> - <span class="gl-mr-3"> + <span class="gl-mr-3" data-qa-selector="merge_request_status_content"> <span class="js-status-text-before-author" data-testid="beforeStatusText">{{ statusTextBeforeAuthor }}</span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 2335e2984e4..23f415c3116 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue @@ -1,9 +1,6 @@ <script> -import { GlButton, GlModalDirective, GlSkeletonLoader } from '@gitlab/ui'; -import $ from 'jquery'; -import { escape } from 'lodash'; -import { s__, sprintf } from '~/locale'; -import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover'; +import { GlButton, GlModalDirective, GlSkeletonLoader, GlPopover, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import userPermissionsQuery from '../../queries/permissions.query.graphql'; @@ -16,6 +13,8 @@ export default { GlSkeletonLoader, StatusIcon, GlButton, + GlPopover, + GlLink, }, directives: { GlModalDirective, @@ -106,48 +105,11 @@ export default { return this.showResolveButton && this.sourceBranchProtected; }, }, - watch: { - showPopover: { - handler(newVal) { - if (newVal) { - this.$nextTick(this.initPopover); - } - }, - immediate: true, - }, - }, - methods: { - initPopover() { - const $el = $(this.$refs.popover); - - $el - .popover({ - html: true, - trigger: 'focus', - container: 'body', - placement: 'top', - template: - '<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-header"></p><div class="popover-body"></div></div>', - title: s__( - 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.', - ), - content: sprintf( - s__('mrWidget|%{link_start}Learn more about resolving conflicts%{link_end}'), - { - link_start: `<a href="${escape( - this.mr.conflictsDocsPath, - )}" target="_blank" rel="noopener noreferrer">`, - link_end: '</a>', - }, - false, - ), - }) - .on('mouseenter', mouseenter) - .on('mouseleave', debouncedMouseleave(300)) - .on('show.bs.popover', () => { - window.addEventListener('scroll', togglePopover.bind($el, false), { once: true }); - }); - }, + i18n: { + title: s__( + 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.', + ), + linkText: s__('mrWidget|Learn more about resolving conflicts'), }, }; </script> @@ -162,7 +124,7 @@ export default { <rect x="250" y="7" width="84" height="16" rx="4" /> </gl-skeleton-loader> </div> - <div v-else class="media-body space-children"> + <div v-else class="media-body space-children gl-display-flex gl-align-items-center"> <span v-if="shouldBeRebased" class="bold"> {{ s__(`mrWidget|Fast-forward merge is not possible. @@ -181,17 +143,35 @@ export default { </span> <span v-if="showResolveButton" ref="popover"> <gl-button - :href="!sourceBranchProtected && mr.conflictResolutionPath" + :href="mr.conflictResolutionPath" :disabled="sourceBranchProtected" - class="js-resolve-conflicts-button" + data-testid="resolve-conflicts-button" > {{ s__('mrWidget|Resolve conflicts') }} </gl-button> + <gl-popover + v-if="showPopover" + :target="() => $refs.popover" + placement="top" + triggers="hover focus" + > + <template #title> + <div class="gl-font-weight-normal gl-font-base"> + {{ $options.i18n.title }} + </div> + </template> + + <div class="gl-text-center"> + <gl-link :href="mr.conflictsDocsPath" target="_blank" rel="noopener noreferrer"> + {{ $options.i18n.linkText }} + </gl-link> + </div> + </gl-popover> </span> <gl-button v-if="canMerge" v-gl-modal-directive="'modal-merge-info'" - class="js-merge-locally-button" + data-testid="merge-locally-button" > {{ s__('mrWidget|Merge locally') }} </gl-button> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue index f0259a975db..01e0b91bd4a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue @@ -1,12 +1,15 @@ <script> /* eslint-disable vue/no-v-html */ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlSprintf, GlLink } from '@gitlab/ui'; import emptyStateSVG from 'icons/_mr_widget_empty_state.svg'; +import { helpPagePath } from '~/helpers/help_page_helper'; export default { name: 'MRWidgetNothingToMerge', components: { GlButton, + GlSprintf, + GlLink, }, props: { mr: { @@ -17,6 +20,7 @@ export default { data() { return { emptyStateSVG }; }, + ciHelpPage: helpPagePath('/ci/quick_start/index.html'), }; </script> @@ -30,25 +34,20 @@ export default { </div> <div class="text col-md-7 order-md-first col-12"> <p class="highlight"> - {{ - s__( - 'mrWidgetNothingToMerge|Merge requests are a place to propose changes you have made to a project and discuss those changes with others.', - ) - }} + {{ s__('mrWidgetNothingToMerge|This merge request contains no changes.') }} </p> <p> - {{ - s__( - 'mrWidgetNothingToMerge|Interested parties can even contribute by pushing commits if they want to.', - ) - }} - </p> - <p> - {{ - s__( - "mrWidgetNothingToMerge|Currently there are no changes in this merge request's source branch. Please push new commits or use a different branch.", - ) - }} + <gl-sprintf + :message=" + s__( + 'mrWidgetNothingToMerge|Use merge requests to propose changes to your project and discuss them with your team. To make changes, push a commit or edit this merge request to use a different branch. With %{linkStart}CI/CD%{linkEnd}, automatically test your changes before merging.', + ) + " + > + <template #link="{ content }"> + <gl-link :href="$options.ciHelpPage" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> </p> <div> <gl-button @@ -56,6 +55,7 @@ export default { :href="mr.newBlobPath" category="secondary" variant="success" + data-testid="createFileButton" > {{ __('Create file') }} </gl-button> 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 690b6e9c462..0503b76bea4 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 @@ -5,6 +5,7 @@ import { GlButtonGroup, GlDropdown, GlDropdownItem, + GlFormCheckbox, GlSprintf, GlLink, GlTooltipDirective, @@ -81,6 +82,7 @@ export default { GlButtonGroup, GlDropdown, GlDropdownItem, + GlFormCheckbox, GlSkeletonLoader, MergeTrainHelperText: () => import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'), @@ -453,8 +455,8 @@ export default { <div class="mr-widget-body media" :class="{ 'gl-pb-3': shouldRenderMergeTrainHelperText }"> <status-icon :status="iconClass" /> <div class="media-body"> - <div class="mr-widget-body-controls media space-children"> - <gl-button-group> + <div class="mr-widget-body-controls gl-display-flex gl-align-items-center"> + <gl-button-group class="gl-align-self-start"> <gl-button size="medium" category="primary" @@ -493,47 +495,48 @@ export default { /> </gl-dropdown> </gl-button-group> - <div class="media-body-wrap space-children"> - <template v-if="shouldShowMergeControls"> - <label v-if="canRemoveSourceBranch"> - <input - id="remove-source-branch-input" - v-model="removeSourceBranch" - :disabled="isRemoveSourceBranchButtonDisabled" - class="js-remove-source-branch-checkbox" - type="checkbox" - /> - {{ __('Delete source branch') }} - </label> - - <!-- Placeholder for EE extension of this component --> - <squash-before-merge - v-if="shouldShowSquashBeforeMerge" - v-model="squashBeforeMerge" - :help-path="mr.squashBeforeMergeHelpPath" - :is-disabled="isSquashReadOnly" - /> - </template> - <template v-else> - <div class="bold js-resolve-mr-widget-items-message"> - <div - v-if="hasPipelineMustSucceedConflict" - class="gl-display-flex gl-align-items-center" - data-testid="pipeline-succeed-conflict" + <div + v-if="shouldShowMergeControls" + class="gl-display-flex gl-align-items-center gl-flex-wrap" + > + <gl-form-checkbox + v-if="canRemoveSourceBranch" + id="remove-source-branch-input" + v-model="removeSourceBranch" + :disabled="isRemoveSourceBranchButtonDisabled" + class="js-remove-source-branch-checkbox gl-mx-3 gl-display-flex gl-align-items-center" + > + {{ __('Delete source branch') }} + </gl-form-checkbox> + + <!-- Placeholder for EE extension of this component --> + <squash-before-merge + v-if="shouldShowSquashBeforeMerge" + v-model="squashBeforeMerge" + :help-path="mr.squashBeforeMergeHelpPath" + :is-disabled="isSquashReadOnly" + class="gl-mx-3" + /> + </div> + <template v-else> + <div class="bold js-resolve-mr-widget-items-message gl-ml-3"> + <div + v-if="hasPipelineMustSucceedConflict" + class="gl-display-flex gl-align-items-center" + data-testid="pipeline-succeed-conflict" + > + <gl-sprintf :message="pipelineMustSucceedConflictText" /> + <gl-link + :href="mr.pipelineMustSucceedDocsPath" + target="_blank" + class="gl-display-flex gl-ml-2" > - <gl-sprintf :message="pipelineMustSucceedConflictText" /> - <gl-link - :href="mr.pipelineMustSucceedDocsPath" - target="_blank" - class="gl-display-flex gl-ml-2" - > - <gl-icon name="question" /> - </gl-link> - </div> - <gl-sprintf v-else :message="mergeDisabledText" /> + <gl-icon name="question" /> + </gl-link> </div> - </template> - </div> + <gl-sprintf v-else :message="mergeDisabledText" /> + </div> + </template> </div> <div v-if="isSHAMismatch" class="d-flex align-items-center mt-2 js-sha-mismatch"> <gl-icon name="warning-solid" class="text-warning mr-1" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index 12fdfe601a4..6388b817e46 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -44,7 +44,7 @@ export default { :checked="value" :disabled="isDisabled" name="squash" - class="qa-squash-checkbox js-squash-checkbox gl-mb-0 gl-mr-2" + class="qa-squash-checkbox js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center" :title="tooltipTitle" @change="(checked) => $emit('input', checked)" > 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 af305815381..1a549d5ee6f 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 @@ -70,7 +70,7 @@ export default { data: { mergeRequestSetWip: { errors, - mergeRequest: { workInProgress, title }, + mergeRequest: { mergeableDiscussionsState, workInProgress, title }, }, }, }, @@ -88,6 +88,8 @@ export default { const data = produce(sourceData, (draftState) => { // eslint-disable-next-line no-param-reassign + draftState.project.mergeRequest.mergeableDiscussionsState = mergeableDiscussionsState; + // eslint-disable-next-line no-param-reassign draftState.project.mergeRequest.workInProgress = workInProgress; // eslint-disable-next-line no-param-reassign draftState.project.mergeRequest.title = title; @@ -107,6 +109,7 @@ export default { errors: [], mergeRequest: { __typename: 'MergeRequest', + mergeableDiscussionsState: true, title: this.mr.title, workInProgress: false, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js index 96e8bb45e34..7b77d7475bc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js @@ -7,9 +7,4 @@ export default { return []; }, }, - methods: { - hasDownstream() { - return false; - }, - }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql index 37abe5ddf3c..cfaa198d516 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql @@ -1,6 +1,7 @@ mutation toggleWIPStatus($projectPath: ID!, $iid: String!, $wip: Boolean!) { mergeRequestSetWip(input: { projectPath: $projectPath, iid: $iid, wip: $wip }) { mergeRequest { + mergeableDiscussionsState title workInProgress } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index a0f14f558d2..9e0fc10f5d3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -177,7 +177,7 @@ export default class MergeRequestStore { this.ciStatus = `${this.ciStatus}-with-warnings`; } - this.commitsCount = mergeRequest.commitCount || 10; + this.commitsCount = mergeRequest.commitCount; this.branchMissing = !mergeRequest.sourceBranchExists || !mergeRequest.targetBranchExists; this.hasConflicts = mergeRequest.conflicts; this.hasMergeableDiscussionsState = mergeRequest.mergeableDiscussionsState === false; diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index 0af5d028a2a..f7b49a85b83 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -11,12 +11,12 @@ import { GlButton, GlSafeHtmlDirective, } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import { fetchPolicies } from '~/lib/graphql'; import { toggleContainerClasses } from '~/lib/utils/dom_utils'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; -import * as Sentry from '~/sentry/wrapper'; import Tracking from '~/tracking'; import initUserPopovers from '~/user_popovers'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; @@ -222,7 +222,9 @@ export default { }); }, incidentPath(issueId) { - return joinPaths(this.projectIssuesPath, issueId); + return this.isThreatMonitoringPage + ? joinPaths(this.projectIssuesPath, issueId) + : joinPaths(this.projectIssuesPath, 'incident', issueId); }, trackPageViews() { const { category, action } = this.trackAlertsDetailsViewsOptions; @@ -268,10 +270,10 @@ export default { </span> </div> <gl-button - v-if="alert.issueIid" + v-if="alert.issue" class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-incident-button" data-testid="viewIncidentBtn" - :href="incidentPath(alert.issueIid)" + :href="incidentPath(alert.issue.iid)" category="primary" variant="success" > diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue index dd4faa03c00..9d5006564ef 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue @@ -1,7 +1,7 @@ <script> +import * as Sentry from '@sentry/browser'; import Vue from 'vue'; import Vuex from 'vuex'; -import * as Sentry from '~/sentry/wrapper'; Vue.use(Vuex); diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue index ce67d33d4a1..82b3545117f 100644 --- a/app/assets/javascripts/vue_shared/components/awards_list.vue +++ b/app/assets/javascripts/vue_shared/components/awards_list.vue @@ -2,7 +2,9 @@ /* eslint-disable vue/no-v-html */ import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { groupBy } from 'lodash'; +import EmojiPicker from '~/emoji/components/picker.vue'; import { __, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { glEmojiTag } from '../../emoji'; // Internal constant, specific to this component, used when no `currentUserId` is given @@ -12,10 +14,12 @@ export default { components: { GlButton, GlIcon, + EmojiPicker, }, directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { awards: { type: Array, @@ -166,7 +170,25 @@ export default { <span class="js-counter">{{ awardList.list.length }}</span> </gl-button> <div v-if="canAwardEmoji" class="award-menu-holder"> + <emoji-picker + v-if="glFeatures.improvedEmojiPicker" + toggle-class="add-reaction-button gl-relative!" + @click="handleAward" + > + <template #button-content> + <span class="reaction-control-icon reaction-control-icon-neutral"> + <gl-icon name="slight-smile" /> + </span> + <span class="reaction-control-icon reaction-control-icon-positive"> + <gl-icon name="smiley" /> + </span> + <span class="reaction-control-icon reaction-control-icon-super-positive"> + <gl-icon name="smile" /> + </span> + </template> + </emoji-picker> <gl-button + v-else v-gl-tooltip.viewport :class="addButtonClass" class="add-reaction-button js-add-award" diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 14e99977a85..4b53f55b856 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -82,7 +82,13 @@ export default { data-qa-selector="changed_file_icon_content" :data-qa-title="tooltipTitle" > - <gl-icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" /> + <gl-icon + v-if="showIcon" + :name="changedIcon" + :size="size" + :class="changedIconClass" + use-deprecated-sizes + /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 07bd6019b80..dbf459cb289 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -64,6 +64,12 @@ export default { </script> <template> <span :class="cssClass"> - <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" /> + <gl-icon + :name="icon" + :size="size" + :class="cssClasses" + :aria-label="status.icon" + use-deprecated-sizes + /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index bf1361f1a6a..a7699d19872 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -46,6 +46,11 @@ export default { required: false, default: false, }, + tooltipBoundary: { + type: String, + required: false, + default: null, + }, cssClass: { type: String, required: false, @@ -75,8 +80,11 @@ export default { <template> <gl-button - v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }" - v-gl-tooltip.hover.blur + v-gl-tooltip.hover.blur="{ + placement: tooltipPlacement, + container: tooltipContainer, + boundary: tooltipBoundary, + }" :class="cssClass" :title="title" :data-clipboard-text="clipboardText" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue index d6f99e9a049..b3edd05b0ee 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/renamed.vue @@ -37,9 +37,11 @@ export default { required: true, }, }, - data: () => ({ - state: STATE_IDLING, - }), + data() { + return { + state: STATE_IDLING, + }; + }, computed: { shortSha() { return truncateSha(this.diffFile.content_sha); diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index 8ac8a3beb7d..4244cab902a 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -86,7 +86,7 @@ export default { <template> <span> <gl-loading-icon v-if="loading" :inline="true" /> - <gl-icon v-else-if="isSymlink" name="symlink" :size="size" /> + <gl-icon v-else-if="isSymlink" name="symlink" :size="size" use-deprecated-sizes /> <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]"> <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> @@ -95,6 +95,7 @@ export default { :name="folderIconName" :size="size" class="folder-icon" + use-deprecated-sizes data-qa-selector="folder_icon_content" /> </span> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 80ca62a0e9b..b4cac13168a 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -102,7 +102,7 @@ export default { data-qa-selector="pipeline_header" data-testid="ci-header-content" > - <section class="header-main-content"> + <section class="header-main-content gl-mr-3"> <ci-icon-badge :status="status" /> <strong data-testid="ci-header-item-text"> {{ itemName }} #{{ itemId }} </strong> @@ -142,12 +142,16 @@ export default { </template> </section> - <section v-if="$slots.default" data-testid="ci-header-action-buttons" class="gl-display-flex"> + <section + v-if="$slots.default" + data-testid="ci-header-action-buttons" + class="gl-display-flex gl-mr-3" + > <slot></slot> </section> <gl-button v-if="hasSidebarButton" - class="d-sm-none js-sidebar-build-toggle gl-ml-auto" + class="gl-md-display-none gl-ml-auto gl-align-self-start js-sidebar-build-toggle" icon="chevron-double-lg-left" :aria-label="__('Toggle sidebar')" @click="onClickSidebarButton" 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 4c6fa71398d..e7e1a17cbe5 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 @@ -62,9 +62,6 @@ export default { canBeBatched() { return Boolean(this.glFeatures.batchSuggestions); }, - canAddCustomCommitMessage() { - return this.glFeatures.suggestionsCustomCommit; - }, isApplying() { return this.isApplyingSingle || this.isApplyingBatch; }, @@ -89,11 +86,7 @@ export default { if (!this.canApply) return; this.isApplyingSingle = true; - this.$emit( - 'apply', - this.applySuggestionCallback, - gon.features?.suggestionsCustomCommit ? message : undefined, - ); + this.$emit('apply', this.applySuggestionCallback, message); }, applySuggestionCallback() { this.isApplyingSingle = false; @@ -158,23 +151,12 @@ export default { {{ __('Add suggestion to batch') }} </gl-button> <apply-suggestion - v-if="canAddCustomCommitMessage" + v-if="isLoggedIn" :disabled="isDisableButton" :default-commit-message="defaultCommitMessage" class="gl-ml-3" @apply="applySuggestion" /> - <span v-else v-gl-tooltip.viewport="tooltipMessage" tabindex="0"> - <gl-button - v-if="isLoggedIn" - class="btn-inverted js-apply-btn btn-grouped" - :disabled="isDisableButton" - variant="success" - @click="applySuggestion" - > - {{ __('Apply suggestion') }} - </gl-button> - </span> </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue new file mode 100644 index 00000000000..35f9ac14681 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/notes/timeline_icon.vue @@ -0,0 +1,3 @@ +<template> + <div class="timeline-icon"><slot></slot></div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue b/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue index bc7f8a2b17a..1a85a641dd1 100644 --- a/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue +++ b/app/assets/javascripts/vue_shared/components/registry/code_instruction.vue @@ -56,27 +56,29 @@ export default { </script> <template> - <div v-if="!multiline" class="gl-mb-3"> + <div> <label v-if="label" :for="generateFormId('instruction-input')">{{ label }}</label> - <div class="input-group gl-mb-3"> - <input - :id="generateFormId('instruction-input')" - :value="instruction" - type="text" - class="form-control gl-font-monospace" - data-testid="instruction-input" - readonly - @copy="trackCopy" - /> - <span class="input-group-append" data-testid="instruction-button" @click="trackCopy"> - <clipboard-button :text="instruction" :title="copyText" class="input-group-text" /> - </span> + <div v-if="!multiline" class="gl-mb-3"> + <div class="input-group gl-mb-3"> + <input + :id="generateFormId('instruction-input')" + :value="instruction" + type="text" + class="form-control gl-font-monospace" + data-testid="instruction-input" + readonly + @copy="trackCopy" + /> + <span class="input-group-append" data-testid="instruction-button" @click="trackCopy"> + <clipboard-button :text="instruction" :title="copyText" class="input-group-text" /> + </span> + </div> </div> - </div> - <div v-else> - <pre class="gl-font-monospace" data-testid="multiline-instruction" @copy="trackCopy">{{ - instruction - }}</pre> + <div v-else> + <pre class="gl-font-monospace" data-testid="multiline-instruction" @copy="trackCopy">{{ + instruction + }}</pre> + </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/registry/list_item.vue b/app/assets/javascripts/vue_shared/components/registry/list_item.vue index 9db5d6953d7..4ade75e705e 100644 --- a/app/assets/javascripts/vue_shared/components/registry/list_item.vue +++ b/app/assets/javascripts/vue_shared/components/registry/list_item.vue @@ -54,7 +54,7 @@ export default { class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1" :class="optionalClasses" > - <div class="gl-display-flex gl-align-items-center gl-py-3"> + <div class="gl-display-flex gl-align-items-center gl-py-3 gl-px-5"> <div v-if="$slots['left-action']" class="gl-w-7 gl-display-none gl-sm-display-flex gl-justify-content-start gl-pl-2" diff --git a/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue new file mode 100644 index 00000000000..36b1a9c49f4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/registry/persisted_dropdown_selection.vue @@ -0,0 +1,59 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; + +export default { + name: 'PersistedDropdownSelection', + components: { + GlDropdown, + GlDropdownItem, + LocalStorageSync, + }, + props: { + options: { + type: Array, + required: true, + }, + storageKey: { + type: String, + required: true, + }, + }, + data() { + return { + selected: null, + }; + }, + computed: { + dropdownText() { + const selected = this.parsedOptions.find((o) => o.selected); + return selected?.label || this.options[0].label; + }, + parsedOptions() { + return this.options.map((o) => ({ ...o, selected: o.value === this.selected })); + }, + }, + methods: { + setSelected(value) { + this.selected = value; + this.$emit('change', value); + }, + }, +}; +</script> + +<template> + <local-storage-sync :storage-key="storageKey" :value="selected" @input="setSelected"> + <gl-dropdown :text="dropdownText" lazy> + <gl-dropdown-item + v-for="option in parsedOptions" + :key="option.value" + :is-checked="option.selected" + :is-check-item="true" + @click="setSelected(option.value)" + > + {{ option.label }} + </gl-dropdown-item> + </gl-dropdown> + </local-storage-sync> +</template> diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue index 6574a5ddfde..bb1a8fae7b0 100644 --- a/app/assets/javascripts/vue_shared/components/select2_select.vue +++ b/app/assets/javascripts/vue_shared/components/select2_select.vue @@ -20,6 +20,12 @@ export default { }, }, + watch: { + value() { + $(this.$refs.dropdownInput).val(this.value).trigger('change'); + }, + }, + mounted() { loadCSSFile(gon.select2_css_path) .then(() => { diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue index 31094b985a2..92ae4575c52 100644 --- a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue +++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue @@ -5,6 +5,11 @@ import { __ } from '~/locale'; export default { components: { GlButton }, props: { + slideAnimated: { + type: Boolean, + default: true, + required: false, + }, defaultExpanded: { type: Boolean, default: false, @@ -28,7 +33,7 @@ export default { </script> <template> - <section class="settings no-animate" :class="{ expanded }"> + <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded }"> <div class="settings-header"> <h4><slot name="title"></slot></h4> <gl-button @click="sectionExpanded = !sectionExpanded"> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue index f173c8db540..46ccb9470e5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue @@ -21,11 +21,14 @@ export default { 'allowLabelRemove', 'allowScopedLabels', 'labelsFilterBasePath', + 'labelsFilterParam', ]), }, methods: { labelFilterUrl(label) { - return `${this.labelsFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`; + return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent( + label.title, + )}`; }, scopedLabel(label) { return this.allowScopedLabels && isScopedLabel(label); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 93fdae19a8d..426ae430ce7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -81,6 +81,11 @@ export default { required: false, default: '', }, + labelsFilterParam: { + type: String, + required: false, + default: 'label_name', + }, dropdownButtonText: { type: String, required: false, @@ -156,6 +161,7 @@ export default { labelsFetchPath: this.labelsFetchPath, labelsManagePath: this.labelsManagePath, labelsFilterBasePath: this.labelsFilterBasePath, + labelsFilterParam: this.labelsFilterParam, labelsListTitle: this.labelsListTitle, labelsCreateTitle: this.labelsCreateTitle, footerCreateLabelTitle: this.footerCreateLabelTitle, diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue index 132abcab82b..ef5f052527b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue @@ -1,10 +1,11 @@ <script> -import { GlDropdown, GlDropdownForm } from '@gitlab/ui'; +import { GlDropdown, GlDropdownForm, GlDropdownDivider } from '@gitlab/ui'; export default { components: { GlDropdownForm, GlDropdown, + GlDropdownDivider, }, props: { headerText: { @@ -20,8 +21,12 @@ export default { </script> <template> - <gl-dropdown class="show" :text="text" :header-text="headerText" @toggle="$emit('toggle')"> - <slot name="search"></slot> + <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')"> + <template #header> + <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p> + <gl-dropdown-divider /> + <slot name="search"></slot> + </template> <gl-dropdown-form> <slot name="items"></slot> </gl-dropdown-form> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql index 62c0b05426b..459ea27e9cd 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -1,8 +1,10 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" query issueParticipants($fullPath: ID!, $iid: String!) { - project(fullPath: $fullPath) { + workspace: project(fullPath: $fullPath) { + __typename issuable: issue(iid: $iid) { + __typename id participants { nodes { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql index a75ce85a1dc..43bd9f17e9a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" query getMrParticipants($fullPath: ID!, $iid: String!) { - project(fullPath: $fullPath) { + workspace: project(fullPath: $fullPath) { issuable: mergeRequest(iid: $iid) { id participants { diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql index 2eb9bb4b07b..8ee8de2cb5c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql @@ -1,10 +1,10 @@ #import "~/graphql_shared/fragments/user.fragment.graphql" mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { - issueSetAssignees( + issuableSetAssignees: issueSetAssignees( input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath } ) { - issue { + issuable: issue { id assignees { nodes { diff --git a/app/assets/javascripts/vue_shared/components/tabs/tab.vue b/app/assets/javascripts/vue_shared/components/tabs/tab.vue deleted file mode 100644 index d24c27cfcc3..00000000000 --- a/app/assets/javascripts/vue_shared/components/tabs/tab.vue +++ /dev/null @@ -1,47 +0,0 @@ -<script> -export default { - props: { - title: { - type: String, - required: false, - default: '', - }, - active: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - // props can't be updated, so we map it to data where we can - localActive: this.active, - }; - }, - watch: { - active() { - this.localActive = this.active; - }, - }, - created() { - this.isTab = true; - }, - updated() { - if (this.$parent) { - this.$parent.$forceUpdate(); - } - }, -}; -</script> - -<template> - <div - :class="{ - active: localActive, - }" - class="tab-pane" - role="tabpanel" - > - <slot></slot> - </div> -</template> diff --git a/app/assets/javascripts/vue_shared/components/tabs/tabs.js b/app/assets/javascripts/vue_shared/components/tabs/tabs.js deleted file mode 100644 index 233df96a520..00000000000 --- a/app/assets/javascripts/vue_shared/components/tabs/tabs.js +++ /dev/null @@ -1,76 +0,0 @@ -export default { - props: { - stopPropagation: { - type: Boolean, - required: false, - default: false, - }, - }, - data() { - return { - currentIndex: 0, - tabs: [], - }; - }, - mounted() { - this.updateTabs(); - }, - methods: { - updateTabs() { - this.tabs = this.$children.filter((child) => child.isTab); - this.currentIndex = this.tabs.findIndex((tab) => tab.localActive); - }, - setTab(e, index) { - if (this.stopPropagation) { - e.stopPropagation(); - e.preventDefault(); - } - - this.tabs[this.currentIndex].localActive = false; - this.tabs[index].localActive = true; - - this.currentIndex = index; - }, - }, - render(h) { - const navItems = this.tabs.map((tab, i) => - h( - 'li', - { - key: i, - }, - [ - h( - 'a', - { - class: tab.localActive ? 'active' : null, - attrs: { - href: '#', - }, - on: { - click: (e) => this.setTab(e, i), - }, - }, - tab.$slots.title || tab.title, - ), - ], - ), - ); - const nav = h( - 'ul', - { - class: 'nav-links tab-links', - }, - [navItems], - ); - const content = h( - 'div', - { - class: ['tab-content'], - }, - [this.$slots.default], - ); - - return h('div', {}, [[nav], content]); - }, -}; diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue index 8aa6e29adf1..c5fdb5fc242 100644 --- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue +++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue @@ -1,11 +1,11 @@ <script> +import { GlTooltipDirective as GlTooltip } from '@gitlab/ui'; import { isFunction } from 'lodash'; import { hasHorizontalOverflow } from '~/lib/utils/dom_utils'; -import tooltip from '../directives/tooltip'; export default { directives: { - tooltip, + GlTooltip, }, props: { title: { @@ -59,9 +59,8 @@ export default { <template> <span v-if="showTooltip" - v-tooltip + v-gl-tooltip="{ placement }" :title="title" - :data-placement="placement" class="js-show-tooltip gl-min-w-0" > <slot></slot> diff --git a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue index 5a08e992084..afb1ea702fa 100644 --- a/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue +++ b/app/assets/javascripts/vue_shared/components/upload_dropzone/upload_dropzone.vue @@ -36,6 +36,11 @@ export default { required: false, default: () => [VALID_IMAGE_FILE_MIMETYPE.mimetype], }, + singleFileSelection: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -79,7 +84,7 @@ export default { return; } - this.$emit('change', files); + this.$emit('change', this.singleFileSelection ? files[0] : files); }, ondragenter(e) { this.dragCounter += 1; @@ -92,7 +97,7 @@ export default { this.$refs.fileUpload.click(); }, onFileInputChange(e) { - this.$emit('change', e.target.files); + this.$emit('change', this.singleFileSelection ? e.target.files[0] : e.target.files); }, }, }; @@ -119,9 +124,15 @@ export default { data-testid="dropzone-area" > <gl-icon name="upload" :size="iconStyles.size" :class="iconStyles.class" /> - <p class="gl-mb-0"> + <p class="gl-mb-0" data-testid="upload-text"> <slot name="upload-text" :openFileUpload="openFileUpload"> - <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} files to attach')"> + <gl-sprintf + :message=" + singleFileSelection + ? __('Drop or %{linkStart}upload%{linkEnd} file to attach') + : __('Drop or %{linkStart}upload%{linkEnd} files to attach') + " + > <template #link="{ content }"> <gl-link @click.stop="openFileUpload"> {{ content }} @@ -139,7 +150,7 @@ export default { name="upload_file" :accept="validFileMimetypes" class="hide" - multiple + :multiple="!singleFileSelection" @change="onFileInputChange" /> </slot> diff --git a/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue b/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue new file mode 100644 index 00000000000..e5558c038b3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/user_access_role_badge.vue @@ -0,0 +1,22 @@ +<script> +/** + * This component applies particular styling to GlBadge that isn't + * available in the current GlBadge variants. + * Where possible, prefer one of the supported GlBadge variants. + * Discussion issue: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1247 + */ +import { GlBadge } from '@gitlab/ui'; + +export default { + name: 'UserAccessRoleBadge', + components: { + GlBadge, + }, +}; +</script> + +<template> + <gl-badge class="gl-bg-transparent! gl-inset-border-1-gray-100!"> + <slot></slot> + </gl-badge> +</template> diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js deleted file mode 100644 index 0eb505bfce8..00000000000 --- a/app/assets/javascripts/vue_shared/directives/tooltip.js +++ /dev/null @@ -1,35 +0,0 @@ -import $ from 'jquery'; -import '~/commons/bootstrap'; -import { parseBoolean } from '~/lib/utils/common_utils'; - -export default { - bind(el) { - const glTooltipDelay = localStorage.getItem('gl-tooltip-delay'); - const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0; - - $(el).tooltip({ - trigger: 'hover', - delay, - // By default, sanitize is run even if there is no `html` or `template` present - // so let's optimize to only run this when necessary. - // https://github.com/twbs/bootstrap/blob/c5966de27395a407f9a3d20d0eb2ff8e8fb7b564/js/src/tooltip.js#L716 - sanitize: parseBoolean(el.dataset.html) || Boolean(el.dataset.template), - }); - }, - - componentUpdated(el) { - $(el).tooltip('_fixTitle'); - - // update visible tooltips - const tooltipInstance = $(el).data('bs.tooltip'); - const tip = tooltipInstance.getTipElement(); - tooltipInstance.setElementContent( - $(tip.querySelectorAll('.tooltip-inner')), - tooltipInstance.getTitle(), - ); - }, - - unbind(el) { - $(el).tooltip('dispose'); - }, -}; diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js index e1734809bce..c12ffaac40a 100644 --- a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js +++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js @@ -1,7 +1,12 @@ export default (Vue) => { Vue.mixin({ provide: { - glFeatures: { ...((window.gon && window.gon.features) || {}) }, + glFeatures: + { + ...window.gon?.features, + // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460 + ...window.gon?.licensed_features, + } || {}, }, }); }; diff --git a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue index 3c606283c7d..26bc9b5d60e 100644 --- a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue +++ b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue @@ -34,7 +34,7 @@ export default { <span v-if="discoverProjectSecurityPath"> <gl-button ref="discoverProjectSecurity" - icon="information-o" + icon="question-o" category="tertiary" :aria-label="$options.i18n.upgradeToManageVulnerabilities" /> diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js index aac5a5c1def..1cdcf87097f 100644 --- a/app/assets/javascripts/vue_shared/security_reports/constants.js +++ b/app/assets/javascripts/vue_shared/security_reports/constants.js @@ -18,11 +18,12 @@ export const REPORT_FILE_TYPES = { */ export const REPORT_TYPE_SAST = 'sast'; export const REPORT_TYPE_DAST = 'dast'; +export const REPORT_TYPE_DAST_PROFILES = 'dast_profiles'; export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection'; export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning'; export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning'; export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing'; -export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_compliance'; +export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning'; export const REPORT_TYPE_API_FUZZING = 'api_fuzzing'; /** |