diff options
98 files changed, 1531 insertions, 254 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 9e08a257abf..f9d48708473 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -82,6 +82,16 @@ export default { required: false, default: true, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { @@ -109,6 +119,8 @@ export default { autofocus, drawioEnabled, editable, + enableAutocomplete, + autocompleteDataSources, } = this; // This is a non-reactive attribute intentionally since this is a complex object. @@ -118,6 +130,8 @@ export default { extensions, serializerConfig, drawioEnabled, + enableAutocomplete, + autocompleteDataSources, tiptapOptions: { autofocus, editable, diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index 3c19de55ea2..e72b5c7365c 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -120,12 +120,18 @@ function createSuggestionPlugin({ export default Node.create({ name: 'suggestions', + addOptions() { + return { + autocompleteDataSources: {}, + }; + }, + addProseMirrorPlugins() { return [ createSuggestionPlugin({ editor: this.editor, char: '@', - dataSource: gl.GfmAutoComplete?.dataSources.members, + dataSource: this.options.autocompleteDataSources.members, nodeType: 'reference', nodeProps: { referenceType: 'user', @@ -135,7 +141,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '#', - dataSource: gl.GfmAutoComplete?.dataSources.issues, + dataSource: this.options.autocompleteDataSources.issues, nodeType: 'reference', nodeProps: { referenceType: 'issue', @@ -145,7 +151,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '$', - dataSource: gl.GfmAutoComplete?.dataSources.snippets, + dataSource: this.options.autocompleteDataSources.snippets, nodeType: 'reference', nodeProps: { referenceType: 'snippet', @@ -155,7 +161,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '~', - dataSource: gl.GfmAutoComplete?.dataSources.labels, + dataSource: this.options.autocompleteDataSources.labels, nodeType: 'reference_label', nodeProps: { referenceType: 'label', @@ -165,7 +171,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '&', - dataSource: gl.GfmAutoComplete?.dataSources.epics, + dataSource: this.options.autocompleteDataSources.epics, nodeType: 'reference', nodeProps: { referenceType: 'epic', @@ -175,7 +181,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '[vulnerability:', - dataSource: gl.GfmAutoComplete?.dataSources.vulnerabilities, + dataSource: this.options.autocompleteDataSources.vulnerabilities, nodeType: 'reference', nodeProps: { referenceType: 'vulnerability', @@ -185,7 +191,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '!', - dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests, + dataSource: this.options.autocompleteDataSources.mergeRequests, nodeType: 'reference', nodeProps: { referenceType: 'merge_request', @@ -195,7 +201,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '%', - dataSource: gl.GfmAutoComplete?.dataSources.milestones, + dataSource: this.options.autocompleteDataSources.milestones, nodeType: 'reference', nodeProps: { referenceType: 'milestone', @@ -205,7 +211,7 @@ export default Node.create({ createSuggestionPlugin({ editor: this.editor, char: '/', - dataSource: gl.GfmAutoComplete?.dataSources.commands, + dataSource: this.options.autocompleteDataSources.commands, nodeType: 'reference', nodeProps: { referenceType: 'command', diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 9d536793287..f1d4f85dcb0 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -88,6 +88,8 @@ export const createContentEditor = ({ serializerConfig = { marks: {}, nodes: {} }, tiptapOptions, drawioEnabled = false, + enableAutocomplete, + autocompleteDataSources = {}, } = {}) => { if (!isFunction(renderMarkdown)) { throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); @@ -144,7 +146,6 @@ export const createContentEditor = ({ Sourcemap, Strike, Subscript, - Suggestions, Superscript, TableCell, TableHeader, @@ -160,6 +161,7 @@ export const createContentEditor = ({ const allExtensions = [...builtInContentEditorExtensions, ...extensions]; + if (enableAutocomplete) allExtensions.push(Suggestions.configure({ autocompleteDataSources })); if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown })); const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts); diff --git a/app/assets/javascripts/issuable/issuable_form.js b/app/assets/javascripts/issuable/issuable_form.js index ebcb47f056b..adcf2f536e6 100644 --- a/app/assets/javascripts/issuable/issuable_form.js +++ b/app/assets/javascripts/issuable/issuable_form.js @@ -11,7 +11,7 @@ const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; const MR_TARGET_BRANCH = 'merge_request[target_branch]'; const DATA_ISSUES_NEW_PATH = 'data-new-issue-path'; -function organizeQuery(obj, isFallbackKey = false) { +export function organizeQuery(obj, isFallbackKey = false) { if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) { return obj; } @@ -83,11 +83,8 @@ export default class IssuableForm { this.searchTerm = getSearchTerm(form[0].getAttribute(DATA_ISSUES_NEW_PATH)); this.fallbackKey = getFallbackKey(); this.titleField = this.form.find('input[name*="[title]"]'); - this.descriptionField = this.form.find('textarea[name*="[description]"]'); this.draftCheck = document.querySelector('input.js-toggle-draft'); - if (!(this.titleField.length && this.descriptionField.length)) { - return; - } + if (!this.titleField.length) return; this.autosaves = this.initAutosave(); this.form.on('submit', this.handleSubmit); @@ -125,13 +122,6 @@ export default class IssuableForm { ); IssuableForm.addAutosave( autosaveMap, - 'description', - this.form.find('textarea[name*="[description]"]').get(0), - this.searchTerm, - this.fallbackKey, - ); - IssuableForm.addAutosave( - autosaveMap, 'confidential', this.form.find('input:checkbox[name*="[confidential]"]').get(0), this.searchTerm, diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index 3599a2660cc..1fa292d4ab7 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -5,7 +5,6 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; -import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import { TYPE_INCIDENT } from '~/issues/constants'; import Issue from '~/issues/issue'; import { initTitleSuggestions, initTypePopover, initTypeSelect } from '~/issues/new'; @@ -41,7 +40,6 @@ export function initForm() { new GLForm($('.issue-form')); // eslint-disable-line no-new new IssuableForm($('.issue-form')); // eslint-disable-line no-new IssuableLabelSelector(); - new IssuableTemplateSelectors({ warnTemplateOverride: true }); // eslint-disable-line no-new new LabelsSelect(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index d06358aaef4..e0257eb15b3 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -82,6 +82,9 @@ export default { 'hasDrafts', ]), ...mapState(['isToggleStateButtonLoading']), + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, noteableDisplayName() { const displayNameMap = { [constants.ISSUE_NOTEABLE_TYPE]: this.$options.i18n.issue, @@ -371,6 +374,7 @@ export default { :form-field-props="formFieldProps" :autosave-key="autosaveKey" :disabled="isSubmitting" + :autocomplete-data-sources="autocompleteDataSources" supports-quick-actions @keydown.up="editCurrentUserLastNote()" @keydown.meta.enter="handleEnter()" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index c2dee4f1bea..42e1577e663 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -125,6 +125,9 @@ export default { withBatchComments: (state) => state.batchComments?.withBatchComments, }), ...mapGetters('batchComments', ['hasDrafts']), + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, showBatchCommentsActions() { return this.withBatchComments && this.noteId === '' && !this.discussion.for_commit; }, @@ -352,6 +355,7 @@ export default { :show-suggest-popover="showSuggestPopover" :quick-actions-docs-path="quickActionsDocsPath" :autosave-key="autosaveKey" + :autocomplete-data-sources="autocompleteDataSources" :disabled="isSubmitting" supports-quick-actions autofocus diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js index 9d0ecfd2dcb..71538ea5a07 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js @@ -1,14 +1,10 @@ import { s__ } from '~/locale'; -export const EXPIRATION_POLICY_WILL_RUN_IN = s__( - 'ContainerRegistry|Expiration policy will run in %{time}', -); -export const EXPIRATION_POLICY_DISABLED_TEXT = s__( - 'ContainerRegistry|Expiration policy is disabled.', -); +export const EXPIRATION_POLICY_WILL_RUN_IN = s__('ContainerRegistry|Cleanup will run in %{time}'); +export const EXPIRATION_POLICY_DISABLED_TEXT = s__('ContainerRegistry|Cleanup is not scheduled.'); 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}', + 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}run cleanup now manually%{adminLinkEnd} or you can wait for the next scheduled run of the cleanup policy. %{docLinkStart}More information%{docLinkEnd}', ); export const PARTIAL_CLEANUP_CONTINUE_MESSAGE = s__( 'ContainerRegistry|The cleanup will continue within %{time}. %{linkStart}Learn more%{linkEnd}', diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue index 7a9ea7c0bf7..35fc0910a16 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue +++ b/app/assets/javascripts/packages_and_registries/settings/project/components/expiration_toggle.vue @@ -8,7 +8,7 @@ import { export default { i18n: { - toggleLabel: s__('ContainerRegistry|Enable expiration policy'), + toggleLabel: s__('ContainerRegistry|Enable cleanup policy'), }, components: { GlFormGroup, diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index 731fb3e4c45..5f59372e5ba 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -1,6 +1,6 @@ import { s__, __ } from '~/locale'; -export const CONTAINER_CLEANUP_POLICY_TITLE = s__(`ContainerRegistry|Clean up image tags`); +export const CONTAINER_CLEANUP_POLICY_TITLE = s__('ContainerRegistry|Cleanup policies'); export const CONTAINER_CLEANUP_POLICY_DESCRIPTION = s__( `ContainerRegistry|Save storage space by automatically deleting tags from the container registry and keeping the ones you want. %{linkStart}How does cleanup work?%{linkEnd}`, ); diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js index 06dcd2c2d94..c5b63b74c35 100644 --- a/app/assets/javascripts/pages/projects/issues/edit/index.js +++ b/app/assets/javascripts/pages/projects/issues/edit/index.js @@ -1,3 +1,8 @@ import { initForm } from 'ee_else_ce/issues'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; initForm(); + +// eslint-disable-next-line no-new +new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js index 06dcd2c2d94..c5b63b74c35 100644 --- a/app/assets/javascripts/pages/projects/issues/new/index.js +++ b/app/assets/javascripts/pages/projects/issues/new/index.js @@ -1,3 +1,8 @@ import { initForm } from 'ee_else_ce/issues'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; initForm(); + +// eslint-disable-next-line no-new +new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js index 2718765ee23..3d81e77f879 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/creations/new/index.js @@ -3,6 +3,8 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle'; import MergeRequest from '~/merge_request'; import CompareApp from '~/merge_requests/components/compare_app.vue'; import { __ } from '~/locale'; +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; const mrNewCompareNode = document.querySelector('.js-merge-request-new-compare'); if (mrNewCompareNode) { @@ -82,4 +84,6 @@ if (mrNewCompareNode) { action: mrNewSubmitNode.dataset.mrSubmitAction, }); initPipelines(); + // eslint-disable-next-line no-new + new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); } 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 f8cb8b30250..6127adc3584 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/edit/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/edit/index.js @@ -1,10 +1,11 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; - +import { mountMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor'; import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown'; import initMergeRequest from '~/pages/projects/merge_requests/init_merge_request'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import initCheckFormState from './check_form_state'; import initFormUpdate from './update_form'; @@ -72,3 +73,5 @@ initMergeRequest(); initFormUpdate(); initCheckFormState(); initTargetBranchSelector(); +// eslint-disable-next-line no-new +new IssuableTemplateSelectors({ warnTemplateOverride: true, editor: mountMarkdownEditor() }); diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index d4734b8842d..0836cc44575 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -6,7 +6,6 @@ import IssuableLabelSelector from '~/issuable/issuable_label_selector'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import GLForm from '~/gl_form'; import LabelsSelect from '~/labels/labels_select'; -import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; export default () => { @@ -15,8 +14,5 @@ export default () => { new IssuableForm($('.merge-request-form')); IssuableLabelSelector(); new LabelsSelect(); - new IssuableTemplateSelectors({ - warnTemplateOverride: true, - }); mountMilestoneDropdown('[name="merge_request[milestone_id]"]'); }; diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 549c964cce4..3b38d715ea5 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -128,6 +128,9 @@ export default { }; }, computed: { + autocompleteDataSources() { + return gl.GfmAutoComplete?.dataSources; + }, noContent() { return !this.content.trim(); }, @@ -351,6 +354,8 @@ export default { :enable-content-editor="isMarkdownFormat" :enable-preview="isMarkdownFormat" :autofocus="pageInfo.persisted" + :enable-autocomplete="true" + :autocomplete-data-sources="autocompleteDataSources" :drawio-enabled="true" @contentEditor="notifyContentEditorActive" @markdownField="notifyContentEditorInactive" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue index dd62ffb27f7..ffb8c0eb88c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -1,10 +1,12 @@ <script> import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; import Tracking from '~/tracking'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '../../constants'; import PipelineMultiActions from './pipeline_multi_actions.vue'; import PipelinesManualActions from './pipelines_manual_actions.vue'; +import PipelinesManualActionsLegacy from './pipelines_manual_actions_legacy.vue'; export default { BUTTON_TOOLTIP_RETRY, @@ -17,8 +19,9 @@ export default { GlButton, PipelineMultiActions, PipelinesManualActions, + PipelinesManualActionsLegacy, }, - mixins: [Tracking.mixin()], + mixins: [Tracking.mixin(), glFeatureFlagsMixin()], props: { pipeline: { type: Object, @@ -36,6 +39,14 @@ export default { }; }, computed: { + shouldLazyLoadActions() { + return this.glFeatures.lazyLoadPipelineDropdownActions; + }, + hasActions() { + return ( + this.pipeline?.details?.has_manual_actions || this.pipeline?.details?.has_scheduled_actions + ); + }, actions() { if (!this.pipeline || !this.pipeline.details) { return []; @@ -75,7 +86,12 @@ export default { <template> <div class="gl-text-right"> <div class="btn-group"> - <pipelines-manual-actions v-if="actions.length > 0" :actions="actions" /> + <pipelines-manual-actions v-if="hasActions && shouldLazyLoadActions" :iid="pipeline.iid" /> + + <pipelines-manual-actions-legacy + v-if="actions.length > 0 && !shouldLazyLoadActions" + :actions="actions" + /> <gl-button v-if="pipeline.flags.retryable" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue index 3e2be7832a3..265efe77e75 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; @@ -8,8 +8,10 @@ import Tracking from '~/tracking'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import eventHub from '../../event_hub'; import { TRACKING_CATEGORIES } from '../../constants'; +import getPipelineActionsQuery from '../../graphql/queries/get_pipeline_actions.query.graphql'; export default { + name: 'PipelinesManualActions', directives: { GlTooltip: GlTooltipDirective, }, @@ -18,22 +20,52 @@ export default { GlDropdown, GlDropdownItem, GlIcon, + GlLoadingIcon, }, mixins: [Tracking.mixin()], + inject: ['fullPath', 'manualActionsLimit'], props: { - actions: { - type: Array, + iid: { + type: Number, required: true, }, }, + apollo: { + actions: { + query: getPipelineActionsQuery, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + limit: this.manualActionsLimit, + }; + }, + skip() { + return !this.hasDropdownBeenShown; + }, + update({ project }) { + return project?.pipeline?.jobs?.nodes || []; + }, + }, + }, data() { return { isLoading: false, + actions: [], + hasDropdownBeenShown: false, }; }, + computed: { + isActionsLoading() { + return this.$apollo.queries.actions.loading; + }, + isDropdownLimitReached() { + return this.actions.length === this.manualActionsLimit; + }, + }, methods: { async onClickAction(action) { - if (action.scheduled_at) { + if (action.scheduledAt) { const confirmationMessage = sprintf( s__( 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.', @@ -54,12 +86,12 @@ export default { * Ideally, the component would not make an api call directly. * However, in order to use the eventhub and know when to * toggle back the `isLoading` property we'd need an ID - * to track the request with a wacther - since this component + * to track the request with a watcher - since this component * is rendered at least 20 times in the same page, moving the * api call directly here is the most performant solution */ axios - .post(`${action.path}.json`) + .post(`${action.playPath}.json`) .then(() => { this.isLoading = false; eventHub.$emit('updateTable'); @@ -69,12 +101,12 @@ export default { createAlert({ message: __('An error occurred while making the request.') }); }); }, - isActionDisabled(action) { - if (action.playable === undefined) { - return false; - } + fetchActions() { + this.hasDropdownBeenShown = true; + + this.$apollo.queries.actions.refetch(); - return !action.playable; + this.trackClick(); }, trackClick() { this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table }); @@ -91,21 +123,37 @@ export default { right lazy icon="play" - @shown="trackClick" + @shown="fetchActions" > + <gl-dropdown-item v-if="isActionsLoading"> + <div class="gl-display-flex"> + <gl-loading-icon class="mr-2" /> + <span>{{ __('Loading...') }}</span> + </div> + </gl-dropdown-item> + <gl-dropdown-item v-for="action in actions" - :key="action.path" - :disabled="isActionDisabled(action)" + v-else + :key="action.id" + :disabled="!action.canPlayJob" @click="onClickAction(action)" > <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap-wrap"> {{ action.name }} - <span v-if="action.scheduled_at"> + <span v-if="action.scheduledAt"> <gl-icon name="clock" /> - <gl-countdown :end-date-string="action.scheduled_at" /> + <gl-countdown :end-date-string="action.scheduledAt" /> </span> </div> </gl-dropdown-item> + + <template #footer> + <gl-dropdown-item v-if="isDropdownLimitReached"> + <span class="gl-font-sm gl-text-gray-300!" data-testid="limit-reached-msg"> + {{ __('Showing first 50 actions.') }} + </span> + </gl-dropdown-item> + </template> </gl-dropdown> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions_legacy.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions_legacy.vue new file mode 100644 index 00000000000..b08eb4153ce --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_manual_actions_legacy.vue @@ -0,0 +1,112 @@ +<script> +import { GlDropdown, GlDropdownItem, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { s__, __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +import eventHub from '../../event_hub'; +import { TRACKING_CATEGORIES } from '../../constants'; + +export default { + name: 'PipelinesManualActionsLegacy', + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlCountdown, + GlDropdown, + GlDropdownItem, + GlIcon, + }, + mixins: [Tracking.mixin()], + props: { + actions: { + type: Array, + required: true, + }, + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + async onClickAction(action) { + if (action.scheduled_at) { + const confirmationMessage = sprintf( + s__( + 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.', + ), + { jobName: action.name }, + ); + + const confirmed = await confirmAction(confirmationMessage); + + if (!confirmed) { + return; + } + } + + this.isLoading = true; + + /** + * Ideally, the component would not make an api call directly. + * However, in order to use the eventhub and know when to + * toggle back the `isLoading` property we'd need an ID + * to track the request with a wacther - since this component + * is rendered at least 20 times in the same page, moving the + * api call directly here is the most performant solution + */ + axios + .post(`${action.path}.json`) + .then(() => { + this.isLoading = false; + eventHub.$emit('updateTable'); + }) + .catch(() => { + this.isLoading = false; + createAlert({ message: __('An error occurred while making the request.') }); + }); + }, + isActionDisabled(action) { + if (action.playable === undefined) { + return false; + } + + return !action.playable; + }, + trackClick() { + this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table }); + }, + }, +}; +</script> +<template> + <gl-dropdown + v-gl-tooltip + :title="__('Run manual or delayed jobs')" + :loading="isLoading" + data-testid="pipelines-manual-actions-dropdown" + right + lazy + icon="play" + @shown="trackClick" + > + <gl-dropdown-item + v-for="action in actions" + :key="action.path" + :disabled="isActionDisabled(action)" + @click="onClickAction(action)" + > + <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> + {{ action.name }} + <span v-if="action.scheduled_at"> + <gl-icon name="clock" /> + <gl-countdown :end-date-string="action.scheduled_at" /> + </span> + </div> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql new file mode 100644 index 00000000000..d1878c01e91 --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_actions.query.graphql @@ -0,0 +1,24 @@ +query getPipelineActions($fullPath: ID!, $iid: ID!, $limit: Int) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + jobs( + first: $limit + whenExecuted: ["manual", "delayed"] + retried: false + statuses: [MANUAL, SCHEDULED, SUCCESS, FAILED, SKIPPED, CANCELED] + ) { + nodes { + id + name + canPlayJob + manualJob + scheduledAt + scheduled + playPath + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js index 6dccdb1a3e6..4e2ba22e0cb 100644 --- a/app/assets/javascripts/pipelines/pipelines_index.js +++ b/app/assets/javascripts/pipelines/pipelines_index.js @@ -1,5 +1,7 @@ import { GlToast } from '@gitlab/ui'; import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean, historyReplaceState, @@ -13,6 +15,11 @@ import PipelinesStore from './stores/pipelines_store'; Vue.use(Translate); Vue.use(GlToast); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { const el = document.querySelector(selector); @@ -42,10 +49,12 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { anyRunnersAvailable, iosRunnersAvailable, registrationToken, + fullPath, } = el.dataset; return new Vue({ el, + apolloProvider, provide: { pipelineEditorPath, artifactsEndpoint, @@ -54,6 +63,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { ciRunnerSettingsPath, anyRunnersAvailable: parseBoolean(anyRunnersAvailable), iosRunnersAvailable: parseBoolean(iosRunnersAvailable), + fullPath, + manualActionsLimit: 50, }, data() { return { diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index e79b609545e..98417b7cd25 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -7,7 +7,7 @@ export default { }, props: { count: { - type: Number, + type: [Number, String], required: true, }, href: { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index ee275173b15..c19dfe663f4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -9,8 +9,7 @@ import { s__, __ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import eventHub from '../../event_hub'; import approvalsMixin from '../../mixins/approvals'; -import MrWidgetContainer from '../mr_widget_container.vue'; -import MrWidgetIcon from '../mr_widget_icon.vue'; +import StateContainer from '../state_container.vue'; import { INVALID_RULES_DOCS_PATH } from '../../constants'; import ApprovalsSummary from './approvals_summary.vue'; import ApprovalsSummaryOptional from './approvals_summary_optional.vue'; @@ -19,14 +18,17 @@ import { FETCH_LOADING, APPROVE_ERROR, UNAPPROVE_ERROR } from './messages'; export default { name: 'MRWidgetApprovals', components: { - MrWidgetContainer, - MrWidgetIcon, ApprovalsSummary, ApprovalsSummaryOptional, + StateContainer, GlButton, GlSprintf, }, mixins: [approvalsMixin, glFeatureFlagsMixin()], + provide: { + expandDetailsTooltip: __('Expand eligible approvers'), + collapseDetailsTooltip: __('Collapse eligible approvers'), + }, props: { mr: { type: Object, @@ -56,6 +58,11 @@ export default { required: false, default: false, }, + collapsed: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -209,10 +216,17 @@ export default { }; </script> <template> - <mr-widget-container> - <div class="js-mr-approvals d-flex align-items-start align-items-md-center"> - <mr-widget-icon name="approval" /> - <div v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</div> + <div class="js-mr-approvals mr-section-container mr-widget-workflow"> + <state-container + :is-loading="$apollo.queries.approvals.loading" + :mr="mr" + status="approval" + is-collapsible + collapse-on-desktop + :collapsed="collapsed" + @toggle="() => $emit('toggle')" + > + <template v-if="$apollo.queries.approvals.loading">{{ $options.FETCH_LOADING }}</template> <template v-else> <div class="gl-display-flex gl-flex-direction-column"> <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> @@ -221,7 +235,7 @@ export default { :variant="action.variant" :category="action.category" :loading="isApproving" - class="gl-mr-5" + class="gl-mr-3" data-qa-selector="approve_button" @click="action.action" > @@ -235,6 +249,7 @@ export default { <approvals-summary v-else :approval-state="approvals" + :disable-committers-approval="disableCommittersApproval" :multiple-approval-rules-available="mr.multipleApprovalRulesAvailable" /> </div> @@ -250,9 +265,7 @@ export default { :has-approval-auth-error="hasApprovalAuthError" ></slot> </template> - </div> - <template #footer> - <slot name="footer"></slot> - </template> - </mr-widget-container> + </state-container> + <slot name="footer"></slot> + </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index 2af033bb80f..082c261977b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -1,4 +1,5 @@ <script> +import { GlLink, GlPopover } from '@gitlab/ui'; import { toNounSeriesText } from '~/lib/utils/grammar'; import { n__, sprintf } from '~/locale'; import { @@ -12,6 +13,8 @@ import { getApprovalRuleNamesLeft } from 'ee_else_ce/vue_merge_request_widget/ma export default { components: { + GlLink, + GlPopover, UserAvatarList, }, props: { @@ -24,6 +27,11 @@ export default { type: Object, required: true, }, + disableCommittersApproval: { + type: Boolean, + required: false, + default: false, + }, }, computed: { approvers() { @@ -101,6 +109,13 @@ export default { (approver) => getIdFromGraphQLId(approver.id) !== this.currentUserId, ); }, + currentUserHasCommitted() { + if (!this.currentUserId) return false; + + return this.approvalState.committers?.nodes?.some( + (user) => getIdFromGraphQLId(user.id) === this.currentUserId, + ); + }, currentUserId() { return gon.current_user_id; }, @@ -120,5 +135,13 @@ export default { :items="approvers" /> </template> + <template v-if="disableCommittersApproval && currentUserHasCommitted"> + <gl-link id="cant-approve-popover" data-testid="commit-cant-approve" class="gl-cursor-help">{{ + __("Why can't I approve?") + }}</gl-link> + <gl-popover target="cant-approve-popover"> + {{ __("You can't approve because you added one or more commits to this merge request.") }} + </gl-popover> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index f81d2a28bba..370e07b397c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -32,6 +32,7 @@ export default { <div class="gl-display-flex gl-m-auto"> <gl-icon v-if="isMerged" name="merge" :size="16" class="gl-text-blue-500" /> <gl-icon v-else-if="isClosed" name="merge-request-close" :size="16" class="gl-text-red-500" /> + <gl-icon v-else-if="status === 'approval'" name="approval" :size="16" /> <status-icon v-else :is-loading="isLoading" :icon-name="status" :level="1" class="gl-m-0!" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue index e82daf7de1f..b6fda7eb011 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/state_container.vue @@ -1,7 +1,6 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; -import { __ } from '~/locale'; import StatusIcon from './mr_widget_status_icon.vue'; import Actions from './action_buttons.vue'; @@ -14,7 +13,30 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: { + expandDetailsTooltip: { + default: '', + }, + collapseDetailsTooltip: { + default: '', + }, + }, props: { + isCollapsible: { + type: Boolean, + required: false, + default: false, + }, + collapseOnDesktop: { + type: Boolean, + required: false, + default: false, + }, + collapsed: { + type: Boolean, + required: false, + default: false, + }, mr: { type: Object, required: false, @@ -36,10 +58,6 @@ export default { default: () => [], }, }, - i18n: { - expandDetailsTooltip: __('Expand merge details'), - collapseDetailsTooltip: __('Collapse merge details'), - }, computed: { wrapperClasses() { if (this.status === STATUS_MERGED) return 'gl-bg-blue-50'; @@ -55,7 +73,7 @@ export default { <template> <div - class="mr-widget-body media gl-display-flex gl-align-items-center" + class="mr-widget-body media gl-display-flex gl-align-items-center gl-pl-5 gl-pr-4 gl-py-4" :class="wrapperClasses" v-on="$listeners" > @@ -95,21 +113,19 @@ export default { </div> </div> <div - v-if="mr" - class="gl-md-display-none gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6 gl-mt-1" + v-if="isCollapsible" + :class="{ 'gl-md-display-none': !collapseOnDesktop }" + class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6" > <gl-button v-gl-tooltip - :title=" - mr.mergeDetailsCollapsed - ? $options.i18n.expandDetailsTooltip - : $options.i18n.collapseDetailsTooltip - " - :icon="mr.mergeDetailsCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'" + :title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip" + :icon="collapsed ? 'chevron-lg-down' : 'chevron-lg-up'" category="tertiary" size="small" class="gl-vertical-align-top" - @click="() => mr.toggleMergeDetails()" + data-testid="widget-toggle" + @click="() => $emit('toggle')" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue index 6d7ec607557..61eec503951 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue @@ -43,7 +43,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> <bold-text :message="failedText" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue index 837f8b32637..722efe2e6d2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_archived.vue @@ -24,7 +24,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <bold-text :message="$options.message" /> </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 7f0310e5a0e..6299f0fcbb8 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 @@ -131,7 +131,14 @@ export default { }; </script> <template> - <state-container :mr="mr" status="scheduled" :is-loading="loading" :actions="actions"> + <state-container + status="scheduled" + :is-loading="loading" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <template #loading> <gl-skeleton-loader :width="334" :height="24"> <rect x="0" y="0" width="24" height="24" rx="4" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 448805cf8b9..db5ef6c1a0e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -54,7 +54,13 @@ export default { }; </script> <template> - <state-container :mr="mr" status="failed" :actions="actions"> + <state-container + status="failed" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-font-weight-bold"> <template v-if="mergeError">{{ mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue index 670bd36d61e..d4b7d60568b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_checking.vue @@ -15,7 +15,12 @@ export default { }; </script> <template> - <state-container :mr="mr" status="loading"> + <state-container + status="loading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > {{ s__('mrWidget|Checking if merge request can be merged…') }} </state-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue index 6bcf88713a5..aebba67b39a 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue @@ -79,7 +79,13 @@ export default { }; </script> <template> - <state-container :mr="mr" status="closed" :actions="actions"> + <state-container + status="closed" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <mr-widget-author-time :action-text="s__('mrWidget|Closed by')" :author="mr.metrics.closedBy" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue index 6976bfcc989..55ae390216d 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 @@ -72,7 +72,13 @@ export default { }; </script> <template> - <state-container :mr="mr" status="failed" :is-loading="isLoading"> + <state-container + status="failed" + :is-loading="isLoading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <template #loading> <gl-skeleton-loader :width="334" :height="24"> <rect x="0" y="0" width="24" height="24" rx="4" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue index bfc2c282f4c..742f5d4de14 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue @@ -95,12 +95,25 @@ export default { }; </script> <template> - <state-container v-if="isRefreshing" :mr="mr" status="loading"> + <state-container + v-if="isRefreshing" + status="loading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-font-weight-bold"> {{ s__('mrWidget|Refreshing now') }} </span> </state-container> - <state-container v-else :mr="mr" status="failed" :actions="actions"> + <state-container + v-else + status="failed" + :actions="actions" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span v-if="mr.mergeError" class="has-error-message gl-font-weight-bold" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 4e2b12799d0..4d906f29cb0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -150,7 +150,13 @@ export default { }; </script> <template> - <state-container :mr="mr" :actions="actions" status="merged"> + <state-container + :actions="actions" + status="merged" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <mr-widget-author-time :action-text="s__('mrWidget|Merged by')" :author="mr.metrics.mergedBy" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 4d4930e28a3..f3a05341bcd 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -195,7 +195,13 @@ export default { </script> <template> <div> - <state-container :mr="mr" :status="status" :is-loading="isLoading"> + <state-container + :status="status" + :is-loading="isLoading" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <template #loading> <gl-skeleton-loader :width="334" :height="24"> <rect x="0" y="0" width="24" height="24" rx="4" /> 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 2e064ebf9fe..1390f973c9c 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 @@ -528,7 +528,7 @@ export default { > <template v-if="shouldShowMergeControls"> <div - class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap-wrap gl-w-full gl-md-pb-5" + class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap-wrap gl-w-full gl-md-pb-2" > <gl-form-checkbox v-if="canRemoveSourceBranch" @@ -598,9 +598,7 @@ export default { </li> </ul> </div> - <div - class="gl-w-full gl-text-gray-500 gl-mb-3 gl-md-mb-0 gl-md-pb-5 mr-widget-merge-details" - > + <div class="gl-w-full gl-text-gray-500 gl-mb-3 mr-widget-merge-details"> <template v-if="sourceHasDivergedFromTarget"> <gl-sprintf :message="$options.i18n.sourceDivergedFromTargetText"> <template #link> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue index 2aa345b420e..9da754d01fc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue @@ -24,7 +24,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-md-mr-3 gl-flex-grow-1 gl-ml-0! gl-text-body!" data-qa-selector="head_mismatch_content" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index 0fd5551979d..4f1abca8840 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -30,7 +30,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-ml-3 gl-w-100 gl-flex-grow-1 gl-md-mr-3 gl-ml-0! gl-text-body!"> <bold-text :message="$options.message" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 7163e54985e..7fc4a06cbae 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 @@ -137,7 +137,12 @@ export default { </script> <template> - <state-container :mr="mr" status="failed"> + <state-container + status="failed" + is-collapsible + :collapsed="mr.mergeDetailsCollapsed" + @toggle="() => mr.toggleMergeDetails()" + > <span class="gl-ml-0! gl-text-body! gl-flex-grow-1"> <bold-text :message="$options.i18n.removeDraftStatus" /> </span> diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js index ae9111b9504..7e658e77d37 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -16,6 +16,8 @@ export default { result({ data }) { const { mergeRequest } = data.project; + this.disableCommittersApproval = data.project.mergeRequestsDisableCommittersApproval; + this.mr.setApprovals(mergeRequest); }, error() { @@ -29,6 +31,7 @@ export default { return { alerts: [], approvals: {}, + disableCommittersApproval: false, }; }, methods: { diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 374b438a4bc..df6e0ba6b34 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -156,6 +156,10 @@ export default { }, }, mixins: [mergeRequestQueryVariablesMixin], + provide: { + expandDetailsTooltip: __('Expand merge details'), + collapseDetailsTooltip: __('Collapse merge details'), + }, props: { mrData: { type: Object, diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index 5bfc4e3c7b5..420d7ebe7d6 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -20,6 +20,11 @@ export default { type: String, required: true, }, + setFacade: { + type: Function, + required: false, + default: null, + }, renderMarkdownPath: { type: String, required: true, @@ -44,6 +49,16 @@ export default { required: false, default: false, }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + autocompleteDataSources: { + type: Object, + required: false, + default: () => ({}), + }, supportsQuickActions: { type: Boolean, required: false, @@ -54,6 +69,11 @@ export default { required: false, default: null, }, + markdownDocsPath: { + type: String, + required: false, + default: '', + }, quickActionsDocsPath: { type: String, required: false, @@ -99,8 +119,23 @@ export default { this.$emit('input', this.markdown); this.saveDraft(); + + this.setFacade?.({ + getValue: () => this.getValue(), + setValue: (val) => this.setValue(val), + }); }, methods: { + getValue() { + return this.markdown; + }, + setValue(value) { + this.markdown = value; + this.$emit('input', value); + + this.saveDraft(); + this.autosizeTextarea(); + }, updateMarkdownFromContentEditor({ markdown }) { this.markdown = markdown; this.$emit('input', markdown); @@ -162,7 +197,7 @@ export default { <div> <local-storage-sync v-model="editingMode" - storage-key="gl-wiki-content-editor-enabled" + storage-key="gl-content-editor-enabled" @input="onEditingModeRestored" /> <markdown-field @@ -174,6 +209,9 @@ export default { can-attach-file :textarea-value="markdown" :uploads-path="uploadsPath" + :enable-autocomplete="enableAutocomplete" + :autocomplete-data-sources="autocompleteDataSources" + :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" :show-content-editor-switcher="enableContentEditor" :drawio-enabled="drawioEnabled" @@ -206,6 +244,8 @@ export default { :autofocus="contentEditorAutofocused" :placeholder="formFieldProps.placeholder" :drawio-enabled="drawioEnabled" + :enable-autocomplete="enableAutocomplete" + :autocomplete-data-sources="autocompleteDataSources" :editable="!disabled" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" diff --git a/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js new file mode 100644 index 00000000000..bf9f1948de2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js @@ -0,0 +1,100 @@ +import Vue from 'vue'; +import { queryToObject, objectToQuery } from '~/lib/utils/url_utility'; +import MarkdownEditor from './markdown_editor.vue'; + +const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; +const MR_TARGET_BRANCH = 'merge_request[target_branch]'; + +function organizeQuery(obj, isFallbackKey = false) { + if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) { + return obj; + } + + if (isFallbackKey) { + return { + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], + }; + } + + return { + [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH], + [MR_TARGET_BRANCH]: obj[MR_TARGET_BRANCH], + }; +} + +function format(searchTerm, isFallbackKey = false) { + const queryObject = queryToObject(searchTerm, { legacySpacesDecode: true }); + const organizeQueryObject = organizeQuery(queryObject, isFallbackKey); + const formattedQuery = objectToQuery(organizeQueryObject); + + return formattedQuery; +} + +function getSearchTerm(newIssuePath) { + const { search, pathname } = document.location; + return newIssuePath === pathname ? '' : format(search); +} + +export function mountMarkdownEditor() { + const el = document.querySelector('.js-markdown-editor'); + + if (!el) { + return null; + } + + const { + renderMarkdownPath, + markdownDocsPath, + quickActionsDocsPath, + enableContentEditor, + formFieldPlaceholder, + formFieldClasses, + qaSelector, + newIssuePath, + } = el.dataset; + + const hiddenInput = el.querySelector('input[type="hidden"]'); + const formFieldName = hiddenInput.getAttribute('name'); + const formFieldId = hiddenInput.getAttribute('id'); + const formFieldValue = hiddenInput.value; + + const searchTerm = getSearchTerm(newIssuePath); + const facade = { + setValue() {}, + getValue() {}, + focus() {}, + }; + + const setFacade = (props) => Object.assign(facade, props); + + // eslint-disable-next-line no-new + new Vue({ + el, + render(h) { + return h(MarkdownEditor, { + props: { + setFacade, + enableContentEditor: Boolean(enableContentEditor), + value: formFieldValue, + renderMarkdownPath, + markdownDocsPath, + quickActionsDocsPath, + formFieldProps: { + placeholder: formFieldPlaceholder, + id: formFieldId, + name: formFieldName, + class: formFieldClasses, + 'data-qa-selector': qaSelector, + }, + autosaveKey: `autosave/${document.location.pathname}/${searchTerm}/description`, + enableAutocomplete: true, + autocompleteDataSources: gl.GfmAutoComplete?.dataSources, + supportsQuickActions: true, + autofocus: true, + }, + }); + }, + }); + + return facade; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index a3ddde9364c..9542831ca8e 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -1,3 +1,27 @@ +// stylelint-disable length-zero-no-unit +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --system-footer-height: 0px; + --mr-review-bar-height: 0px; +} + +.with-performance-bar { + --performance-bar-height: #{$performance-bar-height}; +} + +.with-system-header { + --system-header-height: #{$system-header-height}; +} + +.with-system-footer { + --system-footer-height: #{$system-footer-height}; +} + +.review-bar-visible { + --mr-review-bar-height: #{$mr-review-bar-height}; +} + /** COLORS **/ .cgray { color: $gl-text-color; } .clgray { color: $gray-200; } diff --git a/app/assets/stylesheets/framework/super_sidebar.scss b/app/assets/stylesheets/framework/super_sidebar.scss index 3be5d9ac543..48c87682897 100644 --- a/app/assets/stylesheets/framework/super_sidebar.scss +++ b/app/assets/stylesheets/framework/super_sidebar.scss @@ -27,8 +27,8 @@ display: flex; flex-direction: column; position: fixed; - top: 0; - bottom: 0; + top: $calc-application-header-height; + bottom: $calc-application-footer-height; left: 0; background-color: var(--gray-10, $gray-10); border-right: 1px solid $t-gray-a-08; @@ -207,10 +207,6 @@ } } -.with-performance-bar .super-sidebar { - top: $performance-bar-height; -} - .gl-dark { .super-sidebar { .gl-new-dropdown-custom-toggle { diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss index fc6b1181575..ede3369414b 100644 --- a/app/assets/stylesheets/framework/system_messages.scss +++ b/app/assets/stylesheets/framework/system_messages.scss @@ -48,7 +48,6 @@ // left sidebar eg: project page // right sidebar eg: MR page .nav-sidebar, - .super-sidebar, .right-sidebar { top: calc(#{$system-header-height} + #{$header-height}); } @@ -73,7 +72,6 @@ // left sidebar eg: project page // right sidebar eg: MR page .nav-sidebar, - .super-sidebar, .right-sidebar { top: calc(#{$header-height} + #{$performance-bar-height} + #{$system-header-height}); } @@ -85,7 +83,6 @@ // left sidebar eg: project page // right sidebar eg: mr page .nav-sidebar, - .super-sidebar, .right-sidebar, .review-bar-component, // navless pages' footer eg: login page diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 509ef5feae8..2743bba976c 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -480,6 +480,7 @@ $highlight-changes-color: rgb(235, 255, 232); $performance-bar-height: 35px; $system-header-height: 16px; $system-footer-height: $system-header-height; +$mr-review-bar-height: calc(2rem + 13px); $flash-height: 52px; $flash-container-top: 48px; $context-header-height: 60px; @@ -497,6 +498,14 @@ $gl-line-height-14: 14px; $pages-group-name-color: #4c4e54; /* + * Calculated heights + */ +$calc-application-bars-height: calc(var(--system-header-height) + var(--performance-bar-height)); +$calc-application-header-height: calc(#{$header-height} + #{$calc-application-bars-height}); +$calc-application-footer-height: var(--system-footer-height); +$calc-application-viewport-height: calc(100vh - #{$calc-application-header-height} - #{$calc-application-footer-height}); + +/* * Common component specific colors */ $user-mention-bg: rgba($blue-500, 0.044); diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index d54b18a18c4..e11b6c92455 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -1,6 +1,5 @@ @import 'mixins_and_variables_and_functions'; -$mr-review-bar-height: calc(2rem + 13px); $mr-widget-margin-left: 40px; $mr-widget-min-height: 69px; $tabs-holder-z-index: 250; @@ -242,18 +241,6 @@ $tabs-holder-z-index: 250; } } -.with-system-header { - --system-header-height: #{$system-header-height}; -} - -.with-performance-bar { - --performance-bar-height: #{$performance-bar-height}; -} - -.review-bar-visible { - --review-bar-height: #{$mr-review-bar-height}; -} - .diff-tree-list { // This 11px value should match the additional value found in // /assets/stylesheets/framework/diffs.scss @@ -271,7 +258,7 @@ $tabs-holder-z-index: 250; position: sticky; top: calc(var(--top-pos) + var(--performance-bar-height, 0px)); min-height: 300px; - height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--review-bar-height, 0px)); + height: calc(100vh - var(--top-pos) - var(--system-header-height, 0px) - var(--performance-bar-height, 0px) - var(--mr-review-bar-height, 0px)); .drag-handle { bottom: 16px; diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index 04f09940dd6..12c3ca9ab9e 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -616,6 +616,11 @@ html { color: #bfbfc3; vertical-align: baseline; } +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --system-footer-height: 0px; +} .gl-font-sm { font-size: 12px; } @@ -1444,8 +1449,11 @@ kbd { display: flex; flex-direction: column; position: fixed; - top: 0; - bottom: 0; + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + ); + bottom: var(--system-footer-height); left: 0; background-color: var(--gray-10, #1f1e24); border-right: 1px solid rgba(251, 250, 253, 0.08); diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 52cc2ce2855..3e443e99a61 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -616,6 +616,11 @@ html { color: #535158; vertical-align: baseline; } +:root { + --performance-bar-height: 0px; + --system-header-height: 0px; + --system-footer-height: 0px; +} .gl-font-sm { font-size: 12px; } @@ -1444,8 +1449,11 @@ kbd { display: flex; flex-direction: column; position: fixed; - top: 0; - bottom: 0; + top: calc( + var(--header-height, 48px) + + calc(var(--system-header-height) + var(--performance-bar-height)) + ); + bottom: var(--system-footer-height); left: 0; background-color: var(--gray-10, #fbfafd); border-right: 1px solid rgba(31, 30, 36, 0.08); diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 84eabd3a142..4f3369d5bd0 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -22,6 +22,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy] + before_action :push_frontend_feature_flags, only: [:index] # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } @@ -357,6 +358,10 @@ class Projects::PipelinesController < Projects::ApplicationController def tracking_project_source project end + + def push_frontend_feature_flags + push_frontend_feature_flag(:lazy_load_pipeline_dropdown_actions, @project) + end end Projects::PipelinesController.prepend_mod_with('Projects::PipelinesController') diff --git a/app/graphql/mutations/achievements/update.rb b/app/graphql/mutations/achievements/update.rb new file mode 100644 index 00000000000..2a9e6580629 --- /dev/null +++ b/app/graphql/mutations/achievements/update.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Mutations + module Achievements + class Update < BaseMutation + graphql_name 'AchievementsUpdate' + + include Gitlab::Graphql::Authorize::AuthorizeResource + + field :achievement, + ::Types::Achievements::AchievementType, + null: true, + description: 'Achievement.' + + argument :achievement_id, ::Types::GlobalIDType[::Achievements::Achievement], + required: true, + description: 'Global ID of the achievement being updated.' + + argument :name, GraphQL::Types::String, + required: false, + description: 'Name for the achievement.' + + argument :avatar, ApolloUploadServer::Upload, + required: false, + description: 'Avatar for the achievement.' + + argument :description, GraphQL::Types::String, + required: false, + description: 'Description of or notes for the achievement.' + + authorize :admin_achievement + + def resolve(args) + achievement = authorized_find!(id: args[:achievement_id]) + + args.delete(:achievement_id) + result = ::Achievements::UpdateService.new(current_user, achievement, args).execute + { achievement: result.payload, errors: result.errors } + end + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::Achievements::Achievement) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 93d4baa2e73..2714f4cf502 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -10,6 +10,7 @@ module Types mount_mutation Mutations::Achievements::Create, alpha: { milestone: '15.8' } mount_mutation Mutations::Achievements::Delete, alpha: { milestone: '15.11' } mount_mutation Mutations::Achievements::Revoke, alpha: { milestone: '15.10' } + mount_mutation Mutations::Achievements::Update, alpha: { milestone: '15.11' } mount_mutation Mutations::Admin::SidekiqQueues::DeleteJobs mount_mutation Mutations::AlertManagement::CreateAlertIssue mount_mutation Mutations::AlertManagement::UpdateAlertStatus diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index 823332c3d1d..31dc2600225 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -101,7 +101,8 @@ module Ci has_gitlab_ci: has_gitlab_ci?(project).to_s, pipeline_editor_path: can?(current_user, :create_pipeline, project) && project_ci_pipeline_editor_path(project), suggested_ci_templates: suggested_ci_templates.to_json, - ci_runner_settings_path: project_settings_ci_cd_path(project, anchor: 'js-runners-settings') + ci_runner_settings_path: project_settings_ci_cd_path(project, anchor: 'js-runners-settings'), + full_path: project.full_path } experiment(:runners_availability_section, namespace: project.root_ancestor) do |e| diff --git a/app/services/achievements/update_service.rb b/app/services/achievements/update_service.rb new file mode 100644 index 00000000000..dcadae8dc3b --- /dev/null +++ b/app/services/achievements/update_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Achievements + class UpdateService + attr_reader :current_user, :achievement, :params + + def initialize(current_user, achievement, params) + @current_user = current_user + @achievement = achievement + @params = params + end + + def execute + return error_no_permissions unless allowed? + + if achievement.update(params) + ServiceResponse.success(payload: achievement) + else + error_updating + end + end + + private + + def allowed? + current_user&.can?(:admin_achievement, achievement) + end + + def error_no_permissions + error('You have insufficient permission to update this achievement') + end + + def error(message) + ServiceResponse.error(payload: achievement, message: Array(message)) + end + + def error_updating + error(achievement&.errors&.full_messages || 'Failed to update achievement') + end + end +end diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index 6a8ef86a56e..16b2a0b8fc6 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -6,9 +6,9 @@ = f.label :container_registry_token_expire_delay, _('Authorization token duration (minutes)'), class: 'label-bold' = f.number_field :container_registry_token_expire_delay, class: 'form-control gl-form-input' .form-group - - label = _("Enable container expiration and retention policies for projects created earlier than GitLab 12.7.") + - label = _("Enable cleanup policies for projects created earlier than GitLab 12.7.") - label_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'cleanup-policy') - - help_text = _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.") + - help_text = _("Existing projects will be able to use cleanup policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.") - help_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'use-with-external-container-registries') = f.gitlab_ui_checkbox_component :container_expiration_policies_enable_historic_entries, '%{label} %{label_link}'.html_safe % { label: label, label_link: label_link }, @@ -29,9 +29,9 @@ .form-text.text-muted = _("The maximum number of tags that a single worker accepts for cleanup. If the number of tags goes above this limit, the list of tags to delete is truncated to this number. To remove this limit, set it to 0.") .form-group - - help_text = _("When enabled, cleanup polices execute faster but put more load on Redis.") + - help_text = _("When enabled, cleanup policies execute faster but put more load on Redis.") - help_link = link_to sprite_icon('question-o'), help_page_path('user/packages/container_registry/reduce_container_registry_storage', anchor: 'set-cleanup-limits-to-conserve-resources') - = f.gitlab_ui_checkbox_component :container_registry_expiration_policies_caching, _("Enable container expiration caching."), + = f.gitlab_ui_checkbox_component :container_registry_expiration_policies_caching, _("Enable cleanup policy caching."), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml index d27d268d65e..0f2fa7c2aaa 100644 --- a/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml +++ b/app/views/projects/settings/packages_and_registries/cleanup_tags.html.haml @@ -1,6 +1,6 @@ - add_to_breadcrumbs _('Packages and registries settings'), project_settings_packages_and_registries_path(@project) -- breadcrumb_title s_('ContainerRegistry|Clean up image tags') -- page_title s_('ContainerRegistry|Clean up image tags'), _('Packages and registries settings') +- breadcrumb_title s_('ContainerRegistry|Cleanup policies') +- page_title s_('ContainerRegistry|Cleanup policies'), _('Packages and registries settings') - @content_class = 'limit-container-width' unless fluid_layout #js-registry-settings-cleanup-image-tags{ data: cleanup_settings_data } diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 12d0af294da..21cfb757174 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -1,11 +1,11 @@ +- @gfm_form = true - project = local_assigns.fetch(:project) - model = local_assigns.fetch(:model) - form = local_assigns.fetch(:form) - placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a description or drag your files here…') - no_issuable_templates = issuable_templates(ref_project, model.to_ability_name).empty? - -- supports_quick_actions = true - preview_url = preview_markdown_path(project, target_type: model.class.name) +- enable_content_editor = Feature.enabled?(:content_editor_on_issues) ? "true" : "" .form-group = form.label :description, _('Description'), class: 'gl-display-block' @@ -17,14 +17,15 @@ = render 'shared/form_elements/apply_template_warning', issuable: model - = render layout: 'shared/md_preview', locals: { url: preview_url, referenced_users: true } do - = render 'shared/zen', f: form, attr: :description, - classes: 'note-textarea rspec-issuable-form-description', - placeholder: placeholder, - supports_quick_actions: supports_quick_actions, - qa_selector: 'issuable_form_description_field' - = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions - .clearfix - .error-alert + .js-markdown-editor{ data: { render_markdown_path: preview_url, + markdown_docs_path: help_page_path('user/markdown'), + quick_actions_docs_path: help_page_path('user/project/quick_actions'), + qa_selector: 'issuable_form_description_field', + enable_content_editor: enable_content_editor, + form_field_placeholder: placeholder, + form_field_classes: 'js-gfm-input markdown-area note-textarea rspec-issuable-form-description' } } + = form.hidden_field :description + - if no_issuable_templates && can?(current_user, :push_code, model.project) = render 'shared/issuable/form/default_templates' + diff --git a/db/migrate/20230330215636_remove_unused_project_jira_indexes.rb b/db/migrate/20230330215636_remove_unused_project_jira_indexes.rb new file mode 100644 index 00000000000..ce10b70f81c --- /dev/null +++ b/db/migrate/20230330215636_remove_unused_project_jira_indexes.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class RemoveUnusedProjectJiraIndexes < Gitlab::Database::Migration[2.1] + TITLE_INDEX = { + name: 'index_merge_requests_on_target_project_id_and_iid_jira_title', + where: "((title)::text ~ '[A-Z][A-Z_0-9]+-\d+'::text)" + }.freeze + + DESCRIPTION_INDEX = { + name: 'index_merge_requests_on_target_project_id_iid_jira_description', + where: "(description ~ '[A-Z][A-Z_0-9]+-\d+'::text)" + }.freeze + + # TODO: Indexes to be destroyed synchronously in https://gitlab.com/gitlab-org/gitlab/-/issues/403327 + def up + prepare_async_index_removal :merge_requests, [:target_project_id, :iid], + where: TITLE_INDEX[:where], + name: TITLE_INDEX[:name] + + prepare_async_index_removal :merge_requests, [:target_project_id, :iid], + where: DESCRIPTION_INDEX[:where], + name: DESCRIPTION_INDEX[:name] + end + + def down + unprepare_async_index :merge_requests, [:target_project_id, :iid], + where: TITLE_INDEX[:where], + name: TITLE_INDEX[:name] + + unprepare_async_index :merge_requests, [:target_project_id, :iid], + where: DESCRIPTION_INDEX[:where], + name: DESCRIPTION_INDEX[:name] + end +end diff --git a/db/schema_migrations/20230330215636 b/db/schema_migrations/20230330215636 new file mode 100644 index 00000000000..21d09d15906 --- /dev/null +++ b/db/schema_migrations/20230330215636 @@ -0,0 +1 @@ +46f78841bf6d0ff3b852eeb2f6690ec9fa20a460566a8c23f8530f0e4ec60ed8
\ No newline at end of file diff --git a/doc/administration/packages/container_registry.md b/doc/administration/packages/container_registry.md index 619ec2490a7..05d2f307f02 100644 --- a/doc/administration/packages/container_registry.md +++ b/doc/administration/packages/container_registry.md @@ -932,7 +932,7 @@ information, see the [Sidekiq configuration](../sidekiq/index.md) page. To reduce the amount of [Container Registry disk space used by a given project](#registry-disk-space-usage-by-project), -administrators can clean up image tags +administrators can setup cleanup policies and [run garbage collection](#container-registry-garbage-collection). ### Registry Disk Space Usage by Project diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 3fd73c8c0d6..9a4df4cab04 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -842,6 +842,32 @@ Input type: `AchievementsRevokeInput` | <a id="mutationachievementsrevokeerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationachievementsrevokeuserachievement"></a>`userAchievement` | [`UserAchievement`](#userachievement) | Achievement award. | +### `Mutation.achievementsUpdate` + +WARNING: +**Introduced** in 15.11. +This feature is in Alpha. It can be changed or removed at any time. + +Input type: `AchievementsUpdateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationachievementsupdateachievementid"></a>`achievementId` | [`AchievementsAchievementID!`](#achievementsachievementid) | Global ID of the achievement being updated. | +| <a id="mutationachievementsupdateavatar"></a>`avatar` | [`Upload`](#upload) | Avatar for the achievement. | +| <a id="mutationachievementsupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationachievementsupdatedescription"></a>`description` | [`String`](#string) | Description of or notes for the achievement. | +| <a id="mutationachievementsupdatename"></a>`name` | [`String`](#string) | Name for the achievement. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationachievementsupdateachievement"></a>`achievement` | [`Achievement`](#achievement) | Achievement. | +| <a id="mutationachievementsupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationachievementsupdateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.addProjectToSecurityDashboard` Input type: `AddProjectToSecurityDashboardInput` diff --git a/doc/user/packages/container_registry/reduce_container_registry_storage.md b/doc/user/packages/container_registry/reduce_container_registry_storage.md index 88c5efd1b39..f873453e049 100644 --- a/doc/user/packages/container_registry/reduce_container_registry_storage.md +++ b/doc/user/packages/container_registry/reduce_container_registry_storage.md @@ -156,8 +156,8 @@ You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the To create a cleanup policy in the UI: 1. For your project, go to **Settings > Packages and registries**. -1. Expand the **Clean up image tags** section. -1. Complete the fields. +1. In the **Cleanup policies** section, select **Set cleanup rules**. +1. Complete the fields: | Field | Description | |----------------------------|-------------------------------------------------| diff --git a/doc/user/project/repository/vscode.md b/doc/user/project/repository/vscode.md index 94fa21436b3..6c1f39d6615 100644 --- a/doc/user/project/repository/vscode.md +++ b/doc/user/project/repository/vscode.md @@ -48,7 +48,7 @@ as they type. Depending on the cursor position, the extension either: - Provides entire code snippets, like generating functions. - Completes the current line. -Developers can press <kbd>Tab</tab> to accept suggestions. +Developers can press <kbd>Tab</kbd> to accept suggestions. Code Suggestions support the following languages with the highest confidence: @@ -87,7 +87,7 @@ To enable Code Suggestions in VS Code: 1. Provide your GitLab instance URL. A default is provided. 1. Provide your personal access token. 1. After your GitLab account connects successfully, in the left sidebar, select **Extensions**. -1. Find the **GitLab workflow** extension, and select **Settings** (**{settings}**). +1. Find the **GitLab workflow** extension, select **Settings** (**{settings}**), and select **Extension Settings**. 1. Enable **GitLab › AI Assisted Code Suggestions**. Start typing and receive suggestions for your GitLab projects. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2e34602fc73..5c5572ccd8a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10395,7 +10395,7 @@ msgstr "" msgid "Collapse all threads" msgstr "" -msgid "Collapse approvers" +msgid "Collapse eligible approvers" msgstr "" msgid "Collapse issues" @@ -11199,9 +11199,6 @@ msgstr "" msgid "ContainerRegistry|CLI Commands" msgstr "" -msgid "ContainerRegistry|Clean up image tags" -msgstr "" - msgid "ContainerRegistry|Cleanup disabled" msgstr "" @@ -11217,12 +11214,18 @@ msgstr "" msgid "ContainerRegistry|Cleanup is disabled for this project" msgstr "" +msgid "ContainerRegistry|Cleanup is not scheduled." +msgstr "" + msgid "ContainerRegistry|Cleanup is ongoing" msgstr "" msgid "ContainerRegistry|Cleanup pending" msgstr "" +msgid "ContainerRegistry|Cleanup policies" +msgstr "" + msgid "ContainerRegistry|Cleanup policy for tags is disabled" msgstr "" @@ -11235,6 +11238,9 @@ msgstr "" msgid "ContainerRegistry|Cleanup will run %{time}" msgstr "" +msgid "ContainerRegistry|Cleanup will run in %{time}" +msgstr "" + msgid "ContainerRegistry|Cleanup will run soon" msgstr "" @@ -11277,13 +11283,7 @@ msgstr "" msgid "ContainerRegistry|Edit cleanup rules" msgstr "" -msgid "ContainerRegistry|Enable expiration policy" -msgstr "" - -msgid "ContainerRegistry|Expiration policy is disabled." -msgstr "" - -msgid "ContainerRegistry|Expiration policy will run in %{time}" +msgid "ContainerRegistry|Enable cleanup policy" msgstr "" msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password." @@ -11444,7 +11444,7 @@ msgstr "" msgid "ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}View regex examples.%{linkEnd}" msgstr "" -msgid "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}" +msgid "ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}run cleanup now manually%{adminLinkEnd} or you can wait for the next scheduled run of the cleanup policy. %{docLinkStart}More information%{docLinkEnd}" msgstr "" msgid "ContainerRegistry|The cleanup will continue within %{time}. %{linkStart}Learn more%{linkEnd}" @@ -15967,10 +15967,10 @@ msgstr "" msgid "Enable automatic repository housekeeping" msgstr "" -msgid "Enable container expiration and retention policies for projects created earlier than GitLab 12.7." +msgid "Enable cleanup policies for projects created earlier than GitLab 12.7." msgstr "" -msgid "Enable container expiration caching." +msgid "Enable cleanup policy caching." msgstr "" msgid "Enable dashboard limits on namespaces" @@ -17238,7 +17238,7 @@ msgstr "" msgid "Existing projects may be moved into a group" msgstr "" -msgid "Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project." +msgid "Existing projects will be able to use cleanup policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project." msgstr "" msgid "Existing sign in methods may be removed" @@ -17259,7 +17259,7 @@ msgstr "" msgid "Expand all threads" msgstr "" -msgid "Expand approvers" +msgid "Expand eligible approvers" msgstr "" msgid "Expand file" @@ -40914,6 +40914,9 @@ msgstr "" msgid "Showing data for workflow items completed in this date range. Date range limited to %{maxDateRange} days." msgstr "" +msgid "Showing first 50 actions." +msgstr "" + msgid "Showing last %{size} of log -" msgstr "" @@ -48120,9 +48123,6 @@ msgstr "" msgid "View documentation" msgstr "" -msgid "View eligible approvers" -msgstr "" - msgid "View exposed artifact" msgid_plural "View %d exposed artifacts" msgstr[0] "" @@ -49231,7 +49231,7 @@ msgstr "" msgid "When enabled, SSH keys with no expiry date or an invalid expiration date are no longer accepted. Leave blank for no limit." msgstr "" -msgid "When enabled, cleanup polices execute faster but put more load on Redis." +msgid "When enabled, cleanup policies execute faster but put more load on Redis." msgstr "" msgid "When enabled, existing access tokens may be revoked. Leave blank for no limit." @@ -49297,6 +49297,9 @@ msgstr "" msgid "Why are you signing up? (optional)" msgstr "" +msgid "Why can't I approve?" +msgstr "" + msgid "Why is this rule invalid?" msgstr "" @@ -50156,6 +50159,9 @@ msgstr "" msgid "You can view the source or %{linkStart}%{cloneIcon} clone the repository%{linkEnd}" msgstr "" +msgid "You can't approve because you added one or more commits to this merge request." +msgstr "" + msgid "You can't follow more than %{limit} users. To follow more users, unfollow some others." msgstr "" diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 3a1aa36208e..9a0d7ea0848 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -487,7 +487,7 @@ RSpec.describe 'Admin updates settings', feature_category: :shared do container_registry_delete_tags_service_timeout: 'Container Registry delete tags service execution timeout', container_registry_expiration_policies_worker_capacity: 'Cleanup policy maximum workers running concurrently', container_registry_cleanup_tags_service_max_list_size: 'Cleanup policy maximum number of tags to be deleted', - container_registry_expiration_policies_caching: 'Enable container expiration caching' + container_registry_expiration_policies_caching: 'Enable cleanup policy caching' } end diff --git a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb index 6325f226ccf..6d9eb3a7191 100644 --- a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb +++ b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb @@ -73,8 +73,8 @@ RSpec.describe 'User creates branch and merge request on issue page', :js, featu expect(page).to have_content('New merge request') expect(page).to have_content("From #{issue.to_branch_name} into #{project.default_branch}") - expect(page).to have_content("Closes ##{issue.iid}") expect(page).to have_field("Title", with: "Draft: Resolve \"Cherry-Coloured Funk\"") + expect(page).to have_field("Description", with: "Closes ##{issue.iid}") expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: issue.to_branch_name, target_branch: project.default_branch, issue_iid: issue.iid })) end end @@ -98,8 +98,8 @@ RSpec.describe 'User creates branch and merge request on issue page', :js, featu expect(page).to have_content('New merge request') expect(page).to have_content("From #{branch_name} into #{project.default_branch}") - expect(page).to have_content("Closes ##{issue.iid}") expect(page).to have_field("Title", with: "Draft: Resolve \"Cherry-Coloured Funk\"") + expect(page).to have_field("Description", with: "Closes ##{issue.iid}") expect(page).to have_current_path(project_new_merge_request_path(project, merge_request: { source_branch: branch_name, target_branch: project.default_branch, issue_iid: issue.iid })) end end diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb index 2377a2ba3c7..4e3968230b4 100644 --- a/spec/features/issues/user_creates_issue_spec.rb +++ b/spec/features/issues/user_creates_issue_spec.rb @@ -59,22 +59,22 @@ RSpec.describe "User creates issue", feature_category: :team_planning do textarea = first(".gfm-form textarea") page.within(form) do - click_button("Preview") + click_link("Preview") - preview = find(".js-md-preview") # this element is findable only when the "Preview" link is clicked. + preview = find(".js-vue-md-preview") # this element is findable only when the "Preview" link is clicked. expect(preview).to have_content("Nothing to preview.") - click_button("Write") + click_link("Write") fill_in("Description", with: "Bug fixed :smile:") - click_button("Preview") + click_link("Preview") expect(preview).to have_css("gl-emoji") expect(textarea).not_to be_visible - click_button("Write") + click_link("Write") fill_in("Description", with: "/confidential") - click_button("Preview") + click_link("Preview") expect(form).to have_content('Makes this issue confidential.') end @@ -127,6 +127,8 @@ RSpec.describe "User creates issue", feature_category: :team_planning do end end + it_behaves_like 'edits content using the content editor' + context 'dropzone upload file', :js do before do visit new_project_issue_path(project) diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index 06c1b2afdb0..3a927e76fd1 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -26,6 +26,8 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin visit edit_project_issue_path(project, issue) end + it_behaves_like 'edits content using the content editor' + it "previews content", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391757' do form = first(".gfm-form") diff --git a/spec/features/merge_request/user_edits_merge_request_spec.rb b/spec/features/merge_request/user_edits_merge_request_spec.rb index 839081d00dc..584a17ae33d 100644 --- a/spec/features/merge_request/user_edits_merge_request_spec.rb +++ b/spec/features/merge_request/user_edits_merge_request_spec.rb @@ -108,4 +108,6 @@ RSpec.describe 'User edits a merge request', :js, feature_category: :code_review end end end + + it_behaves_like 'edits content using the content editor' end diff --git a/spec/features/merge_request/user_views_open_merge_request_spec.rb b/spec/features/merge_request/user_views_open_merge_request_spec.rb index afa57cb0f8f..095607b61fb 100644 --- a/spec/features/merge_request/user_views_open_merge_request_spec.rb +++ b/spec/features/merge_request/user_views_open_merge_request_spec.rb @@ -56,25 +56,25 @@ RSpec.describe 'User views an open merge request', feature_category: :code_revie end it 'renders empty description preview' do - find('.gfm-form').fill_in(:merge_request_description, with: '') + fill_in(:merge_request_description, with: '') - page.within('.gfm-form') do - click_button('Preview') + page.within('.js-vue-markdown-field') do + click_link('Preview') - expect(find('.js-md-preview')).to have_content('Nothing to preview.') + expect(find('.js-vue-md-preview')).to have_content('Nothing to preview.') end end it 'renders description preview' do - find('.gfm-form').fill_in(:merge_request_description, with: ':+1: Nice') + fill_in(:merge_request_description, with: ':+1: Nice') - page.within('.gfm-form') do - click_button('Preview') + page.within('.js-vue-markdown-field') do + click_link('Preview') - expect(find('.js-md-preview')).to have_css('gl-emoji') + expect(find('.js-vue-md-preview')).to have_css('gl-emoji') end - expect(find('.gfm-form')).to have_css('.js-md-preview').and have_button('Write') + expect(find('.js-vue-markdown-field')).to have_css('.js-vue-md-preview').and have_link('Write') expect(find('#merge_request_description', visible: false)).not_to be_visible end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index c46605fa9a8..376fb244924 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -278,7 +278,6 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do end before do - stub_feature_flags(lazy_load_pipeline_dropdown_actions: false) visit_project_pipelines end @@ -289,12 +288,17 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do it 'has link to the manual action' do find('[data-testid="pipelines-manual-actions-dropdown"]').click + wait_for_requests + expect(page).to have_button('manual build') end context 'when manual action was played' do before do find('[data-testid="pipelines-manual-actions-dropdown"]').click + + wait_for_requests + click_button('manual build') end @@ -313,7 +317,6 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do end before do - stub_feature_flags(lazy_load_pipeline_dropdown_actions: false) visit_project_pipelines end @@ -324,6 +327,8 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do it "has link to the delayed job's action" do find('[data-testid="pipelines-manual-actions-dropdown"]').click + wait_for_requests + time_diff = [0, delayed_job.scheduled_at - Time.zone.now].max expect(page).to have_button('delayed job 1') expect(page).to have_content(Time.at(time_diff).utc.strftime("%H:%M:%S")) @@ -340,6 +345,8 @@ RSpec.describe 'Pipelines', :js, feature_category: :projects do it "shows 00:00:00 as the remaining time" do find('[data-testid="pipelines-manual-actions-dropdown"]').click + wait_for_requests + expect(page).to have_content("00:00:00") end end diff --git a/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb b/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb index 57aa3a56c6d..bdfe6a06dd1 100644 --- a/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb +++ b/spec/features/projects/settings/registry_settings_cleanup_tags_spec.rb @@ -32,10 +32,10 @@ feature_category: :projects do it 'shows available section' do subject - expect(find('.breadcrumbs')).to have_content('Clean up image tags') + expect(find('.breadcrumbs')).to have_content('Cleanup policies') section = find('[data-testid="container-expiration-policy-project-settings"]') - expect(section).to have_text 'Clean up image tags' + expect(section).to have_text 'Cleanup policies' end it 'passes axe automated accessibility testing' do diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb index 628fa23afdc..68e9b0225ea 100644 --- a/spec/features/projects/settings/registry_settings_spec.rb +++ b/spec/features/projects/settings/registry_settings_spec.rb @@ -42,10 +42,10 @@ feature_category: :projects do subject settings_block = find('[data-testid="container-expiration-policy-project-settings"]') - expect(settings_block).to have_text 'Clean up image tags' + expect(settings_block).to have_text 'Cleanup policies' end - it 'contains link to clean up image tags page' do + it 'contains link to cleanup policies page' do subject expect(page).to have_link('Edit cleanup rules', href: cleanup_image_tags_project_settings_packages_and_registries_path(project)) diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index fdd157dd09f..179ba917e7f 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -48,9 +48,9 @@ describe('dropzone_input', () => { }; beforeEach(() => { - loadHTMLFixture('issues/new-issue.html'); + loadHTMLFixture('milestones/new-milestone.html'); - form = $('#new_issue'); + form = $('#new_milestone'); form.data('uploads-path', TEST_UPLOAD_PATH); dropzoneInput(form); }); diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb index 1e6baf30a76..e85e683b599 100644 --- a/spec/frontend/fixtures/issues.rb +++ b/spec/frontend/fixtures/issues.rb @@ -20,15 +20,6 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', :with_licens remove_repository(project) end - it 'issues/new-issue.html' do - get :new, params: { - namespace_id: project.namespace.to_param, - project_id: project - } - - expect(response).to be_successful - end - it 'issues/open-issue.html' do render_issue(create(:issue, project: project)) end diff --git a/spec/frontend/fixtures/milestones.rb b/spec/frontend/fixtures/milestones.rb new file mode 100644 index 00000000000..5e39dcf190a --- /dev/null +++ b/spec/frontend/fixtures/milestones.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::MilestonesController, '(JavaScript fixtures)', :with_license, feature_category: :team_planning, type: :controller do + include JavaScriptFixturesHelpers + + let_it_be(:user) { create(:user, feed_token: 'feedtoken:coldfeed') } + let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') } + let_it_be(:project) { create(:project_empty_repo, namespace: namespace, path: 'milestones-project') } + + render_views + + before do + project.add_maintainer(user) + sign_in(user) + end + + after do + remove_repository(project) + end + + it 'milestones/new-milestone.html' do + get :new, params: { + namespace_id: project.namespace.to_param, + project_id: project + } + + expect(response).to be_successful + end + + private + + def render_milestone(milestone) + get :show, params: { + namespace_id: project.namespace.to_param, + project_id: project, + id: milestone.to_param + } + + expect(response).to be_successful + end +end diff --git a/spec/frontend/fixtures/pipelines.rb b/spec/frontend/fixtures/pipelines.rb index 768934d6278..24a6f6f7de6 100644 --- a/spec/frontend/fixtures/pipelines.rb +++ b/spec/frontend/fixtures/pipelines.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :controller do + include ApiHelpers + include GraphqlHelpers include JavaScriptFixturesHelpers let_it_be(:namespace) { create(:namespace, name: 'frontend-fixtures') } @@ -56,4 +58,27 @@ RSpec.describe Projects::PipelinesController, '(JavaScript fixtures)', type: :co expect(response).to be_successful end + + describe GraphQL::Query, type: :request do + fixtures_path = 'graphql/pipelines/' + get_pipeline_actions_query = 'get_pipeline_actions.query.graphql' + + let!(:pipeline_with_manual_actions) { create(:ci_pipeline, project: project, user: user) } + let!(:build_scheduled) { create(:ci_build, :scheduled, pipeline: pipeline_with_manual_actions, stage: 'test') } + let!(:build_manual) { create(:ci_build, :manual, pipeline: pipeline_with_manual_actions, stage: 'build') } + let!(:build_manual_cannot_play) do + create(:ci_build, :manual, :skipped, pipeline: pipeline_with_manual_actions, stage: 'build') + end + + let_it_be(:query) do + get_graphql_query_as_string("pipelines/graphql/queries/#{get_pipeline_actions_query}") + end + + it "#{fixtures_path}#{get_pipeline_actions_query}.json" do + post_graphql(query, current_user: user, + variables: { fullPath: project.full_path, iid: pipeline_with_manual_actions.iid }) + + expect_graphql_errors_to_be_empty + end + end end diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js index 3e778e50fb8..baab95cb5e5 100644 --- a/spec/frontend/issuable/issuable_form_spec.js +++ b/spec/frontend/issuable/issuable_form_spec.js @@ -21,7 +21,6 @@ describe('IssuableForm', () => { <form> <input name="[title]" /> <input type="checkbox" class="js-toggle-draft" /> - <textarea name="[description]"></textarea> </form> `); $form = $('form'); @@ -35,16 +34,13 @@ describe('IssuableForm', () => { describe('autosave', () => { let $title; - let $description; beforeEach(() => { $title = $form.find('input[name*="[title]"]').get(0); - $description = $form.find('textarea[name*="[description]"]').get(0); }); afterEach(() => { $title = null; - $description = null; }); describe('initAutosave', () => { @@ -64,11 +60,6 @@ describe('IssuableForm', () => { ['/foo', 'bar=true', 'title'], 'autosave//foo/bar=true=title', ); - expect(Autosave).toHaveBeenCalledWith( - $description, - ['/foo', 'bar=true', 'description'], - 'autosave//foo/bar=true=description', - ); }); it("creates autosave fields without the searchTerm if it's an issue new form", () => { @@ -81,11 +72,6 @@ describe('IssuableForm', () => { ['/issues/new', '', 'title'], 'autosave//issues/new/bar=true=title', ); - expect(Autosave).toHaveBeenCalledWith( - $description, - ['/issues/new', '', 'description'], - 'autosave//issues/new/bar=true=description', - ); }); it.each([ @@ -116,13 +102,12 @@ describe('IssuableForm', () => { }); describe('resetAutosave', () => { - it('calls reset on title and description', () => { + it('calls reset on title', () => { instance = createIssuable($form); instance.resetAutosave(); expect(instance.autosaves.get('title').reset).toHaveBeenCalledTimes(1); - expect(instance.autosaves.get('description').reset).toHaveBeenCalledTimes(1); }); it('resets autosave when submit', () => { diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index 5c145ed4707..c7116f380a1 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -81,11 +81,8 @@ describe('Description field component', () => { autofocus: true, supportsQuickActions: true, quickActionsDocsPath: expect.any(String), - }); - - expect(findMarkdownEditor().vm.$attrs).toMatchObject({ - 'enable-autocomplete': true, - 'markdown-docs-path': '/', + markdownDocsPath: '/', + enableAutocomplete: true, }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js index 9e443234c34..2fea0a9199b 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js @@ -247,7 +247,7 @@ describe('Details Header', () => { expect(findCleanup().props('icon')).toBe('expire'); }); - it('when the expiration policy is disabled', async () => { + it('when cleanup is not scheduled', async () => { mountComponent(); await waitForMetadataItems(); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js index 45304cc2329..b7f3698e155 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -81,7 +81,7 @@ describe('registry_header', () => { }); }); - describe('expiration policy', () => { + describe('cleanup policy', () => { it('when is disabled', async () => { await mountComponent({ expirationPolicy: { enabled: false }, @@ -111,11 +111,11 @@ describe('registry_header', () => { const cleanupLink = findSetupCleanUpLink(); expect(text.exists()).toBe(true); - expect(text.props('text')).toBe('Expiration policy will run in '); + expect(text.props('text')).toBe('Cleanup will run in '); expect(cleanupLink.exists()).toBe(true); expect(cleanupLink.text()).toBe(SET_UP_CLEANUP); }); - it('when the expiration policy is completely disabled', async () => { + it('when the cleanup policy is not scheduled', async () => { await mountComponent({ expirationPolicy: { enabled: true }, expirationPolicyHelpPagePath: 'foo', diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js index 54655acdf2a..12425909454 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js @@ -79,7 +79,7 @@ describe('Registry Settings app', () => { ${false} | ${true} ${false} | ${false} `( - 'container expiration policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings', + 'container cleanup policy $showContainerRegistrySettings and package cleanup policy is $showPackageRegistrySettings', ({ showContainerRegistrySettings, showPackageRegistrySettings }) => { mountComponent({ showContainerRegistrySettings, diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 1be4a974f7a..c6ca1b10dc9 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -116,6 +116,7 @@ describe('WikiForm', () => { renderMarkdownPath: pageInfoPersisted.markdownPreviewPath, uploadsPath: pageInfoPersisted.uploadsPath, autofocus: pageInfoPersisted.persisted, + markdownDocsPath: pageInfoPersisted.markdownHelpPath, }), ); @@ -123,10 +124,6 @@ describe('WikiForm', () => { id: 'wiki_content', name: 'wiki[content]', }); - - expect(markdownEditor.vm.$attrs['markdown-docs-path']).toEqual( - pageInfoPersisted.markdownHelpPath, - ); }); it.each` diff --git a/spec/frontend/pipelines/pipeline_operations_spec.js b/spec/frontend/pipelines/pipeline_operations_spec.js new file mode 100644 index 00000000000..15fc23e8b54 --- /dev/null +++ b/spec/frontend/pipelines/pipeline_operations_spec.js @@ -0,0 +1,101 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; +import PipelinesManualActionsLegacy from '~/pipelines/components/pipelines_list/pipelines_manual_actions_legacy.vue'; +import PipelineMultiActions from '~/pipelines/components/pipelines_list/pipeline_multi_actions.vue'; +import PipelineOperations from '~/pipelines/components/pipelines_list/pipeline_operations.vue'; +import eventHub from '~/pipelines/event_hub'; + +describe('Pipeline operations', () => { + let wrapper; + + const defaultProps = { + pipeline: { + id: 329, + iid: 234, + details: { + has_manual_actions: true, + has_scheduled_actions: false, + manual_actions: [ + { + name: 'dont-interrupt-me', + path: '/root/ci-project/-/jobs/3974323562/play', + playable: true, + scheduled: false, + }, + ], + scheduled_actions: [], + }, + flags: { + retryable: true, + cancelable: true, + }, + cancel_path: '/root/ci-project/-/pipelines/329/cancel', + retry_path: '/root/ci-project/-/pipelines/329/retry', + }, + }; + + const createComponent = (props = defaultProps, flagState = true) => { + wrapper = shallowMountExtended(PipelineOperations, { + provide: { + glFeatures: { + lazyLoadPipelineDropdownActions: flagState, + }, + }, + propsData: { + ...props, + }, + }); + }; + + const findLegacyManualActions = () => wrapper.findComponent(PipelinesManualActionsLegacy); + const findManualActions = () => wrapper.findComponent(PipelinesManualActions); + const findMultiActions = () => wrapper.findComponent(PipelineMultiActions); + const findRetryBtn = () => wrapper.findByTestId('pipelines-retry-button'); + const findCancelBtn = () => wrapper.findByTestId('pipelines-cancel-button'); + + it('should display pipeline manual actions', () => { + createComponent(); + + expect(findManualActions().exists()).toBe(true); + expect(findLegacyManualActions().exists()).toBe(false); + }); + + it('should display legacy pipeline manual actions', () => { + createComponent(defaultProps, false); + + expect(findLegacyManualActions().exists()).toBe(true); + expect(findManualActions().exists()).toBe(false); + }); + + it('should display pipeline multi actions', () => { + createComponent(); + + expect(findMultiActions().exists()).toBe(true); + }); + + describe('events', () => { + beforeEach(() => { + createComponent(); + + jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); + }); + + it('should emit retryPipeline event', () => { + findRetryBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith( + 'retryPipeline', + defaultProps.pipeline.retry_path, + ); + }); + + it('should emit openConfirmationModal event', () => { + findCancelBtn().vm.$emit('click'); + + expect(eventHub.$emit).toHaveBeenCalledWith('openConfirmationModal', { + pipeline: defaultProps.pipeline, + endpoint: defaultProps.pipeline.cancel_path, + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_manual_actions_legacy_spec.js index 2db9f5c2a83..50ff301060b 100644 --- a/spec/frontend/pipelines/pipelines_actions_spec.js +++ b/spec/frontend/pipelines/pipelines_manual_actions_legacy_spec.js @@ -9,7 +9,7 @@ import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; +import PipelinesManualActionsLegacy from '~/pipelines/components/pipelines_list/pipelines_manual_actions_legacy.vue'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import { TRACKING_CATEGORIES } from '~/pipelines/constants'; @@ -21,7 +21,7 @@ describe('Pipelines Actions dropdown', () => { let mock; const createComponent = (props, mountFn = shallowMount) => { - wrapper = mountFn(PipelinesManualActions, { + wrapper = mountFn(PipelinesManualActionsLegacy, { propsData: { ...props, }, diff --git a/spec/frontend/pipelines/pipelines_manual_actions_spec.js b/spec/frontend/pipelines/pipelines_manual_actions_spec.js new file mode 100644 index 00000000000..e9695d57f93 --- /dev/null +++ b/spec/frontend/pipelines/pipelines_manual_actions_spec.js @@ -0,0 +1,216 @@ +import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import mockPipelineActionsQueryResponse from 'test_fixtures/graphql/pipelines/get_pipeline_actions.query.graphql.json'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import PipelinesManualActions from '~/pipelines/components/pipelines_list/pipelines_manual_actions.vue'; +import getPipelineActionsQuery from '~/pipelines/graphql/queries/get_pipeline_actions.query.graphql'; +import { TRACKING_CATEGORIES } from '~/pipelines/constants'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; + +Vue.use(VueApollo); + +jest.mock('~/alert'); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); + +describe('Pipeline manual actions', () => { + let wrapper; + let mock; + + const queryHandler = jest.fn().mockResolvedValue(mockPipelineActionsQueryResponse); + const { + data: { + project: { + pipeline: { + jobs: { nodes }, + }, + }, + }, + } = mockPipelineActionsQueryResponse; + + const mockPath = nodes[2].playPath; + + const createComponent = (limit = 50) => { + wrapper = shallowMountExtended(PipelinesManualActions, { + provide: { + fullPath: 'root/ci-project', + manualActionsLimit: limit, + }, + propsData: { + iid: 100, + }, + stubs: { + GlDropdown, + }, + apolloProvider: createMockApollo([[getPipelineActionsQuery, queryHandler]]), + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findAllCountdowns = () => wrapper.findAllComponents(GlCountdown); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findLimitMessage = () => wrapper.findByTestId('limit-reached-msg'); + + it('skips calling query on mount', () => { + createComponent(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + + describe('loading', () => { + beforeEach(() => { + createComponent(); + + findDropdown().vm.$emit('shown'); + }); + + it('display loading state while actions are being fetched', async () => { + expect(findAllDropdownItems().at(0).text()).toBe('Loading...'); + expect(findLoadingIcon().exists()).toBe(true); + expect(findAllDropdownItems()).toHaveLength(1); + }); + }); + + describe('loaded', () => { + beforeEach(async () => { + mock = new MockAdapter(axios); + + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + }); + + afterEach(() => { + mock.restore(); + confirmAction.mockReset(); + }); + + it('displays dropdown with the provided actions', () => { + expect(findAllDropdownItems()).toHaveLength(3); + }); + + it("displays a disabled action when it's not playable", () => { + expect(findAllDropdownItems().at(0).attributes('disabled')).toBe('true'); + }); + + describe('on action click', () => { + it('makes a request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + }); + + it('makes a failed request and toggles the loading state', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR); + + findAllDropdownItems().at(1).vm.$emit('click'); + + await nextTick(); + + expect(findDropdown().props('loading')).toBe(true); + + await waitForPromises(); + + expect(findDropdown().props('loading')).toBe(false); + expect(createAlert).toHaveBeenCalledTimes(1); + }); + }); + + describe('tracking', () => { + afterEach(() => { + unmockTracking(); + }); + + it('tracks manual actions click', () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findDropdown().vm.$emit('shown'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_manual_actions', { + label: TRACKING_CATEGORIES.table, + }); + }); + }); + + describe('scheduled jobs', () => { + beforeEach(() => { + jest + .spyOn(Date, 'now') + .mockImplementation(() => new Date('2063-04-04T00:42:00Z').getTime()); + }); + + it('makes post request after confirming', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(true); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(1); + }); + + it('does not make post request if confirmation is cancelled', async () => { + mock.onPost(mockPath).reply(HTTP_STATUS_OK); + + confirmAction.mockResolvedValueOnce(false); + + findAllDropdownItems().at(2).vm.$emit('click'); + + expect(confirmAction).toHaveBeenCalled(); + + await waitForPromises(); + + expect(mock.history.post).toHaveLength(0); + }); + + it('displays the remaining time in the dropdown', () => { + expect(findAllCountdowns().at(0).props('endDateString')).toBe(nodes[2].scheduledAt); + }); + }); + }); + + describe('limit message', () => { + it('limit message does not show', async () => { + createComponent(); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(false); + }); + + it('limit message does show', async () => { + createComponent(3); + + findDropdown().vm.$emit('shown'); + + await waitForPromises(); + + expect(findLimitMessage().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index 3482d011be9..099c45ac683 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -116,10 +116,7 @@ describe('WorkItemDescription', () => { supportsQuickActions: true, renderMarkdownPath: markdownPreviewPath(fullPath, iid), quickActionsDocsPath: wrapper.vm.$options.quickActionsDocsPath, - }); - - expect(findMarkdownEditor().vm.$attrs).toMatchObject({ - 'autocomplete-data-sources': autocompleteDataSources(fullPath, iid), + autocompleteDataSources: autocompleteDataSources(fullPath, iid), }); }); }); diff --git a/spec/graphql/mutations/achievements/update_spec.rb b/spec/graphql/mutations/achievements/update_spec.rb new file mode 100644 index 00000000000..b69c8bef478 --- /dev/null +++ b/spec/graphql/mutations/achievements/update_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Update, feature_category: :user_profile do + include GraphqlHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:recipient) { create(:user) } + let_it_be(:group) { create(:group) } + + let(:achievement) { create(:achievement, namespace: group) } + let(:name) { 'Hero' } + + describe '#resolve' do + subject(:resolve_mutation) do + described_class.new(object: nil, context: { current_user: current_user }, field: nil).resolve( + achievement_id: achievement&.to_global_id, name: name + ) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it 'raises an error' do + expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR) + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:achievement) { nil } + + it 'returns the validation error' do + expect { resolve_mutation }.to raise_error { Gitlab::Graphql::Errors::ArgumentError } + end + end + + it 'updates the achievement' do + resolve_mutation + + expect(Achievements::Achievement.find_by(id: achievement.id).name).to eq(name) + end + end + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_achievement) } +end diff --git a/spec/helpers/ci/pipelines_helper_spec.rb b/spec/helpers/ci/pipelines_helper_spec.rb index 535e8f3170e..e3cfe5d6f46 100644 --- a/spec/helpers/ci/pipelines_helper_spec.rb +++ b/spec/helpers/ci/pipelines_helper_spec.rb @@ -121,7 +121,8 @@ RSpec.describe Ci::PipelinesHelper do :has_gitlab_ci, :pipeline_editor_path, :suggested_ci_templates, - :ci_runner_settings_path]) + :ci_runner_settings_path, + :full_path]) end describe 'the `any_runners_available` attribute' do diff --git a/spec/requests/api/graphql/mutations/achievements/update_spec.rb b/spec/requests/api/graphql/mutations/achievements/update_spec.rb new file mode 100644 index 00000000000..b2bb01b564c --- /dev/null +++ b/spec/requests/api/graphql/mutations/achievements/update_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Achievements::Update, feature_category: :user_profile do + include GraphqlHelpers + include WorkhorseHelpers + + let_it_be(:developer) { create(:user) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:group) { create(:group) } + + let!(:achievement) { create(:achievement, namespace: group) } + let(:mutation) { graphql_mutation(:achievements_update, params) } + let(:achievement_id) { achievement&.to_global_id } + let(:params) { { achievement_id: achievement_id, name: 'GitLab', avatar: avatar } } + let(:avatar) { nil } + + subject { post_graphql_mutation_with_uploads(mutation, current_user: current_user) } + + def mutation_response + graphql_mutation_response(:achievements_update) + end + + before_all do + group.add_developer(developer) + group.add_maintainer(maintainer) + end + + context 'when the user does not have permission' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not update the achievement' do + expect { subject }.not_to change { achievement.reload.name } + end + end + + context 'when the user has permission' do + let(:current_user) { maintainer } + + context 'when the params are invalid' do + let(:achievement) { nil } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s).to include('invalid value for achievementId (Expected value to not be null)') + end + end + + context 'when the achievement_id is invalid' do + let(:achievement_id) { "gid://gitlab/Achievements::Achievement/#{non_existing_record_id}" } + + it 'returns the validation error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'when the feature flag is disabled' do + before do + stub_feature_flags(achievements: false) + end + + it 'returns the relevant permission error' do + subject + + expect(graphql_errors.to_s) + .to include("The resource that you are attempting to access does not exist or you don't have permission") + end + end + + context 'with a new avatar' do + let(:avatar) { fixture_file_upload("spec/fixtures/dk.png") } + + it 'updates the achievement' do + subject + + achievement.reload + + expect(achievement.name).to eq('GitLab') + expect(achievement.avatar.file).not_to be_nil + end + end + end +end diff --git a/spec/services/achievements/update_service_spec.rb b/spec/services/achievements/update_service_spec.rb new file mode 100644 index 00000000000..6168d60450b --- /dev/null +++ b/spec/services/achievements/update_service_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Achievements::UpdateService, feature_category: :user_profile do + describe '#execute' do + let_it_be(:user) { create(:user) } + + let(:params) { attributes_for(:achievement, namespace: group) } + + subject(:response) { described_class.new(user, group, params).execute } + + context 'when user does not have permission' do + let_it_be(:group) { create(:group) } + let_it_be(:achievement) { create(:achievement, namespace: group) } + + before_all do + group.add_developer(user) + end + + it 'returns an error' do + expect(response).to be_error + expect(response.message).to match_array( + ['You have insufficient permission to update this achievement']) + end + end + + context 'when user has permission' do + let_it_be(:group) { create(:group) } + let_it_be(:achievement) { create(:achievement, namespace: group) } + + before_all do + group.add_maintainer(user) + end + + it 'updates an achievement' do + expect(response).to be_success + end + + it 'returns an error when the achievement cannot be updated' do + params[:name] = nil + + expect(response).to be_error + expect(response.message).to include("Name can't be blank") + end + end + end +end diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb index 40d76c7d5d2..2ae18f1969f 100644 --- a/spec/support/shared_examples/features/content_editor_shared_examples.rb +++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb @@ -14,7 +14,11 @@ RSpec.shared_examples 'edits content using the content editor' do wait_until_hidden_field_is_updated /Typing text in the content editor/ - refresh + begin + refresh + rescue Selenium::WebDriver::Error::UnexpectedAlertOpenError + page.driver.browser.switch_to.alert.dismiss + end expect(page).to have_text('Typing text in the content editor') end diff --git a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb index ea6d1655694..d2dfb468485 100644 --- a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb +++ b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb @@ -77,7 +77,7 @@ RSpec.shared_examples 'an editable merge request' do expect(page).to have_selector('.js-quick-submit') end - it 'warns about version conflict' do + it 'warns about version conflict', :js do merge_request.update!(title: "New title") fill_in 'merge_request_title', with: 'bug 345' diff --git a/spec/views/projects/merge_requests/edit.html.haml_spec.rb b/spec/views/projects/merge_requests/edit.html.haml_spec.rb index 75956160c0a..8774623d07e 100644 --- a/spec/views/projects/merge_requests/edit.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/edit.html.haml_spec.rb @@ -43,7 +43,7 @@ RSpec.describe 'projects/merge_requests/edit.html.haml' do render expect(rendered).to have_field('merge_request[title]') - expect(rendered).to have_field('merge_request[description]') + expect(rendered).to have_selector('input[name="merge_request[description]"]', visible: false) expect(rendered).to have_selector('input[name="merge_request[label_ids][]"]', visible: false) expect(rendered).to have_selector('.js-milestone-dropdown-root') expect(rendered).not_to have_selector('#merge_request_target_branch', visible: false) @@ -55,7 +55,7 @@ RSpec.describe 'projects/merge_requests/edit.html.haml' do render expect(rendered).to have_field('merge_request[title]') - expect(rendered).to have_field('merge_request[description]') + expect(rendered).to have_selector('input[name="merge_request[description]"]', visible: false) expect(rendered).to have_selector('input[name="merge_request[label_ids][]"]', visible: false) expect(rendered).to have_selector('.js-milestone-dropdown-root') expect(rendered).to have_selector('#merge_request_target_branch', visible: false) diff --git a/workhorse/go.mod b/workhorse/go.mod index 0b4027e0b5b..ed1ec085cae 100644 --- a/workhorse/go.mod +++ b/workhorse/go.mod @@ -7,7 +7,7 @@ require ( github.com/BurntSushi/toml v1.2.1 github.com/FZambia/sentinel v1.1.1 github.com/alecthomas/chroma/v2 v2.7.0 - github.com/aws/aws-sdk-go v1.44.227 + github.com/aws/aws-sdk-go v1.44.228 github.com/disintegration/imaging v1.6.2 github.com/getsentry/raven-go v0.2.0 github.com/golang-jwt/jwt/v4 v4.5.0 diff --git a/workhorse/go.sum b/workhorse/go.sum index 67f8f07275e..7282e8b7613 100644 --- a/workhorse/go.sum +++ b/workhorse/go.sum @@ -570,8 +570,8 @@ github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4 github.com/aws/aws-sdk-go v1.44.156/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go v1.44.187/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go v1.44.200/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= -github.com/aws/aws-sdk-go v1.44.227 h1:HWNpINBu20yyfEXGHHSIsB955KUjWmZJETqnLIXizN4= -github.com/aws/aws-sdk-go v1.44.227/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go v1.44.228 h1:CkkAlgNFf7qPZy/bAssF6lafR/ThMiiwKQEHVfPJixc= +github.com/aws/aws-sdk-go v1.44.228/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go-v2 v1.9.1/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= github.com/aws/aws-sdk-go-v2 v1.17.4 h1:wyC6p9Yfq6V2y98wfDsj6OnNQa4w2BLGCLIxzNhwOGY= github.com/aws/aws-sdk-go-v2 v1.17.4/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= |