diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 11:43:02 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 11:43:02 +0300 |
commit | d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb (patch) | |
tree | 2341ef426af70ad1e289c38036737e04b0aa5007 /app/assets/javascripts/vue_merge_request_widget | |
parent | d6e514dd13db8947884cd58fe2a9c2a063400a9b (diff) |
Add latest changes from gitlab-org/gitlab@14-4-stable-eev14.4.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_merge_request_widget')
18 files changed, 547 insertions, 139 deletions
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 ea73ab416de..0c4a5ee35d9 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 @@ -35,13 +35,17 @@ export default { } if (!this.rulesLeft.length) { - return n__('Requires approval.', 'Requires %d more approvals.', this.approvalsLeft); + return n__( + 'Requires %d approval from eligible users.', + 'Requires %d approvals from eligible users.', + this.approvalsLeft, + ); } return sprintf( n__( - 'Requires approval from %{names}.', - 'Requires %{count} more approvals from %{names}.', + 'Requires %{count} approval from %{names}.', + 'Requires %{count} approvals from %{names}.', this.approvalsLeft, ), { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue new file mode 100644 index 00000000000..023367a794e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/actions.vue @@ -0,0 +1,70 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; + +export default { + components: { + GlButton, + GlDropdown, + GlDropdownItem, + }, + props: { + widget: { + type: String, + required: true, + }, + tertiaryButtons: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + dropdownLabel() { + return sprintf(__('%{widget} options'), { widget: this.widget }); + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown + v-if="tertiaryButtons" + :text="dropdownLabel" + icon="ellipsis_v" + no-caret + category="tertiary" + right + lazy + text-sr-only + size="small" + toggle-class="gl-p-2!" + class="gl-display-block gl-md-display-none!" + > + <gl-dropdown-item + v-for="(btn, index) in tertiaryButtons" + :key="index" + :href="btn.href" + :target="btn.target" + > + {{ btn.text }} + </gl-dropdown-item> + </gl-dropdown> + <template v-if="tertiaryButtons.length"> + <gl-button + v-for="(btn, index) in tertiaryButtons" + :key="index" + :href="btn.href" + :target="btn.target" + :class="{ 'gl-mr-3': index > 1 }" + category="tertiary" + variant="confirm" + size="small" + class="gl-display-none gl-md-display-block" + > + {{ btn.text }} + </gl-button> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 0ac98f6c982..298f7c7ad8c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -1,7 +1,18 @@ <script> -import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui'; +import { + GlButton, + GlLoadingIcon, + GlLink, + GlBadge, + GlSafeHtmlDirective, + GlTooltipDirective, + GlIntersectionObserver, +} from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; -import StatusIcon from '../mr_widget_status_icon.vue'; +import { EXTENSION_ICON_CLASS } from '../../constants'; +import StatusIcon from './status_icon.vue'; +import Actions from './actions.vue'; export const LOADING_STATES = { collapsedLoading: 'collapsedLoading', @@ -13,14 +24,16 @@ export default { components: { GlButton, GlLoadingIcon, - GlIcon, GlLink, GlBadge, + GlIntersectionObserver, SmartVirtualList, StatusIcon, + Actions, }, directives: { SafeHtml: GlSafeHtmlDirective, + GlTooltip: GlTooltipDirective, }, data() { return { @@ -28,9 +41,16 @@ export default { collapsedData: null, fullData: null, isCollapsed: true, + showFade: false, }; }, computed: { + widgetLabel() { + return this.$options.i18n?.label || this.$options.name; + }, + widgetLoadingText() { + return this.$options.i18n?.loading || __('Loading...'); + }, isLoadingSummary() { return this.loadingState === LOADING_STATES.collapsedLoading; }, @@ -44,17 +64,22 @@ export default { return true; }, + collapseButtonLabel() { + return sprintf( + this.isCollapsed + ? s__('mrWidget|Show %{widget} details') + : s__('mrWidget|Hide %{widget} details'), + { widget: this.widgetLabel }, + ); + }, statusIconName() { - if (this.isLoadingSummary) { - return 'loading'; - } - - if (this.loadingState === LOADING_STATES.collapsedError) { - return 'warning'; - } + if (this.isLoadingSummary) return null; return this.statusIcon(this.collapsedData); }, + tertiaryActionsButtons() { + return this.tertiaryButtons ? this.tertiaryButtons() : undefined; + }, }, watch: { isCollapsed(newVal) { @@ -95,32 +120,59 @@ export default { throw e; }); }, + appear(index) { + if (index === this.fullData.length - 1) { + this.showFade = false; + } + }, + disappear(index) { + if (index === this.fullData.length - 1) { + this.showFade = true; + } + }, }, + EXTENSION_ICON_CLASS, }; </script> <template> - <section class="media-section mr-widget-border-top"> + <section class="media-section" data-testid="widget-extension"> <div class="media gl-p-5"> - <status-icon :status="statusIconName" class="align-self-center" /> - <div class="media-body d-flex flex-align-self-center align-items-center"> - <div class="code-text"> - <template v-if="isLoadingSummary"> - {{ __('Loading...') }} - </template> + <status-icon + :name="$options.label || $options.name" + :is-loading="isLoadingSummary" + :icon-name="statusIconName" + /> + <div class="media-body gl-display-flex gl-flex-direction-row!"> + <div class="gl-flex-grow-1"> + <template v-if="isLoadingSummary">{{ widgetLoadingText }}</template> <div v-else v-safe-html="summary(collapsedData)"></div> </div> - <gl-button - v-if="isCollapsible" - size="small" - class="float-right align-self-center" - @click="toggleCollapsed" - > - {{ isCollapsed ? __('Expand') : __('Collapse') }} - </gl-button> + <actions + :widget="$options.label || $options.name" + :tertiary-buttons="tertiaryActionsButtons" + /> + <div 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-if="isCollapsible" + v-gl-tooltip + :title="collapseButtonLabel" + :aria-expanded="`${!isCollapsed}`" + :aria-label="collapseButtonLabel" + :icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'" + category="tertiary" + data-testid="toggle-button" + size="small" + @click="toggleCollapsed" + /> + </div> </div> </div> - <div v-if="!isCollapsed" class="mr-widget-grouped-section"> + <div + v-if="!isCollapsed" + class="mr-widget-grouped-section gl-relative" + data-testid="widget-extension-collapsed-section" + > <div v-if="isLoadingExpanded" class="report-block-container"> <gl-loading-icon size="sm" inline /> {{ __('Loading...') }} </div> @@ -131,27 +183,38 @@ export default { :size="32" wtag="ul" wclass="report-block-list" - class="report-block-container" + class="report-block-container gl-px-5 gl-py-0" > - <li v-for="data in fullData" :key="data.id" class="d-flex align-items-center"> - <div v-if="data.icon" :class="data.icon.class" class="d-flex"> - <gl-icon :name="data.icon.name" :size="24" /> - </div> - <div - class="gl-mt-2 gl-mb-2 align-content-around align-items-start flex-wrap align-self-center d-flex" + <li + v-for="(data, index) in fullData" + :key="data.id" + :class="{ + 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': index !== fullData.length - 1, + }" + class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7" + data-testid="extension-list-item" + > + <status-icon v-if="data.icon" :icon-name="data.icon.name" :size="12" /> + <gl-intersection-observer + :options="{ rootMargin: '100px', thresholds: 0.1 }" + class="gl-flex-wrap gl-align-self-center gl-display-flex" + @appear="appear(index)" + @disappear="disappear(index)" > - <div class="gl-mr-4"> - {{ data.text }} - </div> + <div v-safe-html="data.text" class="gl-mr-4"></div> <div v-if="data.link"> <gl-link :href="data.link.href">{{ data.link.text }}</gl-link> </div> <gl-badge v-if="data.badge" :variant="data.badge.variant || 'info'"> {{ data.badge.text }} </gl-badge> - </div> + </gl-intersection-observer> </li> </smart-virtual-list> + <div + :class="{ show: showFade }" + class="fade mr-extenson-scrim gl-absolute gl-left-0 gl-bottom-0 gl-w-full gl-h-7" + ></div> </div> </section> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js index 529160de6a7..b9dfd3bd41e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js @@ -1,4 +1,5 @@ -import { extensions } from './index'; +import { __ } from '~/locale'; +import { registeredExtensions } from './index'; export default { props: { @@ -8,20 +9,46 @@ export default { }, }, render(h) { + const { extensions } = registeredExtensions; + + if (extensions.length === 0) return null; + return h( 'div', - {}, - extensions.map((extension) => - h(extension, { - props: extensions[0].props.reduce( - (acc, key) => ({ - ...acc, - [key]: this.mr[key], - }), - {}, - ), - }), - ), + { + attrs: { + role: 'region', + 'aria-label': __('Merge request reports'), + }, + }, + [ + h( + 'ul', + { + class: 'gl-p-0 gl-m-0 gl-list-style-none', + }, + [ + ...extensions.map((extension, index) => + h('li', { attrs: { class: index > 0 && 'mr-widget-border-top' } }, [ + h( + { ...extension }, + { + props: { + ...extension.props.reduce( + (acc, key) => ({ + ...acc, + [key]: this.mr[key], + }), + {}, + ), + }, + }, + ), + ]), + ), + ], + ), + ], ); }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js index 9796bb44939..4ca0b660696 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js @@ -1,15 +1,17 @@ +import Vue from 'vue'; import ExtensionBase from './base.vue'; // Holds all the currently registered extensions -export const extensions = []; +export const registeredExtensions = Vue.observable({ extensions: [] }); export const registerExtension = (extension) => { // Pushes into the extenions array a dynamically created Vue component // that gets exteneded from `base.vue` - extensions.push({ + registeredExtensions.extensions.push({ extends: ExtensionBase, name: extension.name, props: extension.props, + i18n: extension.i18n, computed: { ...Object.keys(extension.computed).reduce( (acc, computedKey) => ({ diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue new file mode 100644 index 00000000000..01d8de132e7 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue @@ -0,0 +1,61 @@ +<script> +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { EXTENSION_ICON_CLASS, EXTENSION_ICON_NAMES } from '../../constants'; + +export default { + components: { + GlLoadingIcon, + GlIcon, + }, + props: { + name: { + type: String, + required: false, + default: '', + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + iconName: { + type: String, + required: false, + default: null, + }, + size: { + type: Number, + required: false, + default: 16, + }, + }, + computed: { + iconAriaLabel() { + return `${capitalizeFirstCharacter(this.iconName)} ${this.name}`; + }, + }, + EXTENSION_ICON_NAMES, + EXTENSION_ICON_CLASS, +}; +</script> + +<template> + <div + :class="[ + $options.EXTENSION_ICON_CLASS[iconName], + { 'mr-widget-extension-icon': !isLoading && size === 16 }, + { 'gl-p-2': isLoading || size === 16 }, + ]" + class="gl-rounded-full gl-mr-3 gl-relative gl-p-2" + > + <gl-loading-icon v-if="isLoading" size="md" inline class="gl-display-block" /> + <gl-icon + v-else + :name="$options.EXTENSION_ICON_NAMES[iconName]" + :size="size" + :aria-label="iconAriaLabel" + class="gl-display-block" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 966262944ad..5c67b9c7ab5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -10,7 +10,7 @@ import { GlSafeHtmlDirective as SafeHtml, GlSprintf, } from '@gitlab/ui'; -import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility'; +import { constructWebIDEPath } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import clipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; @@ -58,15 +58,7 @@ export default { }); }, webIdePath() { - return mergeUrlParams( - { - target_project: - this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath - ? this.mr.targetProjectFullPath - : '', - }, - webIDEUrl(`/${this.mr.sourceProjectFullPath}/merge_requests/${this.mr.iid}`), - ); + return constructWebIDEPath(this.mr); }, isFork() { return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath; @@ -79,7 +71,7 @@ export default { }; </script> <template> - <div class="d-flex mr-source-target gl-mb-3"> + <div class="gl-display-flex mr-source-target"> <mr-widget-icon name="git-merge" /> <div class="git-merge-container d-flex"> <div class="normal"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue index 7532eabee8a..68cff1368af 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue @@ -1,6 +1,7 @@ <script> /* eslint-disable @gitlab/require-i18n-strings */ import { GlModal, GlLink, GlSprintf } from '@gitlab/ui'; +import { escapeShellString } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -75,20 +76,31 @@ export default { }, computed: { mergeInfo1() { + const escapedOriginBranch = escapeShellString(`origin/${this.sourceBranch}`); + return this.isFork - ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.sourceBranch}\ngit checkout -b "${this.sourceProjectPath}-${this.sourceBranch}" FETCH_HEAD` - : `git fetch origin\ngit checkout -b "${this.sourceBranch}" "origin/${this.sourceBranch}"`; + ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.escapedSourceBranch}\ngit checkout -b ${this.escapedForkBranch} FETCH_HEAD` + : `git fetch origin\ngit checkout -b ${this.escapedSourceBranch} ${escapedOriginBranch}`; }, mergeInfo2() { return this.isFork - ? `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceProjectPath}-${this.sourceBranch}"` - : `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceBranch}"`; + ? `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedForkBranch}` + : `git fetch origin\ngit checkout ${this.escapedTargetBranch}\ngit merge --no-ff ${this.escapedSourceBranch}`; }, mergeInfo3() { return this.canMerge - ? `git push origin "${this.targetBranch}"` + ? `git push origin ${this.escapedTargetBranch}` : __('Note that pushing to GitLab requires write access to this repository.'); }, + escapedForkBranch() { + return escapeShellString(`${this.sourceProjectPath}-${this.sourceBranch}`); + }, + escapedTargetBranch() { + return escapeShellString(this.targetBranch); + }, + escapedSourceBranch() { + return escapeShellString(this.sourceBranch); + }, }, }; </script> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index a8272002f16..a05e8747a43 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -88,6 +88,9 @@ export default { return this.mr.preferredAutoMergeStrategy; }, + ciStatus() { + return this.isPostMerge ? this.mr?.mergePipeline?.details?.status?.text : this.mr.ciStatus; + }, }, }; </script> @@ -97,7 +100,7 @@ export default { :pipeline="pipeline" :pipeline-coverage-delta="mr.pipelineCoverageDelta" :builds-with-coverage="mr.buildsWithCoverage" - :ci-status="mr.ciStatus" + :ci-status="ciStatus" :has-ci="mr.hasCI" :pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds" :source-branch="branch" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue index a55dba92e16..3ca193514f1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue @@ -1,12 +1,15 @@ <script> -/* eslint-disable @gitlab/require-string-literal-i18n-helpers */ -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlSprintf } from '@gitlab/ui'; import { escape } from 'lodash'; -import { __, n__, sprintf, s__ } from '~/locale'; +import { __, n__, s__ } from '~/locale'; + +const mergeCommitCount = s__('mrWidgetCommitsAdded|1 merge commit'); export default { + mergeCommitCount, components: { GlButton, + GlSprintf, }, props: { isSquashEnabled: { @@ -37,7 +40,7 @@ export default { return this.expanded ? 'chevron-down' : 'chevron-right'; }, commitsCountMessage() { - return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount); + return n__('%d commit', '%d commits', this.isSquashEnabled ? 1 : this.commitsCount); }, modifyLinkMessage() { if (this.isFastForwardEnabled) return __('Modify commit message'); @@ -47,22 +50,15 @@ export default { ariaLabel() { return this.expanded ? __('Collapse') : __('Expand'); }, + targetBranchEscaped() { + return escape(this.targetBranch); + }, message() { - const message = this.isFastForwardEnabled + return this.isFastForwardEnabled ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.') : s__( 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.', ); - - return sprintf( - message, - { - commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`, - mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`, - targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`, - }, - false, - ); }, }, methods: { @@ -89,10 +85,19 @@ export default { /> <span v-if="expanded">{{ __('Collapse') }}</span> <span v-else> - <span - class="vertical-align-middle" - v-html="message /* eslint-disable-line vue/no-v-html */" - ></span> + <span class="vertical-align-middle"> + <gl-sprintf :message="message"> + <template #commitCount> + <strong class="commits-count-message">{{ commitsCountMessage }}</strong> + </template> + <template #mergeCommitCount> + <strong>{{ $options.mergeCommitCount }}</strong> + </template> + <template #targetBranch> + <span class="label-branch">{{ targetBranchEscaped }}</span> + </template> + </gl-sprintf> + </span> <gl-button variant="link" class="modify-message-button"> {{ modifyLinkMessage }} </gl-button> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index 7df65e995a5..7d4bd4cf1bf 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 @@ -29,6 +29,7 @@ import { WARNING, MT_MERGE_STRATEGY, PIPELINE_FAILED_STATE, + STATE_MACHINE, } from '../../constants'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; @@ -47,6 +48,9 @@ const MERGE_FAILED_STATUS = 'failed'; const MERGE_SUCCESS_STATUS = 'success'; const MERGE_HOOK_VALIDATION_ERROR_STATUS = 'hook_validation_error'; +const { transitions } = STATE_MACHINE; +const { MERGE, MERGED, MERGE_FAILURE, AUTO_MERGE } = transitions; + export default { name: 'ReadyToMerge', apollo: { @@ -99,8 +103,8 @@ export default { GlDropdownItem, GlFormCheckbox, GlSkeletonLoader, - MergeTrainHelperText: () => - import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'), + MergeTrainHelperIcon: () => + import('ee_component/vue_merge_request_widget/components/merge_train_helper_icon.vue'), MergeImmediatelyConfirmationDialog: () => import( 'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue' @@ -234,7 +238,7 @@ export default { return CONFIRM; }, iconClass() { - if (this.shouldRenderMergeTrainHelperText && !this.mr.preventMerge) { + if (this.shouldRenderMergeTrainHelperIcon && !this.mr.preventMerge) { return PIPELINE_RUNNING_STATE; } @@ -361,6 +365,11 @@ export default { } this.isMakingRequest = true; + + if (!useAutoMerge) { + this.mr.transitionStateMachine({ transition: MERGE }); + } + this.service .merge(options) .then((res) => res.data) @@ -371,10 +380,12 @@ export default { if (AUTO_MERGE_STRATEGIES.includes(data.status)) { eventHub.$emit('MRWidgetUpdateRequested'); + this.mr.transitionStateMachine({ transition: AUTO_MERGE }); } else if (data.status === MERGE_SUCCESS_STATUS) { this.initiateMergePolling(); } else if (hasError) { eventHub.$emit('FailedToMerge', data.merge_error); + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); } if (this.glFeatures.mergeRequestWidgetGraphql) { @@ -383,6 +394,7 @@ export default { }) .catch(() => { this.isMakingRequest = false; + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); createFlash({ message: __('Something went wrong. Please try again.'), }); @@ -417,6 +429,7 @@ export default { eventHub.$emit('FetchActionsContent'); MergeRequest.hideCloseButton(); MergeRequest.decreaseCounter(); + this.mr.transitionStateMachine({ transition: MERGED }); stopPolling(); refreshUserMergeRequestCounts(); @@ -428,6 +441,7 @@ export default { } } else if (data.merge_error) { eventHub.$emit('FailedToMerge', data.merge_error); + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); stopPolling(); } else { // MR is not merged yet, continue polling until the state becomes 'merged' @@ -438,6 +452,7 @@ export default { createFlash({ message: __('Something went wrong while merging this merge request. Please try again.'), }); + this.mr.transitionStateMachine({ transition: MERGE_FAILURE }); stopPolling(); }); }, @@ -489,7 +504,7 @@ export default { </div> </div> <template v-else> - <div class="mr-widget-body media" :class="{ 'gl-pb-3': shouldRenderMergeTrainHelperText }"> + <div class="mr-widget-body media"> <status-icon :status="iconClass" /> <div class="media-body"> <div class="mr-widget-body-controls gl-display-flex gl-align-items-center"> @@ -560,6 +575,13 @@ export default { :is-disabled="isSquashReadOnly" class="gl-mx-3" /> + + <merge-train-helper-icon + v-if="shouldRenderMergeTrainHelperIcon" + :merge-train-when-pipeline-succeeds-docs-path=" + mr.mergeTrainWhenPipelineSucceedsDocsPath + " + /> </div> <template v-else> <div class="bold js-resolve-mr-widget-items-message gl-ml-3"> @@ -590,13 +612,6 @@ export default { </div> </div> </div> - <merge-train-helper-text - v-if="shouldRenderMergeTrainHelperText" - :pipeline-id="pipelineId" - :pipeline-link="pipeline.path" - :merge-train-length="stateData.mergeTrainsCount" - :merge-train-when-pipeline-succeeds-docs-path="mr.mergeTrainWhenPipelineSucceedsDocsPath" - /> <template v-if="shouldShowMergeControls"> <div v-if="!shouldShowMergeEdit" 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 393c599c7e8..790870ee4c6 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 @@ -3,6 +3,7 @@ import { GlButton } from '@gitlab/ui'; import { produce } from 'immer'; import $ from 'jquery'; import createFlash from '~/flash'; +import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; import MergeRequest from '~/merge_request'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -123,10 +124,7 @@ export default { }, }, }) => { - createFlash({ - message: __('Marked as ready. Merging is now allowed.'), - type: 'notice', - }); + toast(__('Marked as ready. Merging is now allowed.')); $('.merge-request .detail-page-description .title').text(title); }, ) diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index f5710f46b7e..b88e83ccb0f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -1,4 +1,5 @@ import { s__ } from '~/locale'; +import { stateToComponentMap as classStateMap, stateKey } from './stores/state_maps'; export const SUCCESS = 'success'; export const WARNING = 'warning'; @@ -52,3 +53,99 @@ export const MERGE_ACTIVE_STATUS_PHRASES = [ emoji: 'heart_eyes', }, ]; + +const STATE_MACHINE = { + states: { + IDLE: 'IDLE', + MERGING: 'MERGING', + AUTO_MERGE: 'AUTO_MERGE', + }, + transitions: { + MERGE: 'start-merge', + AUTO_MERGE: 'start-auto-merge', + MERGE_FAILURE: 'merge-failed', + MERGED: 'merge-done', + }, +}; +const { states, transitions } = STATE_MACHINE; + +STATE_MACHINE.definition = { + initial: states.IDLE, + states: { + [states.IDLE]: { + on: { + [transitions.MERGE]: states.MERGING, + [transitions.AUTO_MERGE]: states.AUTO_MERGE, + }, + }, + [states.MERGING]: { + on: { + [transitions.MERGED]: states.IDLE, + [transitions.MERGE_FAILURE]: states.IDLE, + }, + }, + [states.AUTO_MERGE]: { + on: { + [transitions.MERGED]: states.IDLE, + [transitions.MERGE_FAILURE]: states.IDLE, + }, + }, + }, +}; + +export const stateToTransitionMap = { + [stateKey.merging]: transitions.MERGE, + [stateKey.merged]: transitions.MERGED, + [stateKey.autoMergeEnabled]: transitions.AUTO_MERGE, +}; +export const stateToComponentMap = { + [states.MERGING]: classStateMap[stateKey.merging], + [states.AUTO_MERGE]: classStateMap[stateKey.autoMergeEnabled], +}; + +export const EXTENSION_ICONS = { + failed: 'failed', + warning: 'warning', + success: 'success', + neutral: 'neutral', + error: 'error', + notice: 'notice', + severityCritical: 'severityCritical', + severityHigh: 'severityHigh', + severityMedium: 'severityMedium', + severityLow: 'severityLow', + severityInfo: 'severityInfo', + severityUnknown: 'severityUnknown', +}; + +export const EXTENSION_ICON_NAMES = { + failed: 'status-failed', + warning: 'status-alert', + success: 'status-success', + neutral: 'status-neutral', + error: 'status-alert', + notice: 'status-alert', + severityCritical: 'severity-critical', + severityHigh: 'severity-high', + severityMedium: 'severity-medium', + severityLow: 'severity-low', + severityInfo: 'severity-info', + severityUnknown: 'severity-unknown', +}; + +export const EXTENSION_ICON_CLASS = { + failed: 'gl-text-red-500', + warning: 'gl-text-orange-500', + success: 'gl-text-green-500', + neutral: 'gl-text-gray-400', + error: 'gl-text-red-500', + notice: 'gl-text-gray-500', + severityCritical: 'gl-text-red-800', + severityHigh: 'gl-text-red-600', + severityMedium: 'gl-text-orange-400', + severityLow: 'gl-text-orange-300', + severityInfo: 'gl-text-blue-400', + severityUnknown: 'gl-text-gray-400', +}; + +export { STATE_MACHINE }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index 6c6f5e7fc73..349e9d29355 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -1,4 +1,5 @@ /* eslint-disable */ +import { EXTENSION_ICONS } from '../constants'; import issuesCollapsedQuery from './issues_collapsed.query.graphql'; import issuesQuery from './issues.query.graphql'; @@ -6,20 +7,29 @@ export default { // Give the extension a name // Make it easier to track in Vue dev tools name: 'WidgetIssues', + i18n: { + label: 'Issues', + loading: 'Loading issues...', + }, // Add an array of props // These then get mapped to values stored in the MR Widget store - props: ['targetProjectFullPath'], + props: ['targetProjectFullPath', 'conflictsDocsPath'], // Add any extra computed props in here computed: { // Small summary text to be displayed in the collapsed state // Receives the collapsed data as an argument summary(count) { - return `<strong>${count}</strong> open issue`; + return 'Summary text<br/>Second line'; }, // Status icon to be used next to the summary text // Receives the collapsed data as an argument statusIcon(count) { - return count > 0 ? 'warning' : 'success'; + return EXTENSION_ICONS.warning; + }, + // Tertiary action buttons that will take the user elsewhere + // in the GitLab app + tertiaryButtons() { + return [{ text: 'Full report', href: this.conflictsDocsPath, target: '_blank' }]; }, }, methods: { @@ -44,16 +54,13 @@ export default { // Icon to get rendered on the side of each row icon: { // Required: Name maps to an icon in GitLabs SVG - name: - issue.state === 'closed' ? 'status_failed_borderless' : 'status_success_borderless', - // Optional: An extra class to be added to the icon for additional styling - class: issue.state === 'closed' ? 'text-danger' : 'text-success', + name: issue.state === 'closed' ? EXTENSION_ICONS.error : EXTENSION_ICONS.success, }, // Badges get rendered next to the text on each row - badge: issue.state === 'closed' && { - text: 'Closed', // Required: Text to be used inside of the badge - // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants - }, + // badge: issue.state === 'closed' && { + // text: 'Closed', // Required: Text to be used inside of the badge + // // variant: 'info', // Optional: The variant of the badge, maps to GitLab UI variants + // }, // Each row can have its own link that will take the user elsewhere // link: { // href: 'https://google.com', // Required: href for the link diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 9d8e5d12d58..cf6472f2c8c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -32,7 +32,7 @@ export default { isMergeImmediatelyDangerous() { return false; }, - shouldRenderMergeTrainHelperText() { + shouldRenderMergeTrainHelperIcon() { return false; }, pipelineId() { 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 78aa3941bfe..3ac1e881658 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 @@ -4,7 +4,7 @@ import { isEmpty } from 'lodash'; import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store'; -import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; +import { stateToComponentMap as classState } from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import createFlash from '~/flash'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import notify from '~/lib/utils/notify'; @@ -38,7 +38,8 @@ import ReadyToMergeState from './components/states/ready_to_merge.vue'; import ShaMismatch from './components/states/sha_mismatch.vue'; import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue'; import WorkInProgressState from './components/states/work_in_progress.vue'; -// import ExtensionsContainer from './components/extensions/container'; +import ExtensionsContainer from './components/extensions/container'; +import { STATE_MACHINE, stateToComponentMap } from './constants'; import eventHub from './event_hub'; import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; import getStateQuery from './queries/get_state.query.graphql'; @@ -52,7 +53,7 @@ export default { }, components: { Loading, - // ExtensionsContainer, + ExtensionsContainer, 'mr-widget-header': WidgetHeader, 'mr-widget-suggest-pipeline': WidgetSuggestPipeline, MrWidgetPipelineContainer, @@ -124,7 +125,9 @@ export default { mr: store, state: store && store.state, service: store && this.createService(store), + machineState: store?.machineValue || STATE_MACHINE.definition.initial, loading: true, + recomputeComponentName: 0, }; }, computed: { @@ -139,7 +142,7 @@ export default { return this.mr.state !== 'nothingToMerge'; }, componentName() { - return stateMaps.stateToComponentMap[this.mr.state]; + return stateToComponentMap[this.machineState] || classState[this.mr.state]; }, hasPipelineMustSucceedConflict() { return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds; @@ -148,9 +151,9 @@ export default { return this.mr.hasCI || this.hasPipelineMustSucceedConflict; }, shouldSuggestPipelines() { - return ( - !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline - ); + const { hasCI, mergeRequestAddCiConfigPath, isDismissedSuggestPipeline } = this.mr; + + return !hasCI && mergeRequestAddCiConfigPath && !isDismissedSuggestPipeline; }, shouldRenderCodeQuality() { return this.mr?.codequalityReportsPath; @@ -204,8 +207,19 @@ export default { hasAlerts() { return this.mr.mergeError || this.showMergePipelineForkWarning; }, + shouldShowExtension() { + return ( + window.gon?.features?.refactorMrWidgetsExtensions || + window.gon?.features?.refactorMrWidgetsExtensionsUser + ); + }, }, watch: { + 'mr.machineValue': { + handler(newValue) { + this.machineState = newValue; + }, + }, state(newVal, oldVal) { if (newVal !== oldVal && this.shouldRenderMergedPipeline) { // init polling @@ -247,6 +261,8 @@ export default { this.mr = new MRWidgetStore({ ...window.gl.mrWidgetData, ...data }); } + this.machineState = this.mr.machineValue; + if (!this.state) { this.state = this.mr.state; } @@ -496,7 +512,7 @@ export default { </template> </mr-widget-alert-message> </div> - <!-- <extensions-container :mr="mr" /> --> + <extensions-container :mr="mr" /> <grouped-codequality-reports-app v-if="shouldRenderCodeQuality" :head-blob-path="mr.headBlobPath" diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 29e0c867f6b..6628225cd46 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -1,11 +1,21 @@ import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key'; import { statusBoxState } from '~/issuable/components/status_box.vue'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; -import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants'; +import { machine } from '~/lib/utils/finite_state_machine'; +import { + MTWPS_MERGE_STRATEGY, + MT_MERGE_STRATEGY, + MWPS_MERGE_STRATEGY, + STATE_MACHINE, + stateToTransitionMap, +} from '../constants'; import { stateKey } from './state_maps'; const { format } = getTimeago(); +const { states } = STATE_MACHINE; +const { IDLE } = states; + export default class MergeRequestStore { constructor(data) { this.sha = data.diff_head_sha; @@ -16,6 +26,9 @@ export default class MergeRequestStore { this.apiUnapprovePath = data.api_unapprove_path; this.hasApprovalsAvailable = data.has_approvals_available; + this.stateMachine = machine(STATE_MACHINE.definition); + this.machineValue = this.stateMachine.value; + this.setPaths(data); this.setData(data); @@ -215,10 +228,7 @@ export default class MergeRequestStore { setState() { if (this.mergeOngoing) { this.state = 'merging'; - return; - } - - if (this.isOpen) { + } else if (this.isOpen) { this.state = getStateKey.call(this); } else { switch (this.mergeRequestState) { @@ -232,6 +242,8 @@ export default class MergeRequestStore { this.state = null; } } + + this.translateStateToMachine(); } setPaths(data) { @@ -277,7 +289,7 @@ export default class MergeRequestStore { // Security reports this.sastComparisonPath = data.sast_comparison_path; - this.secretScanningComparisonPath = data.secret_scanning_comparison_path; + this.secretDetectionComparisonPath = data.secret_detection_comparison_path; } get isNothingToMergeState() { @@ -356,4 +368,32 @@ export default class MergeRequestStore { (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) ); } + + // Because the state machine doesn't yet handle every state and transition, + // some use-cases will need to force a state that can't be reached by + // a known transition. This is undesirable long-term (as it subverts + // the intent of a state machine), but is necessary until the machine + // can handle all possible combinations. (unsafeForce) + transitionStateMachine({ transition, state, unsafeForce = false } = {}) { + if (unsafeForce && state) { + this.stateMachine.value = state; + } else { + this.stateMachine.send(transition); + } + + this.machineValue = this.stateMachine.value; + } + translateStateToMachine() { + const transition = stateToTransitionMap[this.state]; + let transitionOptions = { + state: IDLE, + unsafeForce: true, + }; + + if (transition) { + transitionOptions = { transition }; + } + + this.transitionStateMachine(transitionOptions); + } } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index 04454882666..4cb23407a74 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -1,4 +1,4 @@ -const stateToComponentMap = { +export const stateToComponentMap = { merged: 'mr-widget-merged', closed: 'mr-widget-closed', merging: 'mr-widget-merging', @@ -21,7 +21,7 @@ const stateToComponentMap = { mergeChecksFailed: 'mergeChecksFailed', }; -const statesToShowHelpWidget = [ +export const statesToShowHelpWidget = [ 'merging', 'conflicts', 'workInProgress', @@ -50,11 +50,7 @@ export const stateKey = { notAllowedToMerge: 'notAllowedToMerge', readyToMerge: 'readyToMerge', rebase: 'rebase', + merging: 'merging', merged: 'merged', mergeChecksFailed: 'mergeChecksFailed', }; - -export default { - stateToComponentMap, - statesToShowHelpWidget, -}; |