506 lines
16 KiB
Vue
506 lines
16 KiB
Vue
<script>
|
|
import {
|
|
GlLink,
|
|
GlLoadingIcon,
|
|
GlTable,
|
|
GlAvatarsInline,
|
|
GlAvatarLink,
|
|
GlAvatar,
|
|
GlTooltipDirective,
|
|
GlButton,
|
|
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__, n__ } from '~/locale';
|
|
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
|
|
import SeverityToken from '~/sidebar/components/severity/severity.vue';
|
|
import Tracking from '~/tracking';
|
|
import {
|
|
tdClass,
|
|
thClass,
|
|
bodyTrClass,
|
|
initialPaginationState,
|
|
} 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,
|
|
TH_ESCALATION_STATUS_TEST_ID,
|
|
TH_PUBLISHED_TEST_ID,
|
|
INCIDENT_DETAILS_PATH,
|
|
trackIncidentCreateNewOptions,
|
|
trackIncidentListViewsOptions,
|
|
} from '../constants';
|
|
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
|
|
import getIncidents from '../graphql/queries/get_incidents.query.graphql';
|
|
|
|
const MAX_VISIBLE_ASSIGNEES = 3;
|
|
|
|
export default {
|
|
trackIncidentCreateNewOptions,
|
|
trackIncidentListViewsOptions,
|
|
i18n: I18N,
|
|
statusTabs: INCIDENT_STATUS_TABS,
|
|
fields: [
|
|
{
|
|
key: 'severity',
|
|
label: s__('IncidentManagement|Severity'),
|
|
thClass: `${thClass} gl-w-15p`,
|
|
tdClass: `${tdClass} sortable-cell`,
|
|
actualSortKey: 'SEVERITY',
|
|
sortable: true,
|
|
thAttr: TH_SEVERITY_TEST_ID,
|
|
},
|
|
{
|
|
key: 'title',
|
|
label: s__('IncidentManagement|Incident'),
|
|
thClass: `gl-pointer-events-none`,
|
|
tdClass,
|
|
},
|
|
{
|
|
key: 'escalationStatus',
|
|
label: s__('IncidentManagement|Status'),
|
|
thClass: `${thClass} gl-w-eighth`,
|
|
tdClass: `${tdClass} sortable-cell`,
|
|
actualSortKey: 'ESCALATION_STATUS',
|
|
sortable: true,
|
|
thAttr: TH_ESCALATION_STATUS_TEST_ID,
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
label: s__('IncidentManagement|Date created'),
|
|
thClass: `${thClass} gl-w-eighth`,
|
|
tdClass: `${tdClass} sortable-cell`,
|
|
actualSortKey: 'CREATED',
|
|
sortable: true,
|
|
thAttr: TH_CREATED_AT_TEST_ID,
|
|
},
|
|
{
|
|
key: 'incidentSla',
|
|
label: s__('IncidentManagement|Time to SLA'),
|
|
thClass: `gl-text-right gl-w-10p`,
|
|
tdClass: `${tdClass} gl-text-right`,
|
|
thAttr: TH_INCIDENT_SLA_TEST_ID,
|
|
actualSortKey: 'SLA_DUE_AT',
|
|
sortable: true,
|
|
sortDirection: 'asc',
|
|
},
|
|
{
|
|
key: 'assignees',
|
|
label: s__('IncidentManagement|Assignees'),
|
|
thClass: 'gl-pointer-events-none gl-w-15',
|
|
tdClass,
|
|
},
|
|
{
|
|
key: 'published',
|
|
label: s__('IncidentManagement|Published'),
|
|
thClass: `${thClass} gl-w-15`,
|
|
tdClass: `${tdClass} sortable-cell`,
|
|
actualSortKey: 'PUBLISHED',
|
|
sortable: true,
|
|
thAttr: TH_PUBLISHED_TEST_ID,
|
|
},
|
|
],
|
|
MAX_VISIBLE_ASSIGNEES,
|
|
components: {
|
|
GlLink,
|
|
GlLoadingIcon,
|
|
GlTable,
|
|
GlAvatarsInline,
|
|
GlAvatarLink,
|
|
GlAvatar,
|
|
GlButton,
|
|
TimeAgoTooltip,
|
|
GlIcon,
|
|
PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'),
|
|
ServiceLevelAgreementCell: () =>
|
|
import('ee_component/vue_shared/components/incidents/service_level_agreement.vue'),
|
|
GlEmptyState,
|
|
SeverityToken,
|
|
PaginatedTableWithSearchAndTabs,
|
|
TooltipOnTruncate,
|
|
},
|
|
directives: {
|
|
GlTooltip: GlTooltipDirective,
|
|
},
|
|
inject: [
|
|
'projectPath',
|
|
'newIssuePath',
|
|
'incidentTemplateName',
|
|
'incidentType',
|
|
'issuePath',
|
|
'publishedAvailable',
|
|
'emptyListSvgPath',
|
|
'textQuery',
|
|
'authorUsernameQuery',
|
|
'assigneeUsernameQuery',
|
|
'slaFeatureAvailable',
|
|
'canCreateIncident',
|
|
'incidentEscalationsAvailable',
|
|
],
|
|
apollo: {
|
|
incidents: {
|
|
query: getIncidents,
|
|
variables() {
|
|
return {
|
|
searchTerm: this.searchTerm,
|
|
authorUsername: this.authorUsername,
|
|
assigneeUsername: this.assigneeUsername,
|
|
projectPath: this.projectPath,
|
|
status: this.statusFilter,
|
|
issueTypes: ['INCIDENT'],
|
|
sort: this.sort,
|
|
firstPageSize: this.pagination.firstPageSize,
|
|
lastPageSize: this.pagination.lastPageSize,
|
|
prevPageCursor: this.pagination.prevPageCursor,
|
|
nextPageCursor: this.pagination.nextPageCursor,
|
|
};
|
|
},
|
|
update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) {
|
|
return {
|
|
list: nodes,
|
|
pageInfo,
|
|
};
|
|
},
|
|
error() {
|
|
this.errored = true;
|
|
},
|
|
},
|
|
incidentsCount: {
|
|
query: getIncidentsCountByStatus,
|
|
variables() {
|
|
return {
|
|
searchTerm: this.searchTerm,
|
|
authorUsername: this.authorUsername,
|
|
assigneeUsername: this.assigneeUsername,
|
|
projectPath: this.projectPath,
|
|
issueTypes: ['INCIDENT'],
|
|
};
|
|
},
|
|
update(data) {
|
|
return data.project?.issueStatusCounts;
|
|
},
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
errored: false,
|
|
isErrorAlertDismissed: false,
|
|
redirecting: false,
|
|
incidents: {},
|
|
incidentsCount: {},
|
|
sort: 'CREATED_DESC',
|
|
sortDesc: true,
|
|
statusFilter: '',
|
|
filteredByStatus: '',
|
|
searchTerm: this.textQuery,
|
|
authorUsername: this.authorUsernameQuery,
|
|
assigneeUsername: this.assigneeUsernameQuery,
|
|
pagination: initialPaginationState,
|
|
};
|
|
},
|
|
computed: {
|
|
showErrorMsg() {
|
|
return this.errored && !this.isErrorAlertDismissed;
|
|
},
|
|
loading() {
|
|
return this.$apollo.queries.incidents.loading;
|
|
},
|
|
isEmpty() {
|
|
return !this.incidents?.list?.length;
|
|
},
|
|
showList() {
|
|
return !this.isEmpty || this.errored || this.loading;
|
|
},
|
|
tbodyTrClass() {
|
|
return {
|
|
[bodyTrClass]: !this.loading && !this.isEmpty,
|
|
};
|
|
},
|
|
newIncidentPath() {
|
|
return mergeUrlParams(
|
|
{
|
|
issuable_template: this.incidentTemplateName,
|
|
'issue[issue_type]': this.incidentType,
|
|
},
|
|
this.newIssuePath,
|
|
);
|
|
},
|
|
availableFields() {
|
|
const isHidden = {
|
|
published: !this.publishedAvailable,
|
|
incidentSla: !this.slaFeatureAvailable,
|
|
escalationStatus: !this.incidentEscalationsAvailable,
|
|
};
|
|
|
|
return this.$options.fields.filter(({ key }) => !isHidden[key]);
|
|
},
|
|
activeClosedTabHasNoIncidents() {
|
|
const { all, closed } = this.incidentsCount || {};
|
|
const isClosedTabActive = this.statusFilter === this.$options.statusTabs[1].filters;
|
|
|
|
return isClosedTabActive && all && !closed;
|
|
},
|
|
emptyStateData() {
|
|
const {
|
|
emptyState: { title, emptyClosedTabTitle, description, cannotCreateIncidentDescription },
|
|
createIncidentBtnLabel,
|
|
} = this.$options.i18n;
|
|
|
|
if (this.activeClosedTabHasNoIncidents) {
|
|
return { title: emptyClosedTabTitle };
|
|
}
|
|
if (!this.canCreateIncident) {
|
|
return { title, description: cannotCreateIncidentDescription };
|
|
}
|
|
return {
|
|
title,
|
|
description,
|
|
btnLink: this.newIncidentPath,
|
|
btnText: createIncidentBtnLabel,
|
|
};
|
|
},
|
|
isHeaderButtonVisible() {
|
|
return this.canCreateIncident && (!this.isEmpty || this.activeClosedTabHasNoIncidents);
|
|
},
|
|
},
|
|
methods: {
|
|
hasAssignees(assignees) {
|
|
return Boolean(assignees.nodes?.length);
|
|
},
|
|
navigateToIncidentDetails({ iid }) {
|
|
return visitUrl(this.showIncidentLink({ iid }));
|
|
},
|
|
navigateToCreateNewIncident() {
|
|
const { category, action } = this.$options.trackIncidentCreateNewOptions;
|
|
Tracking.event(category, action);
|
|
this.redirecting = true;
|
|
},
|
|
fetchSortedData({ sortBy, sortDesc }) {
|
|
const field = this.availableFields.find(({ key }) => key === sortBy);
|
|
const sortingDirection = sortDesc ? 'DESC' : 'ASC';
|
|
|
|
this.pagination = initialPaginationState;
|
|
|
|
// BootstapVue natively supports a `sortKey` parameter, but using it results in the sorting
|
|
// icons not being updated properly in the header. We decided to fallback on `actualSortKey`
|
|
// to bypass BootstrapVue's behavior until the bug is addressed upstream.
|
|
// Related discussion: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60926/diffs#note_568020482
|
|
// Upstream issue: https://github.com/bootstrap-vue/bootstrap-vue/issues/6602
|
|
this.sort = `${field.actualSortKey}_${sortingDirection}`;
|
|
},
|
|
getSeverity(severity) {
|
|
return INCIDENT_SEVERITY[severity];
|
|
},
|
|
getEscalationStatus(escalationStatus) {
|
|
return ESCALATION_STATUSES[escalationStatus] || this.$options.i18n.noEscalationStatus;
|
|
},
|
|
showIncidentLink({ iid }) {
|
|
return joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid);
|
|
},
|
|
pageChanged(pagination) {
|
|
this.pagination = pagination;
|
|
},
|
|
statusChanged({ filters, status }) {
|
|
this.statusFilter = filters;
|
|
this.filteredByStatus = status;
|
|
},
|
|
filtersChanged({ searchTerm, authorUsername, assigneeUsername }) {
|
|
this.searchTerm = searchTerm;
|
|
this.authorUsername = authorUsername;
|
|
this.assigneeUsername = assigneeUsername;
|
|
},
|
|
errorAlertDismissed() {
|
|
this.isErrorAlertDismissed = true;
|
|
},
|
|
assigneesBadgeSrOnlyText(item) {
|
|
return n__(
|
|
'%d additional assignee',
|
|
'%d additional assignees',
|
|
item.assignees.nodes.length - MAX_VISIBLE_ASSIGNEES,
|
|
);
|
|
},
|
|
isValidSlaDueAt,
|
|
},
|
|
};
|
|
</script>
|
|
<template>
|
|
<div>
|
|
<paginated-table-with-search-and-tabs
|
|
:show-items="showList"
|
|
:show-error-msg="showErrorMsg"
|
|
:i18n="$options.i18n"
|
|
:items="incidents.list || []"
|
|
:page-info="incidents.pageInfo"
|
|
:items-count="incidentsCount"
|
|
:status-tabs="$options.statusTabs"
|
|
:track-views-options="$options.trackIncidentListViewsOptions"
|
|
filter-search-key="incidents"
|
|
@page-changed="pageChanged"
|
|
@tabs-changed="statusChanged"
|
|
@filters-changed="filtersChanged"
|
|
@error-alert-dismissed="errorAlertDismissed"
|
|
>
|
|
<template #header-actions>
|
|
<gl-button
|
|
v-if="isHeaderButtonVisible"
|
|
class="gl-my-3 gl-mr-5 create-incident-button"
|
|
data-testid="createIncidentBtn"
|
|
data-qa-selector="create_incident_button"
|
|
:loading="redirecting"
|
|
:disabled="redirecting"
|
|
category="primary"
|
|
variant="confirm"
|
|
:href="newIncidentPath"
|
|
@click="navigateToCreateNewIncident"
|
|
>
|
|
{{ $options.i18n.createIncidentBtnLabel }}
|
|
</gl-button>
|
|
</template>
|
|
|
|
<template #title>
|
|
{{ s__('IncidentManagement|Incidents') }}
|
|
</template>
|
|
|
|
<template #table>
|
|
<gl-table
|
|
:items="incidents.list || []"
|
|
:fields="availableFields"
|
|
:busy="loading"
|
|
stacked="md"
|
|
:tbody-tr-class="tbodyTrClass"
|
|
sort-direction="desc"
|
|
:sort-desc.sync="sortDesc"
|
|
sort-by="createdAt"
|
|
show-empty
|
|
no-local-sorting
|
|
sort-icon-left
|
|
fixed
|
|
@row-clicked="navigateToIncidentDetails"
|
|
@sort-changed="fetchSortedData"
|
|
>
|
|
<template #cell(severity)="{ item }">
|
|
<severity-token :severity="getSeverity(item.severity)" />
|
|
</template>
|
|
|
|
<template #cell(title)="{ item }">
|
|
<div
|
|
:class="{
|
|
'gl-display-flex gl-align-items-center gl-max-w-full': item.state === 'closed',
|
|
}"
|
|
>
|
|
<gl-link
|
|
data-testid="incident-link"
|
|
:href="showIncidentLink(item)"
|
|
class="gl-min-w-0"
|
|
>
|
|
<tooltip-on-truncate :title="item.title" class="gl-text-truncate gl-display-block">
|
|
{{ item.title }}
|
|
</tooltip-on-truncate>
|
|
</gl-link>
|
|
<gl-icon
|
|
v-if="item.state === 'closed'"
|
|
name="issue-close"
|
|
class="gl-ml-2 gl-fill-blue-500 gl-flex-shrink-0"
|
|
:size="16"
|
|
data-testid="incident-closed"
|
|
/>
|
|
</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"
|
|
class="gl-display-block gl-max-w-full gl-text-truncate"
|
|
/>
|
|
</template>
|
|
|
|
<template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }">
|
|
<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"
|
|
class="gl-display-block gl-max-w-full gl-text-truncate"
|
|
/>
|
|
</template>
|
|
|
|
<template #cell(assignees)="{ item }">
|
|
<div data-testid="incident-assignees">
|
|
<template v-if="hasAssignees(item.assignees)">
|
|
<gl-avatars-inline
|
|
:avatars="item.assignees.nodes"
|
|
:collapsed="true"
|
|
:max-visible="$options.MAX_VISIBLE_ASSIGNEES"
|
|
:avatar-size="24"
|
|
badge-tooltip-prop="name"
|
|
:badge-tooltip-max-chars="100"
|
|
:badge-sr-only-text="assigneesBadgeSrOnlyText(item)"
|
|
>
|
|
<template #avatar="{ avatar }">
|
|
<gl-avatar-link
|
|
:key="avatar.username"
|
|
v-gl-tooltip
|
|
target="_blank"
|
|
:href="avatar.webUrl"
|
|
:title="avatar.name"
|
|
>
|
|
<gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" />
|
|
</gl-avatar-link>
|
|
</template>
|
|
</gl-avatars-inline>
|
|
</template>
|
|
<template v-else>
|
|
{{ $options.i18n.unassigned }}
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-if="publishedAvailable" #cell(published)="{ item }">
|
|
<published-cell
|
|
:status-page-published-incident="item.statusPagePublishedIncident"
|
|
:un-published="$options.i18n.unPublished"
|
|
/>
|
|
</template>
|
|
|
|
<template #table-busy>
|
|
<gl-loading-icon size="lg" color="dark" class="mt-3" />
|
|
</template>
|
|
|
|
<template v-if="errored" #empty>
|
|
{{ $options.i18n.noIncidents }}
|
|
</template>
|
|
</gl-table>
|
|
</template>
|
|
<template #empty-state>
|
|
<gl-empty-state
|
|
:title="emptyStateData.title"
|
|
:svg-path="emptyListSvgPath"
|
|
:description="emptyStateData.description"
|
|
:primary-button-link="emptyStateData.btnLink"
|
|
:primary-button-text="emptyStateData.btnText"
|
|
/>
|
|
</template>
|
|
</paginated-table-with-search-and-tabs>
|
|
</div>
|
|
</template>
|