Commit 5df5ac82 authored by Tristan Read's avatar Tristan Read Committed by David O'Regan

```

Adds dynamic updates to Incident SLA usages

Also provides additional text to a resolved SLA - either 'missed' or 'achieved'
depending on whether the issue is closed or open.

Changelog: changed
EE: true
```
parent 23551042
......@@ -10,6 +10,7 @@ import {
GlIcon,
GlEmptyState,
} from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
......@@ -287,6 +288,7 @@ export default {
errorAlertDismissed() {
this.isErrorAlertDismissed = true;
},
isValidSlaDueAt,
},
};
</script>
......@@ -367,7 +369,13 @@ export default {
</template>
<template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }">
<service-level-agreement-cell :sla-due-at="item.slaDueAt" data-testid="incident-sla" />
<service-level-agreement-cell
v-if="isValidSlaDueAt(item.slaDueAt)"
:issue-iid="item.iid"
:project-path="projectPath"
:sla-due-at="item.slaDueAt"
data-testid="incident-sla"
/>
</template>
<template #cell(assignees)="{ item }">
......
import { noop } from 'lodash';
export const isValidSlaDueAt = noop;
query getIncidentState($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
id
state
}
}
}
......@@ -3,27 +3,27 @@ import { GlIcon } from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee/vue_shared/components/incidents/utils';
import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/service_level_agreement.vue';
import createFlash from '~/flash';
import { formatTime, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale';
import getSlaDueAt from './graphql/queries/get_sla_due_at.graphql';
import getSlaIncidentDataQuery from './graphql/queries/get_sla_due_at.query.graphql';
export default {
components: { GlIcon, ServiceLevelAgreement },
inject: ['fullPath', 'iid', 'slaFeatureAvailable'],
apollo: {
slaDueAt: {
query: getSlaDueAt,
query: getSlaIncidentDataQuery,
variables() {
return {
fullPath: this.fullPath,
iid: this.iid,
};
},
update(data) {
return data?.project?.issue?.slaDueAt;
update({ project }) {
return project?.issue?.slaDueAt || null;
},
result({ data }) {
const isValidSla = isValidSlaDueAt(data?.project?.issue?.slaDueAt);
const issue = data?.project?.issue;
const isValidSla = isValidSlaDueAt(issue?.slaDueAt);
// Render component
this.hasData = isValidSla;
......@@ -40,18 +40,10 @@ export default {
},
data() {
return {
slaDueAt: null,
hasData: false,
slaDueAt: null,
};
},
computed: {
displayValue() {
const time = formatTime(calculateRemainingMilliseconds(this.slaDueAt));
// remove the seconds portion of the string
return time.substring(0, time.length - 3);
},
},
};
</script>
......@@ -60,7 +52,7 @@ export default {
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Time to SLA:') }}</span>
<span class="gl-white-space-nowrap">
<gl-icon name="timer" />
<service-level-agreement :sla-due-at="slaDueAt" />
<service-level-agreement :sla-due-at="slaDueAt" :issue-iid="iid" :project-path="fullPath" />
</span>
</div>
</template>
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import getIncidentStateQuery from 'ee/graphql_shared/queries/get_incident_state.query.graphql';
import { formatTime, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '~/locale';
import { isValidSlaDueAt } from './utils';
export default {
i18n: {
achievedSLAText: s__('IncidentManagement|Achieved SLA'),
missedSLAText: s__('IncidentManagement|Missed SLA'),
longTitle: s__('IncidentManagement|%{hours} hours, %{minutes} minutes remaining'),
shortTitle: s__('IncidentManagement|%{minutes} minutes remaining'),
},
// Refresh the timer display every 15 minutes.
REFRESH_INTERVAL: 15 * 60 * 1000,
directives: {
GlTooltip: GlTooltipDirective,
},
apollo: {
issueState: {
query: getIncidentStateQuery,
variables() {
return {
iid: this.issueIid,
fullPath: this.projectPath,
};
},
skip() {
return this.remainingTime > 0;
},
update(data) {
return data?.project?.issue?.state;
},
},
},
props: {
slaDueAt: {
type: String, // ISODateString
required: false,
default: null,
},
issueIid: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
},
data() {
return {
issueState: null,
clientRemainingTime: null,
};
},
computed: {
shouldShow() {
return isValidSlaDueAt(this.slaDueAt);
hasNoTimeRemaining() {
return this.remainingTime === 0;
},
isMissedSLA() {
return this.hasNoTimeRemaining && !this.isClosed;
},
isAchievedSLA() {
return this.hasNoTimeRemaining && this.isClosed;
},
isClosed() {
return this.issueState === 'closed';
},
remainingTime() {
return calculateRemainingMilliseconds(this.slaDueAt);
return this.clientRemainingTime ?? calculateRemainingMilliseconds(this.slaDueAt);
},
shouldShow() {
return isValidSlaDueAt(this.slaDueAt);
},
slaText() {
if (this.isMissedSLA) {
return this.$options.i18n.missedSLAText;
}
if (this.isAchievedSLA) {
return this.$options.i18n.achievedSLAText;
}
const remainingDuration = formatTime(this.remainingTime);
// remove the seconds portion of the string
return remainingDuration.substring(0, remainingDuration.length - 3);
},
slaTitle() {
if (this.hasNoTimeRemaining) {
return '';
}
const minutes = Math.floor(this.remainingTime / 1000 / 60) % 60;
const hours = Math.floor(this.remainingTime / 1000 / 60 / 60);
......@@ -42,6 +101,22 @@ export default {
return sprintf(this.$options.i18n.shortTitle, { minutes });
},
},
mounted() {
this.timer = setInterval(this.refreshTime, this.$options.REFRESH_INTERVAL);
},
beforeDestroy() {
clearTimeout(this.timer);
},
methods: {
refreshTime() {
if (this.remainingTime > this.$options.REFRESH_INTERVAL) {
this.clientRemainingTime = this.remainingTime - this.$options.REFRESH_INTERVAL;
} else {
clearTimeout(this.timer);
this.clientRemainingTime = 0;
}
},
},
};
</script>
<template>
......
[
{
"iid": "15",
"title": "New: Alert",
"createdAt": "2020-06-03T15:46:08Z",
"assignees": {},
"state": "opened",
"severity": "CRITICAL",
"slaDueAt": "2020-06-04T12:46:08Z"
},
{
"iid": "14",
"title": "Create issue4",
"createdAt": "2020-05-19T09:26:07Z",
"assignees": {
"nodes": [
{
"name": "Benjamin Braun",
"username": "kami.hegmann",
"avatarUrl": "https://invalid'",
"webUrl": "https://invalid"
}
]
},
"state": "opened",
"severity": "HIGH",
"slaDueAt": "2020-06-05T12:46:08Z"
},
{
"iid": "13",
"title": "Create issue3",
"createdAt": "2020-05-19T08:53:55Z",
"assignees": {},
"state": "closed",
"severity": "LOW"
},
{
"iid": "12",
"title": "Create issue2",
"createdAt": "2020-05-18T17:13:35Z",
"assignees": {},
"state": "closed",
"severity": "MEDIUM"
}
]
import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/service_level_agreement.vue';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import IncidentsList from '~/incidents/components/incidents_list.vue';
import mockIncidents from './mocks/incidents.json';
const defaultProvide = {
projectPath: '/project/path',
newIssuePath: 'namespace/project/-/issues/new',
incidentTemplateName: 'incident',
incidentType: 'incident',
issuePath: '/project/issues',
publishedAvailable: true,
emptyListSvgPath: '/assets/empty.svg',
textQuery: '',
authorUsernameQuery: '',
assigneeUsernameQuery: '',
slaFeatureAvailable: true,
};
describe('Incidents Service Level Agreement', () => {
let wrapper;
const findIncidentSlaHeader = () => wrapper.findByTestId('incident-management-sla');
const findIncidentSLAs = () => wrapper.findAllComponents(ServiceLevelAgreement);
function mountComponent(provide = {}) {
wrapper = mountExtended(IncidentsList, {
data() {
return {
incidents: { list: mockIncidents },
incidentsCount: {},
};
},
mocks: {
$apollo: {
queries: {
incidents: {
loading: false,
},
},
},
},
provide: {
...defaultProvide,
...provide,
},
});
}
afterEach(() => {
wrapper.destroy();
});
describe('Incident SLA field', () => {
it('displays the column when the feature is available', () => {
mountComponent({ slaFeatureAvailable: true });
expect(findIncidentSlaHeader().text()).toContain('Time to SLA');
});
it('does not display the column when the feature is not available', () => {
mountComponent({ slaFeatureAvailable: false });
expect(findIncidentSlaHeader().exists()).toBe(false);
});
it('renders an SLA for each incident with an SLA', () => {
mountComponent({ slaFeatureAvailable: true });
expect(findIncidentSLAs()).toHaveLength(2);
});
});
});
......@@ -5,7 +5,7 @@ import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/
jest.mock('~/lib/utils/datetime_utility');
const defaultProvide = { fullPath: 'test', iid: 1, slaFeatureAvailable: true };
const defaultProvide = { fullPath: 'test', iid: '1', slaFeatureAvailable: true };
const mockSlaDueAt = '2020-01-01T00:00:00.000Z';
describe('Incident SLA', () => {
......
import { shallowMount } from '@vue/test-utils';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import getIncidentStateQuery from 'ee/graphql_shared/queries/get_incident_state.query.graphql';
import ServiceLevelAgreementCell from 'ee/vue_shared/components/incidents/service_level_agreement.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility';
const localVue = createLocalVue();
const formatTimeActual = jest.requireActual('~/lib/utils/datetime_utility').formatTime;
jest.mock('~/lib/utils/datetime_utility', () => ({
calculateRemainingMilliseconds: jest.fn(() => 1000),
formatTime: jest.fn(() => '00:00:00'),
formatTime: jest.fn(),
}));
const mockDateString = '2020-10-15T02:42:27Z';
describe('Incidents Published Cell', () => {
const ONE_MINUTE = 60 * 1000; // ms
const MINUTES = {
FIVE: 5 * ONE_MINUTE,
FIFTEEN: 15 * ONE_MINUTE,
TWENTY: 20 * ONE_MINUTE,
THIRTY_FIVE: 35 * ONE_MINUTE,
};
const issueStateResponse = (state = 'opened') => ({
data: { project: { issue: { state, id: '1' } } },
});
describe('Service Level Agreement', () => {
let wrapper;
function mountComponent(props) {
const advanceFifteenMinutes = async () => {
jest.advanceTimersByTime(MINUTES.FIFTEEN);
await nextTick();
};
function createMockApolloProvider(issueState) {
localVue.use(VueApollo);
const requestHandlers = [
[getIncidentStateQuery, jest.fn().mockResolvedValue(issueStateResponse(issueState))],
];
return createMockApollo(requestHandlers);
}
function mountComponent({ mockApollo, props } = {}) {
wrapper = shallowMount(ServiceLevelAgreementCell, {
localVue,
apolloProvider: mockApollo,
propsData: {
...props,
issueIid: '5',
projectPath: 'test-project',
},
});
}
......@@ -27,46 +67,120 @@ describe('Incidents Published Cell', () => {
}
});
describe('Service Level Agreement Cell', () => {
beforeEach(() => {
formatTime.mockImplementation(formatTimeActual);
});
describe('initial states', () => {
it('renders an empty cell by default', () => {
mountComponent();
expect(wrapper.html()).toBe('');
});
it('renders a empty cell for an invalid date', () => {
mountComponent({ slaDueAt: 'dfsgsdfg' });
mountComponent({ props: { slaDueAt: 'dfsgsdfg' } });
expect(wrapper.html()).toBe('');
});
});
describe('tooltips', () => {
const hoursInMilliseconds = 60 * 60 * 1000;
const minutesInMilliseconds = 60 * 1000;
it.each`
hours | minutes | expectedMessage
${5} | ${7} | ${'5 hours, 7 minutes remaining'}
${5} | ${0} | ${'5 hours, 0 minutes remaining'}
${0} | ${7} | ${'7 minutes remaining'}
${0} | ${0} | ${''}
`(
'returns the correct message for: hours: "$hours", minutes: "$minutes"',
({ hours, minutes, expectedMessage }) => {
const testTime = hours * hoursInMilliseconds + minutes * minutesInMilliseconds;
calculateRemainingMilliseconds.mockImplementationOnce(() => testTime);
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.attributes('title')).toBe(expectedMessage);
},
);
});
describe('countdown timer', () => {
it('advances a countdown timer', async () => {
calculateRemainingMilliseconds.mockImplementationOnce(() => MINUTES.THIRTY_FIVE);
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.text()).toBe('00:35');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('00:20');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('00:05');
});
it('counts down to zero', async () => {
calculateRemainingMilliseconds.mockImplementationOnce(() => MINUTES.FIFTEEN);
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.text()).toBe('00:15');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('Missed SLA');
});
it('cleans up a countdown timer when countdown is complete', async () => {
calculateRemainingMilliseconds.mockImplementationOnce(() => MINUTES.FIVE);
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.text()).toBe('00:05');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('Missed SLA');
await advanceFifteenMinutes();
expect(wrapper.text()).toBe('Missed SLA');
// If the countdown timer was still running we would expect it to be called a second time
expect(formatTime).toHaveBeenCalledTimes(1);
expect(formatTime).toHaveBeenCalledWith(MINUTES.FIVE);
});
});
describe('SLA text', () => {
it('displays the correct time when displaying an SLA', () => {
formatTime.mockImplementation(() => '12:34:56');
formatTime.mockImplementationOnce(() => '12:34:56');
mountComponent({ slaDueAt: mockDateString });
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.text()).toBe('12:34');
});
describe('tooltips', () => {
const hoursInMilliseconds = 60 * 60 * 1000;
const minutesInMilliseconds = 60 * 1000;
it.each`
hours | minutes | expectedMessage
${5} | ${7} | ${'5 hours, 7 minutes remaining'}
${5} | ${0} | ${'5 hours, 0 minutes remaining'}
${0} | ${7} | ${'7 minutes remaining'}
${0} | ${0} | ${'0 minutes remaining'}
`(
'returns the correct message for: hours: "$hours", hinutes: "$minutes"',
({ hours, minutes, expectedMessage }) => {
const testTime = hours * hoursInMilliseconds + minutes * minutesInMilliseconds;
calculateRemainingMilliseconds.mockImplementation(() => testTime);
mountComponent({ slaDueAt: mockDateString });
expect(wrapper.attributes('title')).toBe(expectedMessage);
},
);
describe('text when remaining time is 0', () => {
beforeEach(() => {
calculateRemainingMilliseconds.mockImplementationOnce(() => 0);
});
it('shows the correct text when the SLA has been missed', async () => {
const issueState = 'open';
const mockApollo = createMockApolloProvider(issueState);
mountComponent({ props: { slaDueAt: mockDateString }, mockApollo });
await nextTick();
expect(wrapper.text()).toBe('Missed SLA');
});
it('shows the correct text when the SLA has been achieved', async () => {
const issueState = 'closed';
const mockApollo = createMockApolloProvider(issueState);
mountComponent({ props: { slaDueAt: mockDateString }, mockApollo });
await nextTick();
expect(wrapper.text()).toBe('Achieved SLA');
});
});
});
});
......@@ -17363,6 +17363,9 @@ msgstr ""
msgid "IncidentManagement|%{minutes} minutes remaining"
msgstr ""
msgid "IncidentManagement|Achieved SLA"
msgstr ""
msgid "IncidentManagement|All"
msgstr ""
......@@ -17402,6 +17405,9 @@ msgstr ""
msgid "IncidentManagement|Medium - S3"
msgstr ""
msgid "IncidentManagement|Missed SLA"
msgstr ""
msgid "IncidentManagement|No incidents to display."
msgstr ""
......
......@@ -43,12 +43,10 @@ describe('Incidents List', () => {
const findLoader = () => wrapper.find(GlLoadingIcon);
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]');
const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]');
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
const findEmptyState = () => wrapper.find(GlEmptyState);
const findSeverity = () => wrapper.findAll(SeverityToken);
const findIncidentSla = () => wrapper.findAll("[data-testid='incident-sla']");
function mountComponent({ data = {}, loading = false, provide = {} } = {}) {
wrapper = mount(IncidentsList, {
......@@ -188,35 +186,6 @@ describe('Incidents List', () => {
joinPaths(`/project/issues/incident`, mockIncidents[0].iid),
);
});
describe('Incident SLA field', () => {
it('displays the column when the feature is available', () => {
mountComponent({
data: { incidents: { list: mockIncidents } },
provide: { slaFeatureAvailable: true },
});
expect(findIncidentSlaHeader().text()).toContain('Time to SLA');
});
it('does not display the column when the feature is not available', () => {
mountComponent({
data: { incidents: { list: mockIncidents } },
provide: { slaFeatureAvailable: false },
});
expect(findIncidentSlaHeader().exists()).toBe(false);
});
it('renders an SLA for each incident', () => {
mountComponent({
data: { incidents: { list: mockIncidents } },
provide: { slaFeatureAvailable: true },
});
expect(findIncidentSla().length).toBe(mockIncidents.length);
});
});
});
describe('Create Incident', () => {
......
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