Commit b1b639c9 authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Paul Slaughter

Adjust incident list column widths and truncation

- Also add escalation status to incident list
  Adds a column to the incident list at Monitor > 
  Incidents showing the escalation status.
- https://gitlab.com/gitlab-org/gitlab/-/merge_requests/80645

Changelog: changed
parent 1a7d4e2c
......@@ -24,9 +24,11 @@ import {
} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import {
I18N,
INCIDENT_STATUS_TABS,
ESCALATION_STATUSES,
TH_CREATED_AT_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
TH_SEVERITY_TEST_ID,
......@@ -38,7 +40,7 @@ import {
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import getIncidents from '../graphql/queries/get_incidents.query.graphql';
const MAX_VISIBLE_ASSIGNEES = 4;
const MAX_VISIBLE_ASSIGNEES = 3;
export default {
trackIncidentCreateNewOptions,
......@@ -49,7 +51,7 @@ export default {
{
key: 'severity',
label: s__('IncidentManagement|Severity'),
thClass: `${thClass} w-15p`,
thClass: `${thClass} gl-w-15p`,
tdClass: `${tdClass} sortable-cell`,
actualSortKey: 'SEVERITY',
sortable: true,
......@@ -61,6 +63,12 @@ export default {
thClass: `gl-pointer-events-none`,
tdClass,
},
{
key: 'escalationStatus',
label: s__('IncidentManagement|Status'),
thClass: `${thClass} gl-w-eighth gl-pointer-events-none`,
tdClass,
},
{
key: 'createdAt',
label: s__('IncidentManagement|Date created'),
......@@ -73,7 +81,7 @@ export default {
{
key: 'incidentSla',
label: s__('IncidentManagement|Time to SLA'),
thClass: `gl-text-right gl-w-eighth`,
thClass: `gl-text-right gl-w-10p`,
tdClass: `${tdClass} gl-text-right`,
thAttr: TH_INCIDENT_SLA_TEST_ID,
actualSortKey: 'SLA_DUE_AT',
......@@ -83,13 +91,13 @@ export default {
{
key: 'assignees',
label: s__('IncidentManagement|Assignees'),
thClass: 'gl-pointer-events-none w-15p',
thClass: 'gl-pointer-events-none gl-w-15',
tdClass,
},
{
key: 'published',
label: s__('IncidentManagement|Published'),
thClass: `${thClass} w-15p`,
thClass: `${thClass} gl-w-15`,
tdClass: `${tdClass} sortable-cell`,
actualSortKey: 'PUBLISHED',
sortable: true,
......@@ -112,6 +120,7 @@ export default {
GlEmptyState,
SeverityToken,
PaginatedTableWithSearchAndTabs,
TooltipOnTruncate,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -129,6 +138,7 @@ export default {
'assigneeUsernameQuery',
'slaFeatureAvailable',
'canCreateIncident',
'incidentEscalationsAvailable',
],
apollo: {
incidents: {
......@@ -222,6 +232,7 @@ export default {
const isHidden = {
published: !this.publishedAvailable,
incidentSla: !this.slaFeatureAvailable,
escalationStatus: !this.incidentEscalationsAvailable,
};
return this.$options.fields.filter(({ key }) => !isHidden[key]);
......@@ -283,6 +294,9 @@ export default {
getSeverity(severity) {
return INCIDENT_SEVERITY[severity];
},
getEscalationStatus(escalationStatus) {
return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus;
},
pageChanged(pagination) {
this.pagination = pagination;
},
......@@ -370,7 +384,12 @@ export default {
<template #cell(title)="{ item }">
<div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }">
<div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div>
<tooltip-on-truncate
:title="item.title"
class="gl-max-w-full gl-text-truncate gl-display-block"
>
{{ item.title }}
</tooltip-on-truncate>
<gl-icon
v-if="item.state === 'closed'"
name="issue-close"
......@@ -381,8 +400,21 @@ export default {
</div>
</template>
<template v-if="incidentEscalationsAvailable" #cell(escalationStatus)="{ item }">
<tooltip-on-truncate
:title="getEscalationStatus(item.escalationStatus)"
data-testid="incident-escalation-status"
class="gl-display-block gl-text-truncate"
>
{{ getEscalationStatus(item.escalationStatus) }}
</tooltip-on-truncate>
</template>
<template #cell(createdAt)="{ item }">
<time-ago-tooltip :time="item.createdAt" />
<time-ago-tooltip
:time="item.createdAt"
class="gl-display-block gl-max-w-full gl-text-truncate"
/>
</template>
<template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }">
......@@ -392,6 +424,7 @@ export default {
:project-path="projectPath"
:sla-due-at="item.slaDueAt"
data-testid="incident-sla"
class="gl-display-block gl-max-w-full gl-text-truncate"
/>
</template>
......@@ -432,6 +465,7 @@ export default {
:un-published="$options.i18n.unPublished"
/>
</template>
<template #table-busy>
<gl-loading-icon size="lg" color="dark" class="mt-3" />
</template>
......
......@@ -7,6 +7,7 @@ export const I18N = {
unassigned: s__('IncidentManagement|Unassigned'),
createIncidentBtnLabel: s__('IncidentManagement|Create incident'),
unPublished: s__('IncidentManagement|Unpublished'),
noEscalationStatus: s__('IncidentManagement|None'),
emptyState: {
title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
......@@ -37,6 +38,12 @@ export const INCIDENT_STATUS_TABS = [
},
];
export const ESCALATION_STATUSES = {
TRIGGERED: s__('AlertManagement|Triggered'),
ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
RESOLVED: s__('AlertManagement|Resolved'),
};
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' };
......
# eslint-disable-next-line @graphql-eslint/require-id-when-available
fragment IncidentFields on Issue {
severity
escalationStatus
}
......@@ -46,6 +46,7 @@ export default () => {
assigneeUsernameQuery,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
canCreateIncident: parseBoolean(canCreateIncident),
incidentEscalationsAvailable: parseBoolean(gon?.features?.incidentEscalations),
},
apolloProvider,
render(createElement) {
......
<script>
import { GlIcon } from '@gitlab/ui';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
export default {
components: {
GlIcon,
TooltipOnTruncate,
},
props: {
severity: {
......@@ -30,13 +32,15 @@ export default {
<template>
<div
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<gl-icon
:size="iconSize"
:name="`severity-${severity.icon}`"
:class="[`icon-${severity.icon}`, { 'gl-mr-3': !iconOnly }]"
/>
<span v-if="!iconOnly">{{ severity.label }}</span>
<tooltip-on-truncate v-if="!iconOnly" :title="severity.label" class="gl-text-truncate">{{
severity.label
}}</tooltip-on-truncate>
</div>
</template>
......@@ -6,6 +6,9 @@ class Projects::IncidentsController < Projects::ApplicationController
before_action :authorize_read_issue!
before_action :load_incident, only: [:show]
before_action do
push_frontend_feature_flag(:incident_escalations, @project)
end
feature_category :incident_management
......
# eslint-disable-next-line @graphql-eslint/require-id-when-available
fragment IncidentFields on Issue {
severity
escalationStatus
statusPagePublishedIncident
slaDueAt
}
......@@ -59,12 +59,6 @@ export default {
hasNoTimeRemaining() {
return this.remainingTime === 0;
},
isMissedSLA() {
return this.hasNoTimeRemaining && !this.isClosed;
},
isAchievedSLA() {
return this.hasNoTimeRemaining && this.isClosed;
},
isClosed() {
return this.issueState === 'closed';
},
......@@ -74,14 +68,18 @@ export default {
shouldShow() {
return isValidSlaDueAt(this.slaDueAt);
},
slaText() {
if (this.isMissedSLA) {
return this.$options.i18n.missedSLAText;
}
if (this.isAchievedSLA) {
hasNoTimeRemainingText() {
if (this.isClosed) {
return this.$options.i18n.achievedSLAText;
}
return this.$options.i18n.missedSLAText;
},
slaText() {
if (this.hasNoTimeRemaining) {
return this.hasNoTimeRemainingText;
}
const remainingDuration = formatTime(this.remainingTime);
// remove the seconds portion of the string
......@@ -89,7 +87,7 @@ export default {
},
slaTitle() {
if (this.hasNoTimeRemaining) {
return '';
return this.hasNoTimeRemainingText;
}
const minutes = Math.floor(this.remainingTime / 1000 / 60) % 60;
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Incident Management index', :js do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:incident) { create(:incident, project: project) }
before_all do
project.add_developer(developer)
end
before do
sign_in(developer)
end
context 'when a developer displays the incident list' do
it 'has expected columns' do
visit project_incidents_path(project)
wait_for_requests
table = page.find('.gl-table')
expect(table).to have_content('Severity')
expect(table).to have_content('Incident')
expect(table).to have_content('Status')
expect(table).to have_content('Date created')
expect(table).to have_content('Assignees')
expect(table).not_to have_content('Time to SLA')
expect(table).not_to have_content('Published')
end
context 'with SLA feature available' do
before do
stub_licensed_features(incident_sla: true)
end
it 'includes the SLA column' do
visit project_incidents_path(project)
wait_for_requests
expect(page.find('.gl-table')).to have_content('Time to SLA')
end
end
context 'with Status Page feature available' do
before do
stub_licensed_features(status_page: true)
end
it 'includes the Published column' do
visit project_incidents_path(project)
wait_for_requests
expect(page.find('.gl-table')).to have_content('Published')
end
end
end
end
......@@ -82,7 +82,7 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
class="sidebar-collapsed-icon"
>
<div
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<svg
aria-hidden="true"
......@@ -229,7 +229,7 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
class="gl-new-dropdown-item-text-primary"
>
<div
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<svg
aria-hidden="true"
......@@ -242,7 +242,9 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
/>
</svg>
<span>
<span
class="gl-min-w-0 gl-text-truncate"
>
Critical - S1
</span>
</div>
......@@ -286,7 +288,7 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
class="gl-new-dropdown-item-text-primary"
>
<div
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<svg
aria-hidden="true"
......@@ -299,7 +301,9 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
/>
</svg>
<span>
<span
class="gl-min-w-0 gl-text-truncate"
>
High - S2
</span>
</div>
......@@ -343,7 +347,7 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
class="gl-new-dropdown-item-text-primary"
>
<div
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<svg
aria-hidden="true"
......@@ -356,7 +360,9 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
/>
</svg>
<span>
<span
class="gl-min-w-0 gl-text-truncate"
>
Medium - S3
</span>
</div>
......@@ -400,7 +406,7 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
class="gl-new-dropdown-item-text-primary"
>
<div
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<svg
aria-hidden="true"
......@@ -413,7 +419,9 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
/>
</svg>
<span>
<span
class="gl-min-w-0 gl-text-truncate"
>
Low - S4
</span>
</div>
......@@ -457,7 +465,7 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
class="gl-new-dropdown-item-text-primary"
>
<div
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<svg
aria-hidden="true"
......@@ -470,7 +478,9 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
/>
</svg>
<span>
<span
class="gl-min-w-0 gl-text-truncate"
>
Unknown
</span>
</div>
......@@ -490,7 +500,7 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
</div>
<div
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between"
class="incident-severity gl-display-inline-flex gl-align-items-center gl-justify-content-between gl-max-w-full"
>
<svg
aria-hidden="true"
......@@ -503,7 +513,9 @@ exports[`ee/BoardContentSidebar incident sidebar matches the snapshot 1`] = `
/>
</svg>
<span>
<span
class="gl-min-w-0 gl-text-truncate"
>
Unknown
</span>
</div>
......
......@@ -16,6 +16,7 @@ const defaultProvide = {
assigneeUsernameQuery: '',
slaFeatureAvailable: true,
canCreateIncident: true,
incidentEscalationsAvailable: true,
};
describe('Incidents Service Level Agreement', () => {
......
......@@ -92,7 +92,7 @@ describe('Service Level Agreement', () => {
${5} | ${7} | ${'5 hours, 7 minutes remaining'}
${5} | ${0} | ${'5 hours, 0 minutes remaining'}
${0} | ${7} | ${'7 minutes remaining'}
${0} | ${0} | ${''}
${0} | ${0} | ${'Missed SLA'}
`(
'returns the correct message for: hours: "$hours", minutes: "$minutes"',
({ hours, minutes, expectedMessage }) => {
......
......@@ -19262,6 +19262,9 @@ msgstr ""
msgid "IncidentManagement|No incidents to display."
msgstr ""
msgid "IncidentManagement|None"
msgstr ""
msgid "IncidentManagement|Open"
msgstr ""
......@@ -19280,6 +19283,9 @@ msgstr ""
msgid "IncidentManagement|Severity"
msgstr ""
msgid "IncidentManagement|Status"
msgstr ""
msgid "IncidentManagement|There are no closed incidents"
msgstr ""
......
......@@ -43,6 +43,7 @@ RSpec.describe Projects::IncidentsController do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
expect(Gon.features).to include('incidentEscalations' => true)
end
context 'when user is unauthorized' do
......
......@@ -34,5 +34,28 @@ RSpec.describe 'Incident Management index', :js do
it 'alert page title' do
expect(page).to have_content('Incidents')
end
it 'has expected columns' do
table = page.find('.gl-table')
expect(table).to have_content('Severity')
expect(table).to have_content('Incident')
expect(table).to have_content('Status')
expect(table).to have_content('Date created')
expect(table).to have_content('Assignees')
end
context 'when :incident_escalations feature is disabled' do
before do
stub_feature_flags(incident_escalations: false)
end
it 'does not include the Status columns' do
visit project_incidents_path(project)
wait_for_requests
expect(page.find('.gl-table')).not_to have_content('Status')
end
end
end
end
......@@ -48,6 +48,7 @@ describe('Incidents List', () => {
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
const findEmptyState = () => wrapper.find(GlEmptyState);
const findSeverity = () => wrapper.findAll(SeverityToken);
const findEscalationStatus = () => wrapper.findAll('[data-testid="incident-escalation-status"]');
function mountComponent({ data = {}, loading = false, provide = {} } = {}) {
wrapper = mount(IncidentsList, {
......@@ -80,6 +81,7 @@ describe('Incidents List', () => {
assigneeUsernameQuery: '',
slaFeatureAvailable: true,
canCreateIncident: true,
incidentEscalationsAvailable: true,
...provide,
},
stubs: {
......@@ -184,6 +186,34 @@ describe('Incidents List', () => {
expect(findSeverity().length).toBe(mockIncidents.length);
});
describe('Escalation status', () => {
it('renders escalation status per row', () => {
expect(findEscalationStatus().length).toBe(mockIncidents.length);
const actualStatuses = findEscalationStatus().wrappers.map((status) => status.text());
expect(actualStatuses).toEqual([
'Triggered',
'Acknowledged',
'Resolved',
I18N.noEscalationStatus,
]);
});
describe('when feature is disabled', () => {
beforeEach(() => {
mountComponent({
data: { incidents: { list: mockIncidents }, incidentsCount },
provide: { incidentEscalationsAvailable: false },
loading: false,
});
});
it('is absent if feature flag is disabled', () => {
expect(findEscalationStatus().length).toBe(0);
});
});
});
it('contains a link to the incident details page', async () => {
findTableRows().at(0).trigger('click');
expect(visitUrl).toHaveBeenCalledWith(
......
......@@ -7,6 +7,7 @@
"assignees": {},
"state": "opened",
"severity": "CRITICAL",
"escalationStatus": "TRIGGERED",
"slaDueAt": "2020-06-04T12:46:08Z"
},
{
......@@ -26,6 +27,7 @@
},
"state": "opened",
"severity": "HIGH",
"escalationStatus": "ACKNOWLEDGED",
"slaDueAt": null
},
{
......@@ -35,7 +37,8 @@
"createdAt": "2020-05-19T08:53:55Z",
"assignees": {},
"state": "closed",
"severity": "LOW"
"severity": "LOW",
"escalationStatus": "RESOLVED"
},
{
"id": 4,
......@@ -44,6 +47,7 @@
"createdAt": "2020-05-18T17:13:35Z",
"assignees": {},
"state": "closed",
"severity": "MEDIUM"
"severity": "MEDIUM",
"escalationStatus": null
}
]
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