Skip to content
Snippets Groups Projects
Commit bca90be3 authored by Coung Ngo's avatar Coung Ngo Committed by Natalia Tepluhina
Browse files

Add health status to issue sidebar

Added new read-only feature which is behind a feature flag
parent ebaaefc3
No related branches found
No related tags found
4 merge requests!158514Fix CodeReviewMetrics worker failure with kwargs,!27224Update stable branch yorick/test-release-tools for automatic RC 12.9.0-rc20200313145923.ee.0,!26962WIP: Allow GMA groups to specify their own PAT expiry setting (2/2),!24639WIP POC: Ss/realtime 2
Showing
with 371 additions and 19 deletions
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(),
  • Contributor

    @cngo this appears to be passing an ID as an IID? It may work locally, but if it does it's pure coincidence: https://docs.gitlab.com/ee/api/README.html#id-vs-iid

    See gitlab-com/gl-infra/production#1772 (comment 305422895)

  • Author Maintainer

    @smcgivern This was pointed out in my following MR where it was corrected from id to iid: !25371 (diffs)

  • Author Maintainer

    Looks like !25371 (merged) is merged but not yet in production so production is still using id!

    This shouldn't be a problem though for now since the query essentially gets information that's not used by GitLab. This is here as the foundation of all graphql queries for the sidebar

    Edited by Coung Ngo
  • Contributor

    Thanks @cngo, that's exactly the problem: we already see this in production. However, it's not harmful (it will just fail silently), and as it's already been fixed, we're all good!

  • Please register or sign in to reply
},
}),
]);
}
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 @@ def set_issuables_index_only_actions
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 @@ def issuable_sidebar_options(issuable)
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 ""
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment