Commit 6ff310b5 authored by Tristan Read's avatar Tristan Read Committed by Olena Horal-Koretska

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

parent 6c20d183
......@@ -39,6 +39,7 @@ import {
DEFAULT_PAGE_SIZE,
INCIDENT_STATUS_TABS,
TH_CREATED_AT_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
TH_SEVERITY_TEST_ID,
TH_PUBLISHED_TEST_ID,
INCIDENT_DETAILS_PATH,
......@@ -67,7 +68,7 @@ export default {
{
key: 'severity',
label: s__('IncidentManagement|Severity'),
thClass,
thClass: `${thClass} w-15p`,
tdClass: `${tdClass} sortable-cell`,
sortable: true,
thAttr: TH_SEVERITY_TEST_ID,
......@@ -75,23 +76,38 @@ export default {
{
key: 'title',
label: s__('IncidentManagement|Incident'),
thClass: `gl-pointer-events-none gl-w-half`,
thClass: `gl-pointer-events-none`,
tdClass,
},
{
key: 'createdAt',
label: s__('IncidentManagement|Date created'),
thClass,
thClass: `${thClass} gl-w-eighth`,
tdClass: `${tdClass} sortable-cell`,
sortable: true,
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',
label: s__('IncidentManagement|Assignees'),
thClass: 'gl-pointer-events-none',
thClass: 'gl-pointer-events-none w-15p',
tdClass,
},
{
key: 'published',
label: s__('IncidentManagement|Published'),
thClass: `${thClass} w-15p`,
tdClass: `${tdClass} sortable-cell`,
sortable: true,
thAttr: TH_PUBLISHED_TEST_ID,
},
],
components: {
GlLoadingIcon,
......@@ -107,6 +123,8 @@ export default {
GlTabs,
GlTab,
PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'),
ServiceLevelAgreementCell: () =>
import('ee_component/incidents/components/service_level_agreement_cell.vue'),
GlBadge,
GlEmptyState,
SeverityToken,
......@@ -126,6 +144,7 @@ export default {
'textQuery',
'authorUsernamesQuery',
'assigneeUsernamesQuery',
'slaFeatureAvailable',
],
apollo: {
incidents: {
......@@ -231,21 +250,12 @@ export default {
);
},
availableFields() {
return this.publishedAvailable
? [
...this.$options.fields,
...[
{
key: 'published',
label: s__('IncidentManagement|Published'),
thClass,
tdClass: `${tdClass} sortable-cell`,
sortable: true,
thAttr: TH_PUBLISHED_TEST_ID,
},
],
]
: this.$options.fields;
const isHidden = {
published: !this.publishedAvailable,
incidentSla: !this.slaFeatureAvailable,
};
return this.$options.fields.filter(({ key }) => !isHidden[key]);
},
isEmpty() {
return !this.incidents.list?.length;
......@@ -526,6 +536,10 @@ export default {
<time-ago-tooltip :time="item.createdAt" />
</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 }">
<div data-testid="incident-assignees">
<template v-if="hasAssignees(item.assignees)">
......
......@@ -46,5 +46,6 @@ export const trackIncidentCreateNewOptions = {
export const DEFAULT_PAGE_SIZE = 20;
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_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' };
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import IncidentsList from './components/incidents_list.vue';
Vue.use(VueApollo);
......@@ -19,6 +20,7 @@ export default () => {
textQuery,
authorUsernamesQuery,
assigneeUsernamesQuery,
slaFeatureAvailable,
} = domEl.dataset;
const apolloProvider = new VueApollo({
......@@ -33,11 +35,12 @@ export default () => {
incidentType,
newIssuePath,
issuePath,
publishedAvailable,
publishedAvailable: parseBoolean(publishedAvailable),
emptyListSvgPath,
textQuery,
authorUsernamesQuery,
assigneeUsernamesQuery,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
},
apolloProvider,
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 {
severity
statusPagePublishedIncident
slaDueAt
}
......@@ -8,17 +8,16 @@ module EE
override :incidents_data
def incidents_data(project, params)
super.merge(
incidents_data_published_available(project)
incidents_data_ee(project)
)
end
private
def incidents_data_published_available(project)
return {} unless project.feature_available?(:status_page)
def incidents_data_ee(project)
{
'published-available' => 'true'
'published-available' => project.feature_available?(:status_page).to_s,
'sla-feature-available' => ::IncidentManagement::IncidentSla.available_for?(project).to_s
}
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'
RSpec.describe Projects::IncidentsHelper do
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(:new_issue_path) { new_project_issue_path(project) }
let(:issue_path) { project_issues_path(project) }
......@@ -26,6 +26,8 @@ RSpec.describe Projects::IncidentsHelper do
'incident-type' => 'incident',
'issue-path' => issue_path,
'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'),
'published-available' => 'false',
'sla-feature-available' => 'false',
'text-query': 'search text',
'author-usernames-query': 'root',
'assignee-usernames-query': 'max.power'
......@@ -34,20 +36,48 @@ RSpec.describe Projects::IncidentsHelper do
subject { helper.incidents_data(project, params) }
before do
allow(project).to receive(:feature_available?).with(:status_page).and_return(status_page_feature_available)
it 'returns the correct set of data' do
expect(subject).to match(expected_incidents_data)
end
context 'when status page feature is available' do
let(:status_page_feature_available) { true }
before do
stub_licensed_features(status_page: true)
end
it { is_expected.to match(expected_incidents_data.merge('published-available' => 'true')) }
it 'returns the feature as enabled' do
expect(subject['published-available']).to eq('true')
end
end
context 'when status page issue is not available' do
let(:status_page_feature_available) { false }
context 'when status page feature is not available' do
before do
stub_licensed_features(status_page: false)
end
it { is_expected.to match(expected_incidents_data) }
it 'returns the feature as disabled' do
expect(subject['published-available']).to eq('false')
end
end
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
......@@ -13734,6 +13734,12 @@ msgstr ""
msgid "Incident Management Limits"
msgstr ""
msgid "IncidentManagement|%{hours} hours, %{minutes} minutes remaining"
msgstr ""
msgid "IncidentManagement|%{minutes} minutes remaining"
msgstr ""
msgid "IncidentManagement|All"
msgstr ""
......@@ -13794,6 +13800,9 @@ msgstr ""
msgid "IncidentManagement|There was an error displaying the incidents."
msgstr ""
msgid "IncidentManagement|Time to SLA"
msgstr ""
msgid "IncidentManagement|Unassigned"
msgstr ""
......
......@@ -55,6 +55,7 @@ describe('Incidents List', () => {
const findLoader = () => wrapper.find(GlLoadingIcon);
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
const findSearch = () => wrapper.find(FilteredSearchBar);
const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]');
const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]');
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
......@@ -64,11 +65,16 @@ describe('Incidents List', () => {
const findStatusTabs = () => wrapper.find(GlTabs);
const findEmptyState = () => wrapper.find(GlEmptyState);
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, {
data() {
return data;
return {
incidents: [],
incidentsCount: {},
...data,
};
},
mocks: {
$apollo: {
......@@ -90,11 +96,14 @@ describe('Incidents List', () => {
textQuery: '',
authorUsernamesQuery: '',
assigneeUsernamesQuery: '',
slaFeatureAvailable: true,
...provide,
},
stubs: {
GlButton: true,
GlAvatar: true,
GlEmptyState: true,
ServiceLevelAgreementCell: true,
},
});
}
......@@ -204,6 +213,35 @@ 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', () => {
......
......@@ -5,7 +5,8 @@
"createdAt": "2020-06-03T15:46:08Z",
"assignees": {},
"state": "opened",
"severity": "CRITICAL"
"severity": "CRITICAL",
"slaDueAt": "2020-06-04T12:46:08Z"
},
{
"iid": "14",
......@@ -22,7 +23,8 @@
]
},
"state": "opened",
"severity": "HIGH"
"severity": "HIGH",
"slaDueAt": null
},
{
"iid": "13",
......
......@@ -21,7 +21,7 @@ RSpec.describe Projects::IncidentsHelper do
subject(:data) { helper.incidents_data(project, params) }
it 'returns frontend configuration' do
expect(data).to match(
expect(data).to include(
'project-path' => project_path,
'new-issue-path' => new_issue_path,
'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