Commit 43be7e3a authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'rj/health-status-widget' into 'master'

Refactor health status widget to use Apollo

See merge request gitlab-org/gitlab!65620
parents 5eb043a8 90a2f8c1
mutation updateIssue($input: UpdateIssueInput!) { mutation updateIssue($input: UpdateIssueInput!) {
updateIssue(input: $input) { updateIssue(input: $input) {
issuable: issue {
id
state
}
errors errors
} }
} }
mutation($projectPath: ID!, $iid: String!, $healthStatus: HealthStatus) { mutation($projectPath: ID!, $iid: String!, $healthStatus: HealthStatus) {
updateIssue(input: { projectPath: $projectPath, iid: $iid, healthStatus: $healthStatus }) { updateIssue(input: { projectPath: $projectPath, iid: $iid, healthStatus: $healthStatus }) {
issue { issuable: issue {
id
healthStatus healthStatus
} }
errors errors
......
...@@ -425,6 +425,15 @@ module IssuablesHelper ...@@ -425,6 +425,15 @@ module IssuablesHelper
} }
end end
def sidebar_status_data(issuable_sidebar, project)
{
iid: issuable_sidebar[:iid],
issuable_type: issuable_sidebar[:type],
full_path: project.full_path,
can_edit: issuable_sidebar.dig(:current_user, :can_edit).to_s
}
end
def parent def parent
@project || @group @project || @group
end end
......
...@@ -58,7 +58,7 @@ ...@@ -58,7 +58,7 @@
#js-severity #js-severity
- if issuable_sidebar.dig(:features_available, :health_status) - if issuable_sidebar.dig(:features_available, :health_status)
.js-sidebar-status-entry-point .js-sidebar-status-entry-point{ data: sidebar_status_data(issuable_sidebar, @project) }
- if issuable_sidebar.has_key?(:confidential) - if issuable_sidebar.has_key?(:confidential)
%script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
......
<script> <script>
import { mapGetters } from 'vuex';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import { OPENED, REOPENED } from '~/notes/constants'; import { OPENED, REOPENED } from '~/notes/constants';
import { healthStatusQueries } from '../../constants';
import Status from './status.vue'; import Status from './status.vue';
export default { export default {
...@@ -10,25 +10,92 @@ export default { ...@@ -10,25 +10,92 @@ export default {
Status, Status,
}, },
props: { props: {
mediator: { issuableType: {
type: String,
required: true, required: true,
type: Object,
validator(mediatorObject) {
return Boolean(mediatorObject.store);
}, },
iid: {
type: String,
required: true,
},
fullPath: {
type: String,
required: true,
},
canUpdate: {
type: Boolean,
required: false,
default: false,
}, },
}, },
computed: { computed: {
...mapGetters(['getNoteableData']),
isOpen() { isOpen() {
return this.getNoteableData.state === OPENED || this.getNoteableData.state === REOPENED; return this.issuableData?.state === OPENED || this.issuableData?.state === REOPENED;
},
isLoading() {
return this.$apollo.queries.issuableData.loading;
},
healthStatus() {
return this.issuableData?.healthStatus;
},
},
apollo: {
issuableData: {
query() {
return healthStatusQueries[this.issuableType].query;
},
variables() {
return {
fullPath: this.fullPath,
iid: this.iid,
};
},
update(data) {
return {
healthStatus: data.workspace?.issuable?.healthStatus,
state: data.workspace?.issuable?.state,
};
},
result({ data }) {
this.$emit('issuableData', {
healthStatus: data.workspace?.issuable?.healthStatus,
state: data.workspace?.issuable?.state,
});
},
error() {
createFlash({
message: sprintf(
__('Something went wrong while setting %{issuableType} health status.'),
{
issuableType: this.issuableType,
},
),
});
},
}, },
}, },
methods: { methods: {
handleDropdownClick(status) { handleDropdownClick(status) {
this.mediator.updateStatus(status).catch(() => { return this.$apollo
.mutate({
mutation: healthStatusQueries[this.issuableType].mutation,
variables: {
projectPath: this.fullPath,
iid: this.iid,
healthStatus: status,
},
})
.then(({ data: { updateIssue } = {} } = {}) => {
const error = updateIssue?.errors[0];
if (error) {
createFlash({ message: error });
}
})
.catch(() => {
createFlash({ createFlash({
message: __('Error occurred while updating the issue status'), message: sprintf(__('Error occurred while updating the %{issuableType} status'), {
issuableType: this.issuableType,
}),
}); });
}); });
}, },
...@@ -39,9 +106,9 @@ export default { ...@@ -39,9 +106,9 @@ export default {
<template> <template>
<status <status
:is-open="isOpen" :is-open="isOpen"
:is-editable="mediator.store.editable" :is-editable="canUpdate"
:is-fetching="mediator.store.isFetching.status" :is-fetching="isLoading"
:status="mediator.store.status" :status="healthStatus"
@onDropdownClick="handleDropdownClick" @onDropdownClick="handleDropdownClick"
/> />
</template> </template>
...@@ -5,9 +5,11 @@ import { ...@@ -5,9 +5,11 @@ import {
IssuableAttributeState as IssuableAttributeStateFoss, IssuableAttributeState as IssuableAttributeStateFoss,
issuableAttributesQueries as issuableAttributesQueriesFoss, issuableAttributesQueries as issuableAttributesQueriesFoss,
} from '~/sidebar/constants'; } from '~/sidebar/constants';
import updateStatusMutation from '~/sidebar/queries/updateStatus.mutation.graphql';
import epicAncestorsQuery from './queries/epic_ancestors.query.graphql'; import epicAncestorsQuery from './queries/epic_ancestors.query.graphql';
import groupEpicsQuery from './queries/group_epics.query.graphql'; import groupEpicsQuery from './queries/group_epics.query.graphql';
import groupIterationsQuery from './queries/group_iterations.query.graphql'; import groupIterationsQuery from './queries/group_iterations.query.graphql';
import issueHealthStatusQuery from './queries/issue_health_status.query.graphql';
import issueWeightQuery from './queries/issue_weight.query.graphql'; import issueWeightQuery from './queries/issue_weight.query.graphql';
import projectIssueEpicMutation from './queries/project_issue_epic.mutation.graphql'; import projectIssueEpicMutation from './queries/project_issue_epic.mutation.graphql';
import projectIssueEpicQuery from './queries/project_issue_epic.query.graphql'; import projectIssueEpicQuery from './queries/project_issue_epic.query.graphql';
...@@ -137,3 +139,14 @@ export const weightQueries = { ...@@ -137,3 +139,14 @@ export const weightQueries = {
mutation: updateIssueWeightMutation, mutation: updateIssueWeightMutation,
}, },
}; };
export const healthStatusQueries = {
[IssuableType.Issue]: {
mutation: updateStatusMutation,
query: issueHealthStatusQuery,
},
[IssuableType.Epic]: {
mutation: updateStatusMutation,
query: issueHealthStatusQuery,
},
};
...@@ -42,15 +42,18 @@ const mountWeightComponent = () => { ...@@ -42,15 +42,18 @@ const mountWeightComponent = () => {
}); });
}; };
const mountStatusComponent = (mediator) => { const mountStatusComponent = () => {
const el = document.querySelector('.js-sidebar-status-entry-point'); const el = document.querySelector('.js-sidebar-status-entry-point');
if (!el) { if (!el) {
return false; return false;
} }
const { iid, fullPath, issuableType, canEdit } = el.dataset;
return new Vue({ return new Vue({
el, el,
apolloProvider,
store, store,
components: { components: {
SidebarStatus, SidebarStatus,
...@@ -58,7 +61,10 @@ const mountStatusComponent = (mediator) => { ...@@ -58,7 +61,10 @@ const mountStatusComponent = (mediator) => {
render: (createElement) => render: (createElement) =>
createElement('sidebar-status', { createElement('sidebar-status', {
props: { props: {
mediator, issuableType,
iid,
fullPath,
canUpdate: parseBoolean(canEdit),
}, },
}), }),
}); });
......
query issueHealthStatus($fullPath: ID!, $iid: String) {
workspace: project(fullPath: $fullPath) {
__typename
issuable: issue(iid: $iid) {
__typename
id
healthStatus
state
}
}
}
import Store from 'ee/sidebar/stores/sidebar_store'; import Store from 'ee/sidebar/stores/sidebar_store';
import updateStatusMutation from '~/sidebar/queries/updateStatus.mutation.graphql';
import CESidebarMediator from '~/sidebar/sidebar_mediator'; import CESidebarMediator from '~/sidebar/sidebar_mediator';
export default class SidebarMediator extends CESidebarMediator { export default class SidebarMediator extends CESidebarMediator {
...@@ -28,20 +27,4 @@ export default class SidebarMediator extends CESidebarMediator { ...@@ -28,20 +27,4 @@ export default class SidebarMediator extends CESidebarMediator {
throw err; throw err;
}); });
} }
updateStatus(healthStatus) {
this.store.setFetchingState('status', true);
return this.service
.updateWithGraphQl(updateStatusMutation, { healthStatus })
.then(({ data }) => {
if (data?.updateIssue?.errors?.length > 0) {
throw data.updateIssue.errors[0];
}
this.store.setStatus(data?.updateIssue?.issue?.healthStatus);
})
.catch((error) => {
throw error;
})
.finally(() => this.store.setFetchingState('status', false));
}
} }
export const getHealthStatusMutationResponse = ({ healthStatus = null }) => {
return {
data: {
updateIssue: {
issuable: { id: 'gid://gitlab/Issue/1', healthStatus, __typename: 'Issue' },
errors: [],
__typename: 'UpdateIssuePayload',
},
},
};
};
export const getHealthStatusQueryResponse = ({ state = 'opened', healthStatus = null }) => {
return {
data: {
workspace: {
issuable: { id: 'gid://gitlab/Issue/1', state, healthStatus, __typename: 'Issue' },
__typename: 'Project',
},
},
};
};
import { shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import SidebarStatus from 'ee/sidebar/components/status/sidebar_status.vue'; import SidebarStatus from 'ee/sidebar/components/status/sidebar_status.vue';
import Status from 'ee/sidebar/components/status/status.vue'; import Status from 'ee/sidebar/components/status/status.vue';
import { healthStatusQueries } from 'ee/sidebar/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import { getHealthStatusMutationResponse, getHealthStatusQueryResponse } from './mock_data';
Vue.use(Vuex); const localVue = createLocalVue();
localVue.use(VueApollo);
const createQueryHandler = ({ state, healthStatus }) =>
jest.fn().mockResolvedValue(getHealthStatusQueryResponse({ state, healthStatus }));
const createMutationHandler = ({ healthStatus }) =>
jest.fn().mockResolvedValue(getHealthStatusMutationResponse({ healthStatus }));
let queryHandler;
let mutationHandler;
function createMockApolloProvider({ healthStatus, state }) {
queryHandler = createQueryHandler({ healthStatus, state });
mutationHandler = createMutationHandler({ healthStatus });
return createMockApollo([
[healthStatusQueries.issue.query, queryHandler],
[healthStatusQueries.issue.mutation, mutationHandler],
]);
}
describe('SidebarStatus', () => { describe('SidebarStatus', () => {
let mediator;
let wrapper; let wrapper;
const createMediator = (states) => { const createWrapper = ({
mediator = { issuableType = 'issue',
updateStatus: jest.fn().mockResolvedValue(), state = 'opened',
store: { healthStatus = 'onTrack',
isFetching: { } = {}) => {
status: true,
},
status: '',
...states,
},
};
};
const createWrapper = ({ noteableState } = {}) => {
const store = new Vuex.Store({
getters: {
getNoteableData: () => ({ state: noteableState }),
},
});
wrapper = shallowMount(SidebarStatus, { wrapper = shallowMount(SidebarStatus, {
localVue,
propsData: { propsData: {
mediator, issuableType,
iid: '1',
fullPath: 'foo/bar',
canUpdate: true,
}, },
store, apolloProvider: createMockApolloProvider({ healthStatus, state }),
}); });
}; };
beforeEach(() => { beforeEach(() => {
createMediator(); createWrapper();
createWrapper({
getters: {
getNoteableData: {},
},
});
}); });
afterEach(() => { afterEach(() => {
...@@ -53,17 +57,16 @@ describe('SidebarStatus', () => { ...@@ -53,17 +57,16 @@ describe('SidebarStatus', () => {
describe('computed', () => { describe('computed', () => {
describe.each` describe.each`
noteableState | isOpen state | isOpen
${'opened'} | ${true} ${'opened'} | ${true}
${'reopened'} | ${true} ${'reopened'} | ${true}
${'closed'} | ${false} ${'closed'} | ${false}
`('isOpen', ({ noteableState, isOpen }) => { `('isOpen', ({ state, isOpen }) => {
beforeEach(() => { beforeEach(() => {
createMediator({ editable: true }); createWrapper({ state });
createWrapper({ noteableState });
}); });
it(`returns ${isOpen} when issue is ${noteableState}`, () => { it(`returns ${isOpen} when issue is ${state}`, () => {
expect(wrapper.vm.isOpen).toBe(isOpen); expect(wrapper.vm.isOpen).toBe(isOpen);
}); });
}); });
...@@ -76,10 +79,16 @@ describe('SidebarStatus', () => { ...@@ -76,10 +79,16 @@ describe('SidebarStatus', () => {
expect(wrapper.find(Status).exists()).toBe(true); expect(wrapper.find(Status).exists()).toBe(true);
}); });
it('calls mediator status update when receiving an onDropdownClick event from Status component', () => { it('calls apollo mutate when receiving an onDropdownClick event from Status component', () => {
wrapper.find(Status).vm.$emit('onDropdownClick', 'onTrack'); wrapper.find(Status).vm.$emit('onDropdownClick', 'onTrack');
expect(mediator.updateStatus).toHaveBeenCalledWith('onTrack'); const variables = {
projectPath: 'foo/bar',
iid: '1',
healthStatus: 'onTrack',
};
expect(mutationHandler).toHaveBeenCalledWith(expect.objectContaining(variables));
}); });
}); });
}); });
import SidebarMediator from 'ee/sidebar/sidebar_mediator'; import SidebarMediator from 'ee/sidebar/sidebar_mediator';
import waitForPromises from 'helpers/wait_for_promises';
import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarService from '~/sidebar/services/sidebar_service';
import CESidebarMediator from '~/sidebar/sidebar_mediator'; import CESidebarMediator from '~/sidebar/sidebar_mediator';
import CESidebarStore from '~/sidebar/stores/sidebar_store'; import CESidebarStore from '~/sidebar/stores/sidebar_store';
...@@ -27,28 +26,4 @@ describe('EE Sidebar mediator', () => { ...@@ -27,28 +26,4 @@ describe('EE Sidebar mediator', () => {
expect(mediator.store.weight).toBe(mockData.weight); expect(mediator.store.weight).toBe(mockData.weight);
expect(mediator.store.status).toBe(mockGraphQlData.project.issue.healthStatus); expect(mediator.store.status).toBe(mockGraphQlData.project.issue.healthStatus);
}); });
it('updates status when updateStatus is called', () => {
const healthStatus = 'onTrack';
jest.spyOn(mediator.service, 'updateWithGraphQl').mockReturnValue(
Promise.resolve({
data: {
updateIssue: {
issue: {
healthStatus,
},
},
},
}),
);
expect(mediator.store.status).toBe('');
mediator.updateStatus(healthStatus);
return waitForPromises().then(() => {
expect(mediator.store.status).toBe(healthStatus);
});
});
}); });
...@@ -12855,6 +12855,9 @@ msgstr "" ...@@ -12855,6 +12855,9 @@ msgstr ""
msgid "Error occurred when saving reviewers" msgid "Error occurred when saving reviewers"
msgstr "" msgstr ""
msgid "Error occurred while updating the %{issuableType} status"
msgstr ""
msgid "Error occurred while updating the issue status" msgid "Error occurred while updating the issue status"
msgstr "" msgstr ""
...@@ -30536,6 +30539,9 @@ msgstr "" ...@@ -30536,6 +30539,9 @@ msgstr ""
msgid "Something went wrong while setting %{issuableType} confidentiality." msgid "Something went wrong while setting %{issuableType} confidentiality."
msgstr "" msgstr ""
msgid "Something went wrong while setting %{issuableType} health status."
msgstr ""
msgid "Something went wrong while setting %{issuableType} notifications." msgid "Something went wrong while setting %{issuableType} notifications."
msgstr "" msgstr ""
......
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