Commit 5f3de8d2 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

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

[RUN AS-IF-FOSS] Add SLA to incident list

See merge request gitlab-org/gitlab!44957
parents 679db733 6ff310b5
...@@ -39,6 +39,7 @@ import { ...@@ -39,6 +39,7 @@ import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
INCIDENT_STATUS_TABS, INCIDENT_STATUS_TABS,
TH_CREATED_AT_TEST_ID, TH_CREATED_AT_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
TH_SEVERITY_TEST_ID, TH_SEVERITY_TEST_ID,
TH_PUBLISHED_TEST_ID, TH_PUBLISHED_TEST_ID,
INCIDENT_DETAILS_PATH, INCIDENT_DETAILS_PATH,
...@@ -67,7 +68,7 @@ export default { ...@@ -67,7 +68,7 @@ export default {
{ {
key: 'severity', key: 'severity',
label: s__('IncidentManagement|Severity'), label: s__('IncidentManagement|Severity'),
thClass, thClass: `${thClass} w-15p`,
tdClass: `${tdClass} sortable-cell`, tdClass: `${tdClass} sortable-cell`,
sortable: true, sortable: true,
thAttr: TH_SEVERITY_TEST_ID, thAttr: TH_SEVERITY_TEST_ID,
...@@ -75,23 +76,38 @@ export default { ...@@ -75,23 +76,38 @@ export default {
{ {
key: 'title', key: 'title',
label: s__('IncidentManagement|Incident'), label: s__('IncidentManagement|Incident'),
thClass: `gl-pointer-events-none gl-w-half`, thClass: `gl-pointer-events-none`,
tdClass, tdClass,
}, },
{ {
key: 'createdAt', key: 'createdAt',
label: s__('IncidentManagement|Date created'), label: s__('IncidentManagement|Date created'),
thClass, thClass: `${thClass} gl-w-eighth`,
tdClass: `${tdClass} sortable-cell`, tdClass: `${tdClass} sortable-cell`,
sortable: true, sortable: true,
thAttr: TH_CREATED_AT_TEST_ID, thAttr: TH_CREATED_AT_TEST_ID,
}, },
{
key: 'incidentSla',
label: s__('IncidentManagement|Time to SLA'),
thClass: `gl-pointer-events-none gl-text-right gl-w-eighth`,
tdClass: `${tdClass} gl-text-right`,
thAttr: TH_INCIDENT_SLA_TEST_ID,
},
{ {
key: 'assignees', key: 'assignees',
label: s__('IncidentManagement|Assignees'), label: s__('IncidentManagement|Assignees'),
thClass: 'gl-pointer-events-none', thClass: 'gl-pointer-events-none w-15p',
tdClass, tdClass,
}, },
{
key: 'published',
label: s__('IncidentManagement|Published'),
thClass: `${thClass} w-15p`,
tdClass: `${tdClass} sortable-cell`,
sortable: true,
thAttr: TH_PUBLISHED_TEST_ID,
},
], ],
components: { components: {
GlLoadingIcon, GlLoadingIcon,
...@@ -107,6 +123,8 @@ export default { ...@@ -107,6 +123,8 @@ export default {
GlTabs, GlTabs,
GlTab, GlTab,
PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'), PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'),
ServiceLevelAgreementCell: () =>
import('ee_component/incidents/components/service_level_agreement_cell.vue'),
GlBadge, GlBadge,
GlEmptyState, GlEmptyState,
SeverityToken, SeverityToken,
...@@ -126,6 +144,7 @@ export default { ...@@ -126,6 +144,7 @@ export default {
'textQuery', 'textQuery',
'authorUsernamesQuery', 'authorUsernamesQuery',
'assigneeUsernamesQuery', 'assigneeUsernamesQuery',
'slaFeatureAvailable',
], ],
apollo: { apollo: {
incidents: { incidents: {
...@@ -231,21 +250,12 @@ export default { ...@@ -231,21 +250,12 @@ export default {
); );
}, },
availableFields() { availableFields() {
return this.publishedAvailable const isHidden = {
? [ published: !this.publishedAvailable,
...this.$options.fields, incidentSla: !this.slaFeatureAvailable,
...[ };
{
key: 'published', return this.$options.fields.filter(({ key }) => !isHidden[key]);
label: s__('IncidentManagement|Published'),
thClass,
tdClass: `${tdClass} sortable-cell`,
sortable: true,
thAttr: TH_PUBLISHED_TEST_ID,
},
],
]
: this.$options.fields;
}, },
isEmpty() { isEmpty() {
return !this.incidents.list?.length; return !this.incidents.list?.length;
...@@ -526,6 +536,10 @@ export default { ...@@ -526,6 +536,10 @@ export default {
<time-ago-tooltip :time="item.createdAt" /> <time-ago-tooltip :time="item.createdAt" />
</template> </template>
<template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }">
<service-level-agreement-cell :sla-due-at="item.slaDueAt" data-testid="incident-sla" />
</template>
<template #cell(assignees)="{ item }"> <template #cell(assignees)="{ item }">
<div data-testid="incident-assignees"> <div data-testid="incident-assignees">
<template v-if="hasAssignees(item.assignees)"> <template v-if="hasAssignees(item.assignees)">
......
...@@ -46,5 +46,6 @@ export const trackIncidentCreateNewOptions = { ...@@ -46,5 +46,6 @@ export const trackIncidentCreateNewOptions = {
export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' };
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' }; export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident'; export const INCIDENT_DETAILS_PATH = 'incident';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import IncidentsList from './components/incidents_list.vue'; import IncidentsList from './components/incidents_list.vue';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -19,6 +20,7 @@ export default () => { ...@@ -19,6 +20,7 @@ export default () => {
textQuery, textQuery,
authorUsernamesQuery, authorUsernamesQuery,
assigneeUsernamesQuery, assigneeUsernamesQuery,
slaFeatureAvailable,
} = domEl.dataset; } = domEl.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
...@@ -33,11 +35,12 @@ export default () => { ...@@ -33,11 +35,12 @@ export default () => {
incidentType, incidentType,
newIssuePath, newIssuePath,
issuePath, issuePath,
publishedAvailable, publishedAvailable: parseBoolean(publishedAvailable),
emptyListSvgPath, emptyListSvgPath,
textQuery, textQuery,
authorUsernamesQuery, authorUsernamesQuery,
assigneeUsernamesQuery, assigneeUsernamesQuery,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
}, },
apolloProvider, apolloProvider,
components: { components: {
......
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { formatTime, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
export default {
i18n: {
longText: s__('IncidentManagement|%{hours} hours, %{minutes} minutes remaining'),
shortText: s__('IncidentManagement|%{minutes} minutes remaining'),
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
slaDueAt: {
type: String,
required: false,
default: null,
},
},
computed: {
shouldShow() {
// Checks for a valid date string
return this.slaDueAt && !Number.isNaN(Date.parse(this.slaDueAt));
},
remainingTime() {
return calculateRemainingMilliseconds(this.slaDueAt);
},
slaText() {
const remainingDuration = formatTime(this.remainingTime);
// remove the seconds portion of the string
return remainingDuration.substring(0, remainingDuration.length - 3);
},
slaTitle() {
const minutes = Math.floor(this.remainingTime / 1000 / 60) % 60;
const hours = Math.floor(this.remainingTime / 1000 / 60 / 60);
if (hours > 0) {
return sprintf(this.$options.i18n.longText, { hours, minutes });
}
return sprintf(this.$options.i18n.shortText, { hours, minutes });
},
},
};
</script>
<template>
<span v-if="shouldShow" v-gl-tooltip :title="slaTitle">
{{ slaText }}
</span>
</template>
fragment IncidentFields on Issue { fragment IncidentFields on Issue {
severity severity
statusPagePublishedIncident statusPagePublishedIncident
slaDueAt
} }
...@@ -8,17 +8,16 @@ module EE ...@@ -8,17 +8,16 @@ module EE
override :incidents_data override :incidents_data
def incidents_data(project, params) def incidents_data(project, params)
super.merge( super.merge(
incidents_data_published_available(project) incidents_data_ee(project)
) )
end end
private private
def incidents_data_published_available(project) def incidents_data_ee(project)
return {} unless project.feature_available?(:status_page)
{ {
'published-available' => 'true' 'published-available' => project.feature_available?(:status_page).to_s,
'sla-feature-available' => ::IncidentManagement::IncidentSla.available_for?(project).to_s
} }
end end
end end
......
import { shallowMount } from '@vue/test-utils';
import ServiceLevelAgreementCell from 'ee/incidents/components/service_level_agreement_cell.vue';
import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility';
jest.mock('~/lib/utils/datetime_utility', () => ({
calculateRemainingMilliseconds: jest.fn(() => 1000),
formatTime: jest.fn(() => '00:00:00'),
}));
const mockDateString = '2020-10-15T02:42:27Z';
describe('Incidents Published Cell', () => {
let wrapper;
function mountComponent(props) {
wrapper = shallowMount(ServiceLevelAgreementCell, {
propsData: {
...props,
},
});
}
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('Service Level Agreement Cell', () => {
it('renders an empty cell by default', () => {
mountComponent();
expect(wrapper.html()).toBe('');
});
it('renders a empty cell for an invalid date', () => {
mountComponent({ slaDueAt: 'dfsgsdfg' });
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', () => {
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);
},
);
});
});
});
...@@ -5,7 +5,7 @@ require 'spec_helper' ...@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Projects::IncidentsHelper do RSpec.describe Projects::IncidentsHelper do
include Gitlab::Routing.url_helpers include Gitlab::Routing.url_helpers
let_it_be(:project) { create(:project) } let_it_be_with_refind(:project) { create(:project) }
let(:project_path) { project.full_path } let(:project_path) { project.full_path }
let(:new_issue_path) { new_project_issue_path(project) } let(:new_issue_path) { new_project_issue_path(project) }
let(:issue_path) { project_issues_path(project) } let(:issue_path) { project_issues_path(project) }
...@@ -26,6 +26,8 @@ RSpec.describe Projects::IncidentsHelper do ...@@ -26,6 +26,8 @@ RSpec.describe Projects::IncidentsHelper do
'incident-type' => 'incident', 'incident-type' => 'incident',
'issue-path' => issue_path, 'issue-path' => issue_path,
'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'), 'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'),
'published-available' => 'false',
'sla-feature-available' => 'false',
'text-query': 'search text', 'text-query': 'search text',
'author-usernames-query': 'root', 'author-usernames-query': 'root',
'assignee-usernames-query': 'max.power' 'assignee-usernames-query': 'max.power'
...@@ -34,20 +36,48 @@ RSpec.describe Projects::IncidentsHelper do ...@@ -34,20 +36,48 @@ RSpec.describe Projects::IncidentsHelper do
subject { helper.incidents_data(project, params) } subject { helper.incidents_data(project, params) }
before do it 'returns the correct set of data' do
allow(project).to receive(:feature_available?).with(:status_page).and_return(status_page_feature_available) expect(subject).to match(expected_incidents_data)
end end
context 'when status page feature is available' do context 'when status page feature is available' do
let(:status_page_feature_available) { true } before do
stub_licensed_features(status_page: true)
end
it 'returns the feature as enabled' do
expect(subject['published-available']).to eq('true')
end
end
it { is_expected.to match(expected_incidents_data.merge('published-available' => 'true')) } context 'when status page feature is not available' do
before do
stub_licensed_features(status_page: false)
end end
context 'when status page issue is not available' do it 'returns the feature as disabled' do
let(:status_page_feature_available) { false } expect(subject['published-available']).to eq('false')
end
end
it { is_expected.to match(expected_incidents_data) } context 'when incident sla feature is available' do
before do
stub_licensed_features(incident_sla: true)
end
it 'returns the feature as enabled' do
expect(subject['sla-feature-available']).to eq('true')
end
end
context 'when incident sla feature is not available' do
before do
stub_licensed_features(incident_sla: false)
end
it 'returns the feature as disabled' do
expect(subject['sla-feature-available']).to eq('false')
end
end end
end end
end end
...@@ -13734,6 +13734,12 @@ msgstr "" ...@@ -13734,6 +13734,12 @@ msgstr ""
msgid "Incident Management Limits" msgid "Incident Management Limits"
msgstr "" msgstr ""
msgid "IncidentManagement|%{hours} hours, %{minutes} minutes remaining"
msgstr ""
msgid "IncidentManagement|%{minutes} minutes remaining"
msgstr ""
msgid "IncidentManagement|All" msgid "IncidentManagement|All"
msgstr "" msgstr ""
...@@ -13794,6 +13800,9 @@ msgstr "" ...@@ -13794,6 +13800,9 @@ msgstr ""
msgid "IncidentManagement|There was an error displaying the incidents." msgid "IncidentManagement|There was an error displaying the incidents."
msgstr "" msgstr ""
msgid "IncidentManagement|Time to SLA"
msgstr ""
msgid "IncidentManagement|Unassigned" msgid "IncidentManagement|Unassigned"
msgstr "" msgstr ""
......
...@@ -55,6 +55,7 @@ describe('Incidents List', () => { ...@@ -55,6 +55,7 @@ describe('Incidents List', () => {
const findLoader = () => wrapper.find(GlLoadingIcon); const findLoader = () => wrapper.find(GlLoadingIcon);
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip); const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
const findSearch = () => wrapper.find(FilteredSearchBar); const findSearch = () => wrapper.find(FilteredSearchBar);
const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]');
const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]'); const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]');
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']");
...@@ -64,11 +65,16 @@ describe('Incidents List', () => { ...@@ -64,11 +65,16 @@ describe('Incidents List', () => {
const findStatusTabs = () => wrapper.find(GlTabs); const findStatusTabs = () => wrapper.find(GlTabs);
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 = { incidents: [], incidentsCount: {} }, loading = false }) { function mountComponent({ data = {}, loading = false, provide = {} } = {}) {
wrapper = mount(IncidentsList, { wrapper = mount(IncidentsList, {
data() { data() {
return data; return {
incidents: [],
incidentsCount: {},
...data,
};
}, },
mocks: { mocks: {
$apollo: { $apollo: {
...@@ -90,11 +96,14 @@ describe('Incidents List', () => { ...@@ -90,11 +96,14 @@ describe('Incidents List', () => {
textQuery: '', textQuery: '',
authorUsernamesQuery: '', authorUsernamesQuery: '',
assigneeUsernamesQuery: '', assigneeUsernamesQuery: '',
slaFeatureAvailable: true,
...provide,
}, },
stubs: { stubs: {
GlButton: true, GlButton: true,
GlAvatar: true, GlAvatar: true,
GlEmptyState: true, GlEmptyState: true,
ServiceLevelAgreementCell: true,
}, },
}); });
} }
...@@ -204,6 +213,35 @@ describe('Incidents List', () => { ...@@ -204,6 +213,35 @@ 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', () => {
......
...@@ -5,7 +5,8 @@ ...@@ -5,7 +5,8 @@
"createdAt": "2020-06-03T15:46:08Z", "createdAt": "2020-06-03T15:46:08Z",
"assignees": {}, "assignees": {},
"state": "opened", "state": "opened",
"severity": "CRITICAL" "severity": "CRITICAL",
"slaDueAt": "2020-06-04T12:46:08Z"
}, },
{ {
"iid": "14", "iid": "14",
...@@ -22,7 +23,8 @@ ...@@ -22,7 +23,8 @@
] ]
}, },
"state": "opened", "state": "opened",
"severity": "HIGH" "severity": "HIGH",
"slaDueAt": null
}, },
{ {
"iid": "13", "iid": "13",
......
...@@ -21,7 +21,7 @@ RSpec.describe Projects::IncidentsHelper do ...@@ -21,7 +21,7 @@ RSpec.describe Projects::IncidentsHelper do
subject(:data) { helper.incidents_data(project, params) } subject(:data) { helper.incidents_data(project, params) }
it 'returns frontend configuration' do it 'returns frontend configuration' do
expect(data).to match( expect(data).to include(
'project-path' => project_path, 'project-path' => project_path,
'new-issue-path' => new_issue_path, 'new-issue-path' => new_issue_path,
'incident-template-name' => 'incident', 'incident-template-name' => '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