Commit bca90be3 authored by Coung Ngo's avatar Coung Ngo Committed by Natalia Tepluhina

Add health status to issue sidebar

Added new read-only feature which is behind a feature flag
parent ebaaefc3
query ($fullPath: ID!, $iid: String!) {
project (fullPath: $fullPath) {
issue (iid: $iid) {
iid
}
}
}
query ($fullPath: ID!, $iid: String!) {
project (fullPath: $fullPath) {
issue (iid: $iid) {
iid
}
}
}
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import sidebarDetailsQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql';
import sidebarDetailsForHealthStatusFeatureFlagQuery from 'ee_else_ce/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql';
export const gqClient = createGqClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
},
);
export default class SidebarService { export default class SidebarService {
constructor(endpointMap) { constructor(endpointMap) {
...@@ -7,6 +17,8 @@ export default class SidebarService { ...@@ -7,6 +17,8 @@ export default class SidebarService {
this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint; this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint;
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint; this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
this.fullPath = endpointMap.fullPath;
this.id = endpointMap.id;
SidebarService.singleton = this; SidebarService.singleton = this;
} }
...@@ -15,7 +27,20 @@ export default class SidebarService { ...@@ -15,7 +27,20 @@ export default class SidebarService {
} }
get() { get() {
return axios.get(this.endpoint); const hasHealthStatusFeatureFlag = gon.features && gon.features.saveIssuableHealthStatus;
return Promise.all([
axios.get(this.endpoint),
gqClient.query({
query: hasHealthStatusFeatureFlag
? sidebarDetailsForHealthStatusFeatureFlagQuery
: sidebarDetailsQuery,
variables: {
fullPath: this.fullPath,
iid: this.id.toString(),
},
}),
]);
} }
update(key, data) { update(key, data) {
......
...@@ -19,6 +19,8 @@ export default class SidebarMediator { ...@@ -19,6 +19,8 @@ export default class SidebarMediator {
toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint, toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint,
moveIssueEndpoint: options.moveIssueEndpoint, moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
fullPath: options.fullPath,
id: options.id,
}); });
SidebarMediator.singleton = this; SidebarMediator.singleton = this;
} }
...@@ -45,8 +47,8 @@ export default class SidebarMediator { ...@@ -45,8 +47,8 @@ export default class SidebarMediator {
fetch() { fetch() {
return this.service return this.service
.get() .get()
.then(({ data }) => { .then(([restResponse, graphQlResponse]) => {
this.processFetchedData(data); this.processFetchedData(restResponse.data, graphQlResponse.data);
}) })
.catch(() => new Flash(__('Error occurred when fetching sidebar data'))); .catch(() => new Flash(__('Error occurred when fetching sidebar data')));
} }
......
...@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group) push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:save_issuable_health_status, project.group)
end end
around_action :allow_gitaly_ref_name_caching, only: [:discussions] around_action :allow_gitaly_ref_name_caching, only: [:discussions]
......
...@@ -463,6 +463,7 @@ module IssuablesHelper ...@@ -463,6 +463,7 @@ module IssuablesHelper
currentUser: issuable[:current_user], currentUser: issuable[:current_user],
rootPath: root_path, rootPath: root_path,
fullPath: issuable[:project_full_path], fullPath: issuable[:project_full_path],
id: issuable[:id],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours
} }
end end
......
...@@ -129,6 +129,9 @@ ...@@ -129,6 +129,9 @@
= render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
- if Feature.enabled?(:save_issuable_health_status, @project.group) && issuable_sidebar[:type] == "issue"
.js-sidebar-status-entry-point
- if issuable_sidebar.has_key?(:confidential) - if issuable_sidebar.has_key?(:confidential)
-# haml-lint:disable InlineJavaScript -# haml-lint:disable InlineJavaScript
%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>
import Status from './status.vue';
export default {
components: {
Status,
},
props: {
mediator: {
required: true,
type: Object,
validator(mediatorObject) {
return Boolean(mediatorObject.store);
},
},
},
};
</script>
<template>
<status :is-fetching="mediator.store.isFetching.status" :status="mediator.store.status" />
</template>
<script>
import { GlIcon, GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { s__ } from '~/locale';
import { healthStatusColorMap, healthStatusTextMap } from '../../constants';
export default {
components: {
GlIcon,
GlLoadingIcon,
GlTooltip,
},
props: {
isFetching: {
type: Boolean,
required: false,
default: false,
},
status: {
type: String,
required: false,
default: '',
},
},
computed: {
statusText() {
return this.status ? healthStatusTextMap[this.status] : s__('Sidebar|None');
},
statusColor() {
return healthStatusColorMap[this.status];
},
tooltipText() {
let tooltipText = s__('Sidebar|Status');
if (this.status) {
tooltipText += `: ${this.statusText}`;
}
return tooltipText;
},
},
};
</script>
<template>
<div class="block">
<div ref="status" class="sidebar-collapsed-icon">
<gl-icon name="status" :size="14" />
<gl-loading-icon v-if="isFetching" />
<p v-else class="collapse-truncated-title px-1">{{ statusText }}</p>
</div>
<gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
{{ tooltipText }}
</gl-tooltip>
<div class="hide-collapsed">
<p class="title">{{ s__('Sidebar|Status') }}</p>
<gl-loading-icon v-if="isFetching" :inline="true" />
<p v-else class="value m-0" :class="{ 'no-value': !status }">
<gl-icon
v-if="status"
name="severity-low"
:size="14"
class="align-bottom mr-2"
:class="statusColor"
/>
{{ statusText }}
</p>
</div>
</div>
</template>
import { __ } from '~/locale';
export const healthStatus = {
ON_TRACK: 'onTrack',
NEEDS_ATTENTION: 'needsAttention',
AT_RISK: 'atRisk',
};
export const healthStatusColorMap = {
[healthStatus.ON_TRACK]: 'text-success',
[healthStatus.NEEDS_ATTENTION]: 'text-warning',
[healthStatus.AT_RISK]: 'text-danger',
};
export const healthStatusTextMap = {
[healthStatus.ON_TRACK]: __('On track'),
[healthStatus.NEEDS_ATTENTION]: __('Needs attention'),
[healthStatus.AT_RISK]: __('At risk'),
};
import Vue from 'vue'; import Vue from 'vue';
import * as CEMountSidebar from '~/sidebar/mount_sidebar';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import * as CEMountSidebar from '~/sidebar/mount_sidebar';
import sidebarWeight from './components/weight/sidebar_weight.vue';
import SidebarItemEpicsSelect from './components/sidebar_item_epics_select.vue'; import SidebarItemEpicsSelect from './components/sidebar_item_epics_select.vue';
import SidebarStatus from './components/status/sidebar_status.vue';
import SidebarWeight from './components/weight/sidebar_weight.vue';
import SidebarStore from './stores/sidebar_store'; import SidebarStore from './stores/sidebar_store';
const mountWeightComponent = mediator => { const mountWeightComponent = mediator => {
...@@ -15,7 +14,7 @@ const mountWeightComponent = mediator => { ...@@ -15,7 +14,7 @@ const mountWeightComponent = mediator => {
return new Vue({ return new Vue({
el, el,
components: { components: {
sidebarWeight, SidebarWeight,
}, },
render: createElement => render: createElement =>
createElement('sidebar-weight', { createElement('sidebar-weight', {
...@@ -26,6 +25,27 @@ const mountWeightComponent = mediator => { ...@@ -26,6 +25,27 @@ const mountWeightComponent = mediator => {
}); });
}; };
const mountStatusComponent = mediator => {
const el = document.querySelector('.js-sidebar-status-entry-point');
if (!el) {
return false;
}
return new Vue({
el,
components: {
SidebarStatus,
},
render: createElement =>
createElement('sidebar-status', {
props: {
mediator,
},
}),
});
};
const mountEpicsSelect = () => { const mountEpicsSelect = () => {
const el = document.querySelector('#js-vue-sidebar-item-epics-select'); const el = document.querySelector('#js-vue-sidebar-item-epics-select');
...@@ -55,5 +75,6 @@ const mountEpicsSelect = () => { ...@@ -55,5 +75,6 @@ const mountEpicsSelect = () => {
export default function mountSidebar(mediator) { export default function mountSidebar(mediator) {
CEMountSidebar.mountSidebar(mediator); CEMountSidebar.mountSidebar(mediator);
mountWeightComponent(mediator); mountWeightComponent(mediator);
mountStatusComponent(mediator);
mountEpicsSelect(); mountEpicsSelect();
} }
query ($fullPath: ID!, $iid: String!) {
project (fullPath: $fullPath) {
issue (iid: $iid) {
iid
}
}
}
query ($fullPath: ID!, $iid: String!) {
project (fullPath: $fullPath) {
issue (iid: $iid) {
healthStatus
}
}
}
...@@ -7,10 +7,11 @@ export default class SidebarMediator extends CESidebarMediator { ...@@ -7,10 +7,11 @@ export default class SidebarMediator extends CESidebarMediator {
this.store = new Store(options); this.store = new Store(options);
} }
processFetchedData(data) { processFetchedData(restData, graphQlData) {
super.processFetchedData(data); super.processFetchedData(restData);
this.store.setWeightData(data); this.store.setWeightData(restData);
this.store.setEpicData(data); this.store.setEpicData(restData);
this.store.setStatusData(graphQlData);
} }
updateWeight(newWeight) { updateWeight(newWeight) {
......
...@@ -4,15 +4,22 @@ export default class SidebarStore extends CESidebarStore { ...@@ -4,15 +4,22 @@ export default class SidebarStore extends CESidebarStore {
initSingleton(options) { initSingleton(options) {
super.initSingleton(options); super.initSingleton(options);
this.isFetching.status = true;
this.isFetching.weight = true; this.isFetching.weight = true;
this.isFetching.epic = true; this.isFetching.epic = true;
this.isLoading.weight = false; this.isLoading.weight = false;
this.status = '';
this.weight = null; this.weight = null;
this.weightOptions = options.weightOptions; this.weightOptions = options.weightOptions;
this.weightNoneValue = options.weightNoneValue; this.weightNoneValue = options.weightNoneValue;
this.epic = {}; this.epic = {};
} }
setStatusData(data) {
this.isFetching.status = false;
this.status = data?.project?.issue?.healthStatus;
}
setWeightData({ weight }) { setWeightData({ weight }) {
this.isFetching.weight = false; this.isFetching.weight = false;
this.weight = typeof weight === 'number' ? Number(weight) : null; this.weight = typeof weight === 'number' ? Number(weight) : null;
......
import { shallowMount } from '@vue/test-utils';
import SidebarStatus from 'ee/sidebar/components/status/sidebar_status.vue';
import Status from 'ee/sidebar/components/status/status.vue';
describe('SidebarStatus', () => {
it('renders Status component', () => {
const mediator = {
store: {
isFetching: {
status: true,
},
status: '',
},
};
const wrapper = shallowMount(SidebarStatus, {
propsData: {
mediator,
},
});
expect(wrapper.contains(Status)).toBe(true);
});
});
import { GlLoadingIcon, GlTooltip } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Status from 'ee/sidebar/components/status/status.vue';
import { healthStatus, healthStatusColorMap, healthStatusTextMap } from 'ee/sidebar/constants';
const getStatusText = wrapper => wrapper.find('.value').text();
const getTooltipText = wrapper => wrapper.find(GlTooltip).text();
const getStatusIconCssClasses = wrapper => wrapper.find('[name="severity-low"]').classes();
describe('Status', () => {
let wrapper;
function shallowMountStatus(propsData) {
wrapper = shallowMount(Status, {
propsData,
});
}
afterEach(() => {
wrapper.destroy();
});
it('shows the text "Status"', () => {
shallowMountStatus();
expect(wrapper.find('.title').text()).toBe('Status');
});
describe('loading icon', () => {
it('shows loader while retrieving data', () => {
const props = {
isFetching: true,
};
shallowMountStatus(props);
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
});
it('does not show loader when not retrieving data', () => {
const props = {
isFetching: false,
};
shallowMountStatus(props);
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
});
});
describe('status text', () => {
describe('when no value is provided for status', () => {
beforeEach(() => {
const props = {
status: '',
};
shallowMountStatus(props);
});
it('shows "None"', () => {
expect(getStatusText(wrapper)).toBe('None');
});
it('shows "Status" in the tooltip', () => {
expect(getTooltipText(wrapper)).toBe('Status');
});
});
describe.each(Object.values(healthStatus))(`when "%s" is provided for status`, statusValue => {
beforeEach(() => {
const props = {
status: statusValue,
};
shallowMountStatus(props);
});
it(`shows "${healthStatusTextMap[statusValue]}"`, () => {
expect(getStatusText(wrapper)).toBe(healthStatusTextMap[statusValue]);
});
it(`shows "Status: ${healthStatusTextMap[statusValue]}" in the tooltip`, () => {
expect(getTooltipText(wrapper)).toBe(`Status: ${healthStatusTextMap[statusValue]}`);
});
it(`uses ${healthStatusColorMap[statusValue]} color for the status icon`, () => {
expect(getStatusIconCssClasses(wrapper)).toContain(healthStatusColorMap[statusValue]);
});
});
});
});
...@@ -5,6 +5,7 @@ describe('EE Sidebar store', () => { ...@@ -5,6 +5,7 @@ describe('EE Sidebar store', () => {
let store; let store;
beforeEach(() => { beforeEach(() => {
store = new SidebarStore({ store = new SidebarStore({
status: '',
weight: null, weight: null,
weightOptions: ['None', 0, 1, 3], weightOptions: ['None', 0, 1, 3],
weightNoneValue: 'None', weightNoneValue: 'None',
...@@ -17,9 +18,26 @@ describe('EE Sidebar store', () => { ...@@ -17,9 +18,26 @@ describe('EE Sidebar store', () => {
CESidebarStore.singleton = null; CESidebarStore.singleton = null;
}); });
describe('setStatusData', () => {
it('sets status data', () => {
const graphQlData = {
project: {
issue: {
healthStatus: 'onTrack',
},
},
};
store.setStatusData(graphQlData);
expect(store.isFetching.status).toBe(false);
expect(store.status).toBe(graphQlData.project.issue.healthStatus);
});
});
describe('setWeightData', () => { describe('setWeightData', () => {
beforeEach(() => { beforeEach(() => {
expect(store.weight).toEqual(null); expect(store.weight).toBe(null);
}); });
it('sets weight data', () => { it('sets weight data', () => {
...@@ -28,8 +46,8 @@ describe('EE Sidebar store', () => { ...@@ -28,8 +46,8 @@ describe('EE Sidebar store', () => {
weight, weight,
}); });
expect(store.isFetching.weight).toEqual(false); expect(store.isFetching.weight).toBe(false);
expect(store.weight).toEqual(weight); expect(store.weight).toBe(weight);
}); });
it('supports 0 weight', () => { it('supports 0 weight', () => {
...@@ -42,10 +60,10 @@ describe('EE Sidebar store', () => { ...@@ -42,10 +60,10 @@ describe('EE Sidebar store', () => {
}); });
it('set weight', () => { it('set weight', () => {
expect(store.weight).toEqual(null); expect(store.weight).toBe(null);
const weight = 1; const weight = 1;
store.setWeight(weight); store.setWeight(weight);
expect(store.weight).toEqual(weight); expect(store.weight).toBe(weight);
}); });
}); });
...@@ -20,8 +20,10 @@ describe('EE Sidebar mediator', () => { ...@@ -20,8 +20,10 @@ describe('EE Sidebar mediator', () => {
it('processes fetched data', () => { it('processes fetched data', () => {
const mockData = const mockData =
Mock.responseMap.GET['/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar']; Mock.responseMap.GET['/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar'];
mediator.processFetchedData(mockData); const mockGraphQlData = Mock.graphQlResponseData;
mediator.processFetchedData(mockData, mockGraphQlData);
expect(mediator.store.weight).toEqual(mockData.weight); expect(mediator.store.weight).toBe(mockData.weight);
expect(mediator.store.status).toBe(mockGraphQlData.project.issue.healthStatus);
}); });
}); });
...@@ -2453,6 +2453,9 @@ msgstr "" ...@@ -2453,6 +2453,9 @@ msgstr ""
msgid "At least one of group_id or project_id must be specified" msgid "At least one of group_id or project_id must be specified"
msgstr "" msgstr ""
msgid "At risk"
msgstr ""
msgid "Attach a file" msgid "Attach a file"
msgstr "" msgstr ""
...@@ -12790,6 +12793,9 @@ msgstr "" ...@@ -12790,6 +12793,9 @@ msgstr ""
msgid "Need help?" msgid "Need help?"
msgstr "" msgstr ""
msgid "Needs attention"
msgstr ""
msgid "Network" msgid "Network"
msgstr "" msgstr ""
...@@ -13398,6 +13404,9 @@ msgstr "" ...@@ -13398,6 +13404,9 @@ msgstr ""
msgid "Omnibus Protected Paths throttle is active. From 12.4, Omnibus throttle is deprecated and will be removed in a future release. Please read the %{relative_url_link_start}Migrating Protected Paths documentation%{relative_url_link_end}." msgid "Omnibus Protected Paths throttle is active. From 12.4, Omnibus throttle is deprecated and will be removed in a future release. Please read the %{relative_url_link_start}Migrating Protected Paths documentation%{relative_url_link_end}."
msgstr "" msgstr ""
msgid "On track"
msgstr ""
msgid "Onboarding" msgid "Onboarding"
msgstr "" msgstr ""
...@@ -17913,6 +17922,9 @@ msgstr "" ...@@ -17913,6 +17922,9 @@ msgstr ""
msgid "Sidebar|Only numeral characters allowed" msgid "Sidebar|Only numeral characters allowed"
msgstr "" msgstr ""
msgid "Sidebar|Status"
msgstr ""
msgid "Sidebar|Weight" msgid "Sidebar|Weight"
msgstr "" msgstr ""
......
...@@ -178,8 +178,17 @@ const RESPONSE_MAP = { ...@@ -178,8 +178,17 @@ const RESPONSE_MAP = {
}, },
}; };
const graphQlResponseData = {
project: {
issue: {
healthStatus: 'onTrack',
},
},
};
const mockData = { const mockData = {
responseMap: RESPONSE_MAP, responseMap: RESPONSE_MAP,
graphQlResponseData,
mediator: { mediator: {
endpoint: '/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar_extras', endpoint: '/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar_extras',
toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription', toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription',
...@@ -195,6 +204,7 @@ const mockData = { ...@@ -195,6 +204,7 @@ const mockData = {
}, },
rootPath: '/', rootPath: '/',
fullPath: '/gitlab-org/gitlab-shell', fullPath: '/gitlab-org/gitlab-shell',
id: 1,
}, },
time: { time: {
time_estimate: 3600, time_estimate: 3600,
......
...@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarMediator from '~/sidebar/sidebar_mediator';
import SidebarStore from '~/sidebar/stores/sidebar_store'; import SidebarStore from '~/sidebar/stores/sidebar_store';
import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarService, { gqClient } from '~/sidebar/services/sidebar_service';
import Mock from './mock_data'; import Mock from './mock_data';
const { mediator: mediatorMockData } = Mock; const { mediator: mediatorMockData } = Mock;
...@@ -44,12 +44,18 @@ describe('Sidebar mediator', function() { ...@@ -44,12 +44,18 @@ describe('Sidebar mediator', function() {
it('fetches the data', done => { it('fetches the data', done => {
const mockData = Mock.responseMap.GET[mediatorMockData.endpoint]; const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
mock.onGet(mediatorMockData.endpoint).reply(200, mockData); mock.onGet(mediatorMockData.endpoint).reply(200, mockData);
const mockGraphQlData = Mock.graphQlResponseData;
spyOn(gqClient, 'query').and.returnValue({
data: mockGraphQlData,
});
spyOn(this.mediator, 'processFetchedData').and.callThrough(); spyOn(this.mediator, 'processFetchedData').and.callThrough();
this.mediator this.mediator
.fetch() .fetch()
.then(() => { .then(() => {
expect(this.mediator.processFetchedData).toHaveBeenCalledWith(mockData); expect(this.mediator.processFetchedData).toHaveBeenCalledWith(mockData, mockGraphQlData);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
......
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