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 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 {
constructor(endpointMap) {
......@@ -7,6 +17,8 @@ export default class SidebarService {
this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint;
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
this.fullPath = endpointMap.fullPath;
this.id = endpointMap.id;
SidebarService.singleton = this;
}
......@@ -15,7 +27,20 @@ export default class SidebarService {
}
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) {
......
......@@ -19,6 +19,8 @@ export default class SidebarMediator {
toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint,
moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
fullPath: options.fullPath,
id: options.id,
});
SidebarMediator.singleton = this;
}
......@@ -45,8 +47,8 @@ export default class SidebarMediator {
fetch() {
return this.service
.get()
.then(({ data }) => {
this.processFetchedData(data);
.then(([restResponse, graphQlResponse]) => {
this.processFetchedData(restResponse.data, graphQlResponse.data);
})
.catch(() => new Flash(__('Error occurred when fetching sidebar data')));
}
......
......@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:save_issuable_health_status, project.group)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
......
......@@ -463,6 +463,7 @@ module IssuablesHelper
currentUser: issuable[:current_user],
rootPath: root_path,
fullPath: issuable[:project_full_path],
id: issuable[:id],
timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours
}
end
......
......@@ -129,6 +129,9 @@
= 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)
-# 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>
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 * as CEMountSidebar from '~/sidebar/mount_sidebar';
import { parseBoolean } from '~/lib/utils/common_utils';
import sidebarWeight from './components/weight/sidebar_weight.vue';
import * as CEMountSidebar from '~/sidebar/mount_sidebar';
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';
const mountWeightComponent = mediator => {
......@@ -15,7 +14,7 @@ const mountWeightComponent = mediator => {
return new Vue({
el,
components: {
sidebarWeight,
SidebarWeight,
},
render: createElement =>
createElement('sidebar-weight', {
......@@ -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 el = document.querySelector('#js-vue-sidebar-item-epics-select');
......@@ -55,5 +75,6 @@ const mountEpicsSelect = () => {
export default function mountSidebar(mediator) {
CEMountSidebar.mountSidebar(mediator);
mountWeightComponent(mediator);
mountStatusComponent(mediator);
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 {
this.store = new Store(options);
}
processFetchedData(data) {
super.processFetchedData(data);
this.store.setWeightData(data);
this.store.setEpicData(data);
processFetchedData(restData, graphQlData) {
super.processFetchedData(restData);
this.store.setWeightData(restData);
this.store.setEpicData(restData);
this.store.setStatusData(graphQlData);
}
updateWeight(newWeight) {
......
......@@ -4,15 +4,22 @@ export default class SidebarStore extends CESidebarStore {
initSingleton(options) {
super.initSingleton(options);
this.isFetching.status = true;
this.isFetching.weight = true;
this.isFetching.epic = true;
this.isLoading.weight = false;
this.status = '';
this.weight = null;
this.weightOptions = options.weightOptions;
this.weightNoneValue = options.weightNoneValue;
this.epic = {};
}
setStatusData(data) {
this.isFetching.status = false;
this.status = data?.project?.issue?.healthStatus;
}
setWeightData({ weight }) {
this.isFetching.weight = false;
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', () => {
let store;
beforeEach(() => {
store = new SidebarStore({
status: '',
weight: null,
weightOptions: ['None', 0, 1, 3],
weightNoneValue: 'None',
......@@ -17,9 +18,26 @@ describe('EE Sidebar store', () => {
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', () => {
beforeEach(() => {
expect(store.weight).toEqual(null);
expect(store.weight).toBe(null);
});
it('sets weight data', () => {
......@@ -28,8 +46,8 @@ describe('EE Sidebar store', () => {
weight,
});
expect(store.isFetching.weight).toEqual(false);
expect(store.weight).toEqual(weight);
expect(store.isFetching.weight).toBe(false);
expect(store.weight).toBe(weight);
});
it('supports 0 weight', () => {
......@@ -42,10 +60,10 @@ describe('EE Sidebar store', () => {
});
it('set weight', () => {
expect(store.weight).toEqual(null);
expect(store.weight).toBe(null);
const weight = 1;
store.setWeight(weight);
expect(store.weight).toEqual(weight);
expect(store.weight).toBe(weight);
});
});
......@@ -20,8 +20,10 @@ describe('EE Sidebar mediator', () => {
it('processes fetched data', () => {
const mockData =
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 ""
msgid "At least one of group_id or project_id must be specified"
msgstr ""
msgid "At risk"
msgstr ""
msgid "Attach a file"
msgstr ""
......@@ -12790,6 +12793,9 @@ msgstr ""
msgid "Need help?"
msgstr ""
msgid "Needs attention"
msgstr ""
msgid "Network"
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}."
msgstr ""
msgid "On track"
msgstr ""
msgid "Onboarding"
msgstr ""
......@@ -17913,6 +17922,9 @@ msgstr ""
msgid "Sidebar|Only numeral characters allowed"
msgstr ""
msgid "Sidebar|Status"
msgstr ""
msgid "Sidebar|Weight"
msgstr ""
......
......@@ -178,8 +178,17 @@ const RESPONSE_MAP = {
},
};
const graphQlResponseData = {
project: {
issue: {
healthStatus: 'onTrack',
},
},
};
const mockData = {
responseMap: RESPONSE_MAP,
graphQlResponseData,
mediator: {
endpoint: '/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar_extras',
toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription',
......@@ -195,6 +204,7 @@ const mockData = {
},
rootPath: '/',
fullPath: '/gitlab-org/gitlab-shell',
id: 1,
},
time: {
time_estimate: 3600,
......
......@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
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';
const { mediator: mediatorMockData } = Mock;
......@@ -44,12 +44,18 @@ describe('Sidebar mediator', function() {
it('fetches the data', done => {
const mockData = Mock.responseMap.GET[mediatorMockData.endpoint];
mock.onGet(mediatorMockData.endpoint).reply(200, mockData);
const mockGraphQlData = Mock.graphQlResponseData;
spyOn(gqClient, 'query').and.returnValue({
data: mockGraphQlData,
});
spyOn(this.mediator, 'processFetchedData').and.callThrough();
this.mediator
.fetch()
.then(() => {
expect(this.mediator.processFetchedData).toHaveBeenCalledWith(mockData);
expect(this.mediator.processFetchedData).toHaveBeenCalledWith(mockData, mockGraphQlData);
})
.then(done)
.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