diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue new file mode 100644 index 0000000000000000000000000000000000000000..1dc4270f054b43c4d62776d2c5b3ffabc4a7260d --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_preparing.vue @@ -0,0 +1,23 @@ + + + + + + {{ $options.i18n.preparing }} + + + diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 185037208149af4025975663a57b70ca22ff2385..db237bc74394838bd8eaf8b3e2fcee4783bda751 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -182,6 +182,7 @@ export const INVALID_RULES_DOCS_PATH = helpPagePath( ); export const DETAILED_MERGE_STATUS = { + PREPARING: 'PREPARING', MERGEABLE: 'MERGEABLE', CHECKING: 'CHECKING', NOT_OPEN: 'NOT_OPEN', diff --git a/app/assets/javascripts/vue_merge_request_widget/i18n.js b/app/assets/javascripts/vue_merge_request_widget/i18n.js index 5ca56074031cb30cfb8efc4e7f22d7d4e267659a..1b5929e31be15e21426f0fb425d8d22d24cd7e44 100644 --- a/app/assets/javascripts/vue_merge_request_widget/i18n.js +++ b/app/assets/javascripts/vue_merge_request_widget/i18n.js @@ -1,5 +1,9 @@ import { __, s__ } from '~/locale'; +export const MR_WIDGET_PREPARING_ASYNCHRONOUSLY = s__( + 'mrWidget|Your merge request is almost ready!', +); + export const MR_WIDGET_MISSING_BRANCH_WHICH = s__( 'mrWidget|The %{type} branch %{codeStart}%{name}%{codeEnd} does not exist.', ); 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 6e0ee1cb91209c11ec978f78eea386ab1993cf5c..e9efc8e75be7794a58749ad38e63e5b5f250223f 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 @@ -26,6 +26,7 @@ import ArchivedState from './components/states/mr_widget_archived.vue'; import MrWidgetAutoMergeEnabled from './components/states/mr_widget_auto_merge_enabled.vue'; import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue'; import CheckingState from './components/states/mr_widget_checking.vue'; +import PreparingState from './components/states/mr_widget_preparing.vue'; import ClosedState from './components/states/mr_widget_closed.vue'; import ConflictsState from './components/states/mr_widget_conflicts.vue'; import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue'; @@ -88,6 +89,7 @@ export default { MrWidgetReadyToMerge, ShaMismatch, MrWidgetChecking: CheckingState, + MrWidgetPreparing: PreparingState, MrWidgetUnresolvedDiscussions: UnresolvedDiscussionsState, MrWidgetPipelineBlocked: PipelineBlockedState, MrWidgetPipelineFailed: PipelineFailedState, @@ -199,7 +201,7 @@ export default { ); }, shouldRenderApprovals() { - return this.mr.state !== 'nothingToMerge'; + return !['preparing', 'nothingToMerge'].includes(this.mr.state); }, componentName() { return stateToComponentMap[this.machineState] || classState[this.mr.state]; diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql index a6b35f20776f8dbd6a65086e347e1b17975c7156..4366c01e0a236c046f190d50e73e55bcefc60466 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.subscription.graphql @@ -3,6 +3,7 @@ subscription getStateSubscription($issuableId: IssuableID!) { ... on MergeRequest { id detailedMergeStatus + commitCount } } } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index cead42b12ae3a2e7aa96c88fd0ea8db6b2631bb1..f90056a8e1aefe135c18834edf00b63cbc43eb4b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -2,7 +2,9 @@ import { DETAILED_MERGE_STATUS } from '../constants'; import { stateKey } from './state_maps'; export default function deviseState() { - if (!this.commitsCount) { + if (this.detailedMergeStatus === DETAILED_MERGE_STATUS.PREPARING) { + return stateKey.preparing; + } else if (!this.commitsCount) { return stateKey.nothingToMerge; } else if (this.projectArchived) { return stateKey.archived; 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 19ce523c4825094ed180e023cf6c63db8678f13e..9ddf8241020c78f6920b23b4320809c2a95f9570 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 @@ -212,6 +212,7 @@ export default class MergeRequestStore { setGraphqlSubscriptionData(data) { this.detailedMergeStatus = data.detailedMergeStatus; + this.commitsCount = data.commitCount; this.setState(); } 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 9dfeaee905cc8000b90b441dc41fdbcf5b37ed08..04468855942c639276f5a8da662bd15ff5c65f01 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 @@ -10,6 +10,7 @@ export const stateToComponentMap = { notAllowedToMerge: 'mr-widget-not-allowed', archived: 'mr-widget-archived', checking: 'mr-widget-checking', + preparing: 'mr-widget-preparing', unresolvedDiscussions: 'mr-widget-unresolved-discussions', pipelineBlocked: 'mr-widget-pipeline-blocked', pipelineFailed: 'mr-widget-pipeline-failed', @@ -38,6 +39,7 @@ export const stateKey = { archived: 'archived', missingBranch: 'missingBranch', nothingToMerge: 'nothingToMerge', + preparing: 'preparing', checking: 'checking', conflicts: 'conflicts', draft: 'draft', diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 64948ae8e4fcdbcebe276ddc350e858012f1fe5a..a01f8efc5247ddc2e178dca1579d0f3b640865f6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -54737,6 +54737,9 @@ msgstr "" msgid "mrWidget|What is a merge train?" msgstr "" +msgid "mrWidget|Your merge request is almost ready!" +msgstr "" + msgid "mrWidget|Your password" msgstr "" diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a54591cdb16fe76bb67bf4a705002e3ec0321d6e --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_preparing_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; + +import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue'; +import { MR_WIDGET_PREPARING_ASYNCHRONOUSLY } from '~/vue_merge_request_widget/i18n'; + +function createComponent() { + return shallowMount(Preparing); +} + +function findSpinnerIcon(wrapper) { + return wrapper.findComponent(GlLoadingIcon); +} + +describe('Preparing', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + it('should render a spinner', () => { + expect(findSpinnerIcon(wrapper).exists()).toBe(true); + }); + + it('should render the correct text', () => { + expect(wrapper.text()).toBe(MR_WIDGET_PREPARING_ASYNCHRONOUSLY); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index 656203b1d25b8d25d550f343d35e196b2b764aa7..daa45a9e876b585af452fd459fb7919dabcb33e3 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -3,8 +3,8 @@ import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import * as Sentry from '@sentry/browser'; import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client'; +import * as Sentry from '@sentry/browser'; import approvedByCurrentUser from 'test_fixtures/graphql/merge_requests/approvals/approvals.query.graphql.json'; import getStateQueryResponse from 'test_fixtures/graphql/merge_requests/get_state.query.graphql.json'; import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json'; @@ -26,10 +26,13 @@ import { STATE_QUERY_POLLING_INTERVAL_BACKOFF } from '~/vue_merge_request_widget import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; +import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; +import Preparing from '~/vue_merge_request_widget/components/states/mr_widget_preparing.vue'; import WidgetContainer from '~/vue_merge_request_widget/components/widget/app.vue'; import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql'; +import getStateSubscription from '~/vue_merge_request_widget/queries/get_state.subscription.graphql'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import approvalsQuery from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.query.graphql'; import approvedBySubscription from 'ee_else_ce/vue_merge_request_widget/components/approvals/queries/approvals.subscription.graphql'; @@ -65,13 +68,14 @@ jest.mock('@sentry/browser', () => ({ Vue.use(VueApollo); describe('MrWidgetOptions', () => { - let mockedApprovalsSubscription; let stateQueryHandler; let queryResponse; let wrapper; let mock; const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; + const findApprovalsWidget = () => wrapper.findComponent(Approvals); + const findPreparingWidget = () => wrapper.findComponent(Preparing); const findWidgetContainer = () => wrapper.findComponent(WidgetContainer); const findExtensionToggleButton = () => wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); @@ -97,7 +101,7 @@ describe('MrWidgetOptions', () => { }); const createComponent = (mrData = mockData, options = {}, data = {}, fullMount = true) => { - mockedApprovalsSubscription = createMockApolloSubscription(); + const mockedApprovalsSubscription = createMockApolloSubscription(); queryResponse = { data: { project: { @@ -105,6 +109,9 @@ describe('MrWidgetOptions', () => { mergeRequest: { ...getStateQueryResponse.data.project.mergeRequest, mergeError: mrData.mergeError || null, + detailedMergeStatus: + mrData.detailedMergeStatus || + getStateQueryResponse.data.project.mergeRequest.detailedMergeStatus, }, }, }, @@ -128,7 +135,10 @@ describe('MrWidgetOptions', () => { ], ...(options.apolloMock || []), ]; - const subscriptionHandlers = [[approvedBySubscription, () => mockedApprovalsSubscription]]; + const subscriptionHandlers = [ + [approvedBySubscription, () => mockedApprovalsSubscription], + ...(options.apolloSubscriptions || []), + ]; const apolloProvider = createMockApollo(queryHandlers); subscriptionHandlers.forEach(([query, stream]) => { @@ -1275,4 +1285,86 @@ describe('MrWidgetOptions', () => { }); }); }); + + describe('async preparation for a newly opened MR', () => { + beforeEach(() => { + mock + .onGet(mockData.merge_request_widget_path) + .reply(() => [HTTP_STATUS_OK, { ...mockData, state: 'opened' }]); + }); + + it('does not render the Preparing state component by default', async () => { + await createComponent(); + + expect(findApprovalsWidget().exists()).toBe(true); + expect(findPreparingWidget().exists()).toBe(false); + }); + + it('renders the Preparing state component when the MR state is initially "preparing"', async () => { + await createComponent({ + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', + }); + + expect(findApprovalsWidget().exists()).toBe(false); + expect(findPreparingWidget().exists()).toBe(true); + }); + + describe('when the MR is updated by observing its status', () => { + let stateSubscription; + + beforeEach(() => { + window.gon.features.realtimeMrStatusChange = true; + stateSubscription = createMockApolloSubscription(); + }); + + it("shows the Preparing widget when the MR reports it's not ready yet", async () => { + await createComponent( + { + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', + }, + { + apolloSubscriptions: [[getStateSubscription, () => stateSubscription]], + }, + {}, + false, + ); + + expect(wrapper.html()).toContain('mr-widget-preparing-stub'); + }); + + it('removes the Preparing widget when the MR indicates it has been prepared', async () => { + await createComponent( + { + ...mockData, + state: 'opened', + detailedMergeStatus: 'PREPARING', + }, + { + apolloSubscriptions: [[getStateSubscription, () => stateSubscription]], + }, + {}, + false, + ); + + expect(wrapper.html()).toContain('mr-widget-preparing-stub'); + + stateSubscription.next({ + data: { + mergeRequestMergeStatusUpdated: { + preparedAt: 'non-null value', + }, + }, + }); + + // Wait for batched DOM updates + await nextTick(); + + expect(wrapper.html()).not.toContain('mr-widget-preparing-stub'); + }); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js index a6288b9c725784ad09bda4e5a6e8563751d3cb6c..ca5c9084a629f61bce8393a00dd415a502e6175f 100644 --- a/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_merge_request_widget/stores/get_state_key_spec.js @@ -16,10 +16,14 @@ describe('getStateKey', () => { commitsCount: 2, hasConflicts: false, draft: false, - detailedMergeStatus: null, + detailedMergeStatus: 'PREPARING', }; const bound = getStateKey.bind(context); + expect(bound()).toEqual('preparing'); + + context.detailedMergeStatus = null; + expect(bound()).toEqual('checking'); context.detailedMergeStatus = 'MERGEABLE';