Commit 1e97ffdd authored by David O'Regan's avatar David O'Regan

Merge branch 'tr-incident-sla-text' into 'master'

Incident SLA - achieved and missed frontend states [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!57703
parents 4e3b102b 5df5ac82
...@@ -10,6 +10,7 @@ import { ...@@ -10,6 +10,7 @@ import {
GlIcon, GlIcon,
GlEmptyState, GlEmptyState,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee_else_ce/vue_shared/components/incidents/utils';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility'; import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
...@@ -287,6 +288,7 @@ export default { ...@@ -287,6 +288,7 @@ export default {
errorAlertDismissed() { errorAlertDismissed() {
this.isErrorAlertDismissed = true; this.isErrorAlertDismissed = true;
}, },
isValidSlaDueAt,
}, },
}; };
</script> </script>
...@@ -367,7 +369,13 @@ export default { ...@@ -367,7 +369,13 @@ export default {
</template> </template>
<template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }"> <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>
<template #cell(assignees)="{ item }"> <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
}
}
}
query getSlaDueAt($iid: String!, $fullPath: ID!) { query getSlaDueAt($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
issue(iid: $iid) { issue(iid: $iid) {
id
slaDueAt slaDueAt
} }
} }
......
...@@ -3,27 +3,27 @@ import { GlIcon } from '@gitlab/ui'; ...@@ -3,27 +3,27 @@ import { GlIcon } from '@gitlab/ui';
import { isValidSlaDueAt } from 'ee/vue_shared/components/incidents/utils'; import { isValidSlaDueAt } from 'ee/vue_shared/components/incidents/utils';
import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/service_level_agreement.vue'; import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/service_level_agreement.vue';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { formatTime, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
import { s__ } from '~/locale'; 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 { export default {
components: { GlIcon, ServiceLevelAgreement }, components: { GlIcon, ServiceLevelAgreement },
inject: ['fullPath', 'iid', 'slaFeatureAvailable'], inject: ['fullPath', 'iid', 'slaFeatureAvailable'],
apollo: { apollo: {
slaDueAt: { slaDueAt: {
query: getSlaDueAt, query: getSlaIncidentDataQuery,
variables() { variables() {
return { return {
fullPath: this.fullPath, fullPath: this.fullPath,
iid: this.iid, iid: this.iid,
}; };
}, },
update(data) { update({ project }) {
return data?.project?.issue?.slaDueAt; return project?.issue?.slaDueAt || null;
}, },
result({ data }) { result({ data }) {
const isValidSla = isValidSlaDueAt(data?.project?.issue?.slaDueAt); const issue = data?.project?.issue;
const isValidSla = isValidSlaDueAt(issue?.slaDueAt);
// Render component // Render component
this.hasData = isValidSla; this.hasData = isValidSla;
...@@ -40,18 +40,10 @@ export default { ...@@ -40,18 +40,10 @@ export default {
}, },
data() { data() {
return { return {
slaDueAt: null,
hasData: false, 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> </script>
...@@ -60,7 +52,7 @@ export default { ...@@ -60,7 +52,7 @@ export default {
<span class="gl-font-weight-bold">{{ s__('HighlightBar|Time to SLA:') }}</span> <span class="gl-font-weight-bold">{{ s__('HighlightBar|Time to SLA:') }}</span>
<span class="gl-white-space-nowrap"> <span class="gl-white-space-nowrap">
<gl-icon name="timer" /> <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> </span>
</div> </div>
</template> </template>
<script> <script>
import { GlTooltipDirective } from '@gitlab/ui'; 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 { formatTime, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import { isValidSlaDueAt } from './utils'; import { isValidSlaDueAt } from './utils';
export default { export default {
i18n: { i18n: {
achievedSLAText: s__('IncidentManagement|Achieved SLA'),
missedSLAText: s__('IncidentManagement|Missed SLA'),
longTitle: s__('IncidentManagement|%{hours} hours, %{minutes} minutes remaining'), longTitle: s__('IncidentManagement|%{hours} hours, %{minutes} minutes remaining'),
shortTitle: s__('IncidentManagement|%{minutes} minutes remaining'), shortTitle: s__('IncidentManagement|%{minutes} minutes remaining'),
}, },
// Refresh the timer display every 15 minutes.
REFRESH_INTERVAL: 15 * 60 * 1000,
directives: { directives: {
GlTooltip: GlTooltipDirective, 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: { props: {
slaDueAt: { slaDueAt: {
type: String, // ISODateString type: String, // ISODateString
required: false, required: false,
default: null, default: null,
}, },
issueIid: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
},
data() {
return {
issueState: null,
clientRemainingTime: null,
};
}, },
computed: { computed: {
shouldShow() { hasNoTimeRemaining() {
return isValidSlaDueAt(this.slaDueAt); return this.remainingTime === 0;
},
isMissedSLA() {
return this.hasNoTimeRemaining && !this.isClosed;
},
isAchievedSLA() {
return this.hasNoTimeRemaining && this.isClosed;
},
isClosed() {
return this.issueState === 'closed';
}, },
remainingTime() { remainingTime() {
return calculateRemainingMilliseconds(this.slaDueAt); return this.clientRemainingTime ?? calculateRemainingMilliseconds(this.slaDueAt);
},
shouldShow() {
return isValidSlaDueAt(this.slaDueAt);
}, },
slaText() { slaText() {
if (this.isMissedSLA) {
return this.$options.i18n.missedSLAText;
}
if (this.isAchievedSLA) {
return this.$options.i18n.achievedSLAText;
}
const remainingDuration = formatTime(this.remainingTime); const remainingDuration = formatTime(this.remainingTime);
// remove the seconds portion of the string // remove the seconds portion of the string
return remainingDuration.substring(0, remainingDuration.length - 3); return remainingDuration.substring(0, remainingDuration.length - 3);
}, },
slaTitle() { slaTitle() {
if (this.hasNoTimeRemaining) {
return '';
}
const minutes = Math.floor(this.remainingTime / 1000 / 60) % 60; const minutes = Math.floor(this.remainingTime / 1000 / 60) % 60;
const hours = Math.floor(this.remainingTime / 1000 / 60 / 60); const hours = Math.floor(this.remainingTime / 1000 / 60 / 60);
...@@ -42,6 +101,22 @@ export default { ...@@ -42,6 +101,22 @@ export default {
return sprintf(this.$options.i18n.shortTitle, { minutes }); 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> </script>
<template> <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/ ...@@ -5,7 +5,7 @@ import ServiceLevelAgreement from 'ee_component/vue_shared/components/incidents/
jest.mock('~/lib/utils/datetime_utility'); 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'; const mockSlaDueAt = '2020-01-01T00:00:00.000Z';
describe('Incident SLA', () => { 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 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'; 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', () => ({ jest.mock('~/lib/utils/datetime_utility', () => ({
calculateRemainingMilliseconds: jest.fn(() => 1000), calculateRemainingMilliseconds: jest.fn(() => 1000),
formatTime: jest.fn(() => '00:00:00'), formatTime: jest.fn(),
})); }));
const mockDateString = '2020-10-15T02:42:27Z'; 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; 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, { wrapper = shallowMount(ServiceLevelAgreementCell, {
localVue,
apolloProvider: mockApollo,
propsData: { propsData: {
...props, ...props,
issueIid: '5',
projectPath: 'test-project',
}, },
}); });
} }
...@@ -27,23 +67,22 @@ describe('Incidents Published Cell', () => { ...@@ -27,23 +67,22 @@ describe('Incidents Published Cell', () => {
} }
}); });
describe('Service Level Agreement Cell', () => { beforeEach(() => {
formatTime.mockImplementation(formatTimeActual);
});
describe('initial states', () => {
it('renders an empty cell by default', () => { it('renders an empty cell by default', () => {
mountComponent(); mountComponent();
expect(wrapper.html()).toBe(''); expect(wrapper.html()).toBe('');
}); });
it('renders a empty cell for an invalid date', () => { it('renders a empty cell for an invalid date', () => {
mountComponent({ slaDueAt: 'dfsgsdfg' }); mountComponent({ props: { slaDueAt: 'dfsgsdfg' } });
expect(wrapper.html()).toBe(''); expect(wrapper.html()).toBe('');
}); });
it('displays the correct time when displaying an SLA', () => {
formatTime.mockImplementation(() => '12:34:56');
mountComponent({ slaDueAt: mockDateString });
expect(wrapper.text()).toBe('12:34');
}); });
describe('tooltips', () => { describe('tooltips', () => {
...@@ -55,18 +94,93 @@ describe('Incidents Published Cell', () => { ...@@ -55,18 +94,93 @@ describe('Incidents Published Cell', () => {
${5} | ${7} | ${'5 hours, 7 minutes remaining'} ${5} | ${7} | ${'5 hours, 7 minutes remaining'}
${5} | ${0} | ${'5 hours, 0 minutes remaining'} ${5} | ${0} | ${'5 hours, 0 minutes remaining'}
${0} | ${7} | ${'7 minutes remaining'} ${0} | ${7} | ${'7 minutes remaining'}
${0} | ${0} | ${'0 minutes remaining'} ${0} | ${0} | ${''}
`( `(
'returns the correct message for: hours: "$hours", hinutes: "$minutes"', 'returns the correct message for: hours: "$hours", minutes: "$minutes"',
({ hours, minutes, expectedMessage }) => { ({ hours, minutes, expectedMessage }) => {
const testTime = hours * hoursInMilliseconds + minutes * minutesInMilliseconds; const testTime = hours * hoursInMilliseconds + minutes * minutesInMilliseconds;
calculateRemainingMilliseconds.mockImplementation(() => testTime); calculateRemainingMilliseconds.mockImplementationOnce(() => testTime);
mountComponent({ slaDueAt: mockDateString }); mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.attributes('title')).toBe(expectedMessage); 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.mockImplementationOnce(() => '12:34:56');
mountComponent({ props: { slaDueAt: mockDateString } });
expect(wrapper.text()).toBe('12:34');
});
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 "" ...@@ -17363,6 +17363,9 @@ msgstr ""
msgid "IncidentManagement|%{minutes} minutes remaining" msgid "IncidentManagement|%{minutes} minutes remaining"
msgstr "" msgstr ""
msgid "IncidentManagement|Achieved SLA"
msgstr ""
msgid "IncidentManagement|All" msgid "IncidentManagement|All"
msgstr "" msgstr ""
...@@ -17402,6 +17405,9 @@ msgstr "" ...@@ -17402,6 +17405,9 @@ msgstr ""
msgid "IncidentManagement|Medium - S3" msgid "IncidentManagement|Medium - S3"
msgstr "" msgstr ""
msgid "IncidentManagement|Missed SLA"
msgstr ""
msgid "IncidentManagement|No incidents to display." msgid "IncidentManagement|No incidents to display."
msgstr "" msgstr ""
......
...@@ -43,12 +43,10 @@ describe('Incidents List', () => { ...@@ -43,12 +43,10 @@ describe('Incidents List', () => {
const findLoader = () => wrapper.find(GlLoadingIcon); const findLoader = () => wrapper.find(GlLoadingIcon);
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip); const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]'); 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 findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']"); const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
const findEmptyState = () => wrapper.find(GlEmptyState); const findEmptyState = () => wrapper.find(GlEmptyState);
const findSeverity = () => wrapper.findAll(SeverityToken); const findSeverity = () => wrapper.findAll(SeverityToken);
const findIncidentSla = () => wrapper.findAll("[data-testid='incident-sla']");
function mountComponent({ data = {}, loading = false, provide = {} } = {}) { function mountComponent({ data = {}, loading = false, provide = {} } = {}) {
wrapper = mount(IncidentsList, { wrapper = mount(IncidentsList, {
...@@ -188,35 +186,6 @@ describe('Incidents List', () => { ...@@ -188,35 +186,6 @@ describe('Incidents List', () => {
joinPaths(`/project/issues/incident`, mockIncidents[0].iid), 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', () => { 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