Commit a4d112fe authored by Phil Hughes's avatar Phil Hughes

Export merge request state globally

This makes the merge requests state exportable which allows for
other components to import and use the state.
Whenever a polling request gets returned from the widget
we update the state globally which allows for other components
to react accordingly.

For example, when a user merges a merge request
the global state gets updated which updates the status box
and also updates the comment box to stop users
from commenting and closing a merge request.
This same interaction happens when a user clicks on comment and
close. The rest of the page updates and sets it into the correct state.

To handle this we export the state from the status box component
and also export a update method. This update event should get
called whenever the state of the page should be changed.
When this method gets called we send a GraphQL request to get the correct
state of the page.

Closes https://gitlab.com/gitlab-org/gitlab/-/issues/328547
parent 1e2fbc86
<script>
import { GlIcon } from '@gitlab/ui';
import Vue from 'vue';
import { fetchPolicies } from '~/lib/graphql';
import { __ } from '~/locale';
import mrEventHub from '../eventhub';
export const statusBoxState = Vue.observable({
state: '',
updateStatus: null,
});
const CLASSES = {
opened: 'status-box-open',
......@@ -21,37 +27,63 @@ export default {
components: {
GlIcon,
},
inject: {
query: { default: null },
projectPath: { default: null },
iid: { default: null },
},
props: {
initialState: {
type: String,
required: true,
required: false,
default: null,
},
issuableType: {
type: String,
required: false,
default: '',
},
},
data() {
return {
state: this.initialState,
};
if (this.initialState) {
statusBoxState.state = this.initialState;
}
return statusBoxState;
},
computed: {
statusBoxClass() {
return CLASSES[this.state];
return CLASSES[`${this.issuableType}_${this.state}`] || CLASSES[this.state];
},
statusHumanName() {
return STATUS[this.state][0];
return (STATUS[`${this.issuableType}_${this.state}`] || STATUS[this.state])[0];
},
statusIconName() {
return STATUS[this.state][1];
return (STATUS[`${this.issuableType}_${this.state}`] || STATUS[this.state])[1];
},
},
created() {
mrEventHub.$on('mr.state.updated', this.updateState);
if (!statusBoxState.updateStatus) {
statusBoxState.updateStatus = this.fetchState;
}
},
beforeDestroy() {
mrEventHub.$off('mr.state.updated', this.updateState);
if (statusBoxState.updateStatus && this.query) {
statusBoxState.updateStatus = null;
}
},
methods: {
updateState({ state }) {
this.state = state;
async fetchState() {
const { data } = await this.$apollo.query({
query: this.query,
variables: {
projectPath: this.projectPath,
iid: this.iid,
},
fetchPolicy: fetchPolicies.NO_CACHE,
});
statusBoxState.state = data?.workspace?.issuable?.state;
},
},
};
......
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
......@@ -15,6 +15,7 @@ import { mapActions, mapGetters, mapState } from 'vuex';
import Autosave from '~/autosave';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { statusBoxState } from '~/issuable/components/status_box.vue';
import httpStatusCodes from '~/lib/utils/http_status';
import {
capitalizeFirstCharacter,
......@@ -162,7 +163,7 @@ export default {
canToggleIssueState() {
return (
this.getNoteableData.current_user.can_update &&
this.getNoteableData.state !== constants.MERGED &&
this.openState !== constants.MERGED &&
!this.closedAndLocked
);
},
......@@ -283,6 +284,7 @@ export default {
const toggleState = this.isOpen ? this.closeIssuable : this.reopenIssuable;
toggleState()
.then(() => statusBoxState.updateStatus && statusBoxState.updateStatus())
.then(refreshUserMergeRequestCounts)
.catch(() => Flash(constants.toggleStateErrorMessage[this.noteableType][this.openState]));
},
......
import { flattenDeep, clone } from 'lodash';
import { statusBoxState } from '~/issuable/components/status_box.vue';
import { isInMRPage } from '~/lib/utils/common_utils';
import * as constants from '../constants';
import { collapseSystemNotes } from './collapse_utils';
......@@ -82,7 +84,8 @@ export const getBlockedByIssues = (state) => state.noteableData.blocked_by_issue
export const userCanReply = (state) => Boolean(state.noteableData.current_user.can_create_note);
export const openState = (state) => state.noteableData.state;
export const openState = (state) =>
isInMRPage() ? statusBoxState.state : state.noteableData.state;
export const getUserData = (state) => state.userData || {};
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import loadAwardsHandler from '~/awards_handler';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
......@@ -7,10 +8,12 @@ import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteMembersTrigger from '~/invite_members/init_invite_members_trigger';
import StatusBox from '~/issuable/components/status_box.vue';
import createDefaultClient from '~/lib/graphql';
import { handleLocationHash } from '~/lib/utils/common_utils';
import StatusBox from '~/merge_request/components/status_box.vue';
import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode';
import getStateQuery from './queries/get_state.query.graphql';
export default function initMergeRequestShow() {
const awardEmojiEl = document.getElementById('js-vue-awards-block');
......@@ -34,9 +37,16 @@ export default function initMergeRequestShow() {
initInviteMembersTrigger();
const el = document.querySelector('.js-mr-status-box');
const apolloProvider = new VueApollo({ defaultClient: createDefaultClient() });
// eslint-disable-next-line no-new
new Vue({
el,
apolloProvider,
provide: {
query: getStateQuery,
projectPath: el.dataset.projectPath,
iid: el.dataset.iid,
},
render(h) {
return h(StatusBox, {
props: {
......
query getMergeRequestState($projectPath: ID!, $iid: String!) {
workspace: project(fullPath: $projectPath) {
issuable: mergeRequest(iid: $iid) {
state
}
}
}
import { format } from 'timeago.js';
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import mrEventHub from '~/merge_request/eventhub';
import { statusBoxState } from '~/issuable/components/status_box.vue';
import { formatDate } from '../../lib/utils/datetime_utility';
import { MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants';
import { stateKey } from './state_maps';
......@@ -23,6 +23,8 @@ export default class MergeRequestStore {
setData(data, isRebased) {
this.initApprovals();
this.updateStatusState(data.state);
if (isRebased) {
this.sha = data.diff_head_sha;
}
......@@ -156,16 +158,14 @@ export default class MergeRequestStore {
this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
this.setState();
if (!window.gon?.features?.mergeRequestWidgetGraphql) {
this.emitUpdatedState();
}
}
setGraphqlData(project) {
const { mergeRequest } = project;
const pipeline = mergeRequest.headPipeline;
this.updateStatusState(mergeRequest.state);
this.projectArchived = project.archived;
this.onlyAllowMergeIfPipelineSucceeds = project.onlyAllowMergeIfPipelineSucceeds;
......@@ -190,10 +190,15 @@ export default class MergeRequestStore {
this.workInProgress = mergeRequest.workInProgress;
this.mergeRequestState = mergeRequest.state;
this.emitUpdatedState();
this.setState();
}
updateStatusState(state) {
if (this.mergeRequestState !== state && statusBoxState.updateStatus) {
statusBoxState.updateStatus();
}
}
setState() {
if (this.mergeOngoing) {
this.state = 'merging';
......@@ -216,12 +221,6 @@ export default class MergeRequestStore {
}
}
emitUpdatedState() {
mrEventHub.$emit('mr.state.updated', {
state: this.mergeRequestState,
});
}
setPaths(data) {
// Paths are set on the first load of the page and not auto-refreshed
this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path;
......
......@@ -332,6 +332,18 @@ module IssuablesHelper
end
end
def state_name_with_icon(issuable)
if issuable.is_a?(MergeRequest) && issuable.merged?
[_("Merged"), "git-merge"]
elsif issuable.is_a?(MergeRequest) && issuable.closed?
[_("Closed"), "close"]
elsif issuable.closed?
[_("Closed"), "mobile-issue-close"]
else
[_("Open"), "issue-open-m"]
end
end
private
def sidebar_gutter_collapsed?
......
......@@ -29,16 +29,6 @@ module MergeRequestsHelper
classes.join(' ')
end
def state_name_with_icon(merge_request)
if merge_request.merged?
[_("Merged"), "git-merge"]
elsif merge_request.closed?
[_("Closed"), "close"]
else
[_("Open"), "issue-open-m"]
end
end
def merge_path_description(merge_request, separator)
if merge_request.for_fork?
"Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.full_path}:#{@merge_request.target_branch}"
......
- @no_breadcrumb_border = true
- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
- can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request)
- state_human_name, state_icon_name = state_name_with_icon(@merge_request)
- are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false)
- if @merge_request.closed_or_merged_without_fork?
......@@ -12,10 +11,7 @@
.detail-page-header.border-bottom-0.pt-0.pb-0
.detail-page-header-body
.issuable-status-box.status-box.js-mr-status-box{ class: status_box_class(@merge_request), data: { state: @merge_request.state } }
= sprite_icon(state_icon_name, css_class: 'gl-display-block gl-sm-display-none!')
%span.gl-display-none.gl-sm-display-block
= state_human_name
= render "shared/issuable/status_box", issuable: @merge_request
.issuable-meta
#js-issuable-header-warnings
......
- state_human_name, state_icon_name = state_name_with_icon(issuable)
.issuable-status-box.status-box.js-mr-status-box{ class: status_box_class(issuable), data: { project_path: issuable.project.path_with_namespace, iid: issuable.iid, state: issuable.state } }
= sprite_icon(state_icon_name, css_class: 'gl-display-block gl-sm-display-none!')
%span.gl-display-none.gl-sm-display-block
= state_human_name
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import StatusBox from '~/merge_request/components/status_box.vue';
import mrEventHub from '~/merge_request/eventhub';
import StatusBox from '~/issuable/components/status_box.vue';
let wrapper;
......@@ -70,18 +68,4 @@ describe('Merge request status box component', () => {
});
});
});
it('updates with eventhub event', async () => {
factory({
initialState: 'opened',
});
expect(wrapper.text()).toContain('Open');
mrEventHub.$emit('mr.state.updated', { state: 'closed' });
await nextTick();
expect(wrapper.text()).toContain('Closed');
});
});
......@@ -436,6 +436,7 @@ describe('issue_comment_form component', () => {
await findCloseReopenButton().trigger('click');
await wrapper.vm.$nextTick;
await wrapper.vm.$nextTick;
expect(flash).toHaveBeenCalledWith(
......@@ -471,6 +472,7 @@ describe('issue_comment_form component', () => {
await findCloseReopenButton().trigger('click');
await wrapper.vm.$nextTick;
await wrapper.vm.$nextTick;
expect(flash).toHaveBeenCalledWith(
......@@ -489,6 +491,8 @@ describe('issue_comment_form component', () => {
await findCloseReopenButton().trigger('click');
await wrapper.vm.$nextTick();
expect(refreshUserMergeRequestCounts).toHaveBeenCalled();
});
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment