Commit 1d1e96d8 authored by David O'Regan's avatar David O'Regan Committed by Olena Horal-Koretska

Incident management test coverage

Flesh out Alert and Incident
management test coverage
via feature specs
parent a7248d86
......@@ -33,30 +33,13 @@ export default {
query: alertsHelpUrlQuery,
},
},
props: {
enableAlertManagementPath: {
type: String,
required: true,
},
userCanEnableAlertManagement: {
type: Boolean,
required: true,
},
emptyAlertSvgPath: {
type: String,
required: true,
},
opsgenieMvcEnabled: {
type: Boolean,
required: false,
default: false,
},
opsgenieMvcTargetUrl: {
type: String,
required: false,
default: '',
},
},
inject: [
'enableAlertManagementPath',
'userCanEnableAlertManagement',
'emptyAlertSvgPath',
'opsgenieMvcEnabled',
'opsgenieMvcTargetUrl',
],
data() {
return {
alertsHelpUrl: '',
......
<script>
import Tracking from '~/tracking';
import { trackAlertListViewsOptions } from '../constants';
import AlertManagementEmptyState from './alert_management_empty_state.vue';
import AlertManagementTable from './alert_management_table.vue';
......@@ -9,67 +7,12 @@ export default {
AlertManagementEmptyState,
AlertManagementTable,
},
props: {
projectPath: {
type: String,
required: true,
},
alertManagementEnabled: {
type: Boolean,
required: true,
},
enableAlertManagementPath: {
type: String,
required: true,
},
populatingAlertsHelpUrl: {
type: String,
required: true,
},
userCanEnableAlertManagement: {
type: Boolean,
required: true,
},
emptyAlertSvgPath: {
type: String,
required: true,
},
opsgenieMvcEnabled: {
type: Boolean,
required: false,
default: false,
},
opsgenieMvcTargetUrl: {
type: String,
required: false,
default: '',
},
},
mounted() {
this.trackPageViews();
},
methods: {
trackPageViews() {
const { category, action } = trackAlertListViewsOptions;
Tracking.event(category, action);
},
},
inject: ['alertManagementEnabled'],
};
</script>
<template>
<div>
<alert-management-table
v-if="alertManagementEnabled"
:populating-alerts-help-url="populatingAlertsHelpUrl"
:project-path="projectPath"
/>
<alert-management-empty-state
v-else
:empty-alert-svg-path="emptyAlertSvgPath"
:enable-alert-management-path="enableAlertManagementPath"
:user-can-enable-alert-management="userCanEnableAlertManagement"
:opsgenie-mvc-enabled="opsgenieMvcEnabled"
:opsgenie-mvc-target-url="opsgenieMvcTargetUrl"
/>
<alert-management-table v-if="alertManagementEnabled" />
<alert-management-empty-state v-else />
</div>
</template>
<script>
/* eslint-disable vue/no-v-html */
import {
GlAlert,
GlLoadingIcon,
GlTable,
GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
GlIcon,
GlLink,
GlTabs,
GlTab,
GlBadge,
GlPagination,
GlSearchBoxByType,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import { debounce, trim } from 'lodash';
import { __, s__ } from '~/locale';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { s__, __ } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
import {
tdClass,
thClass,
bodyTrClass,
initialPaginationState,
} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import Tracking from '~/tracking';
import getAlerts from '../graphql/queries/get_alerts.query.graphql';
import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import {
ALERTS_STATUS_TABS,
ALERTS_SEVERITY_LABELS,
DEFAULT_PAGE_SIZE,
trackAlertListViewsOptions,
trackAlertStatusUpdateOptions,
} from '../constants';
import AlertStatus from './alert_status.vue';
const tdClass =
'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap';
const thClass = 'gl-hover-bg-blue-50';
const bodyTrClass =
'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200';
const TH_TEST_ID = { 'data-testid': 'alert-management-severity-sort' };
const initialPaginationState = {
currentPage: 1,
prevPageCursor: '',
nextPageCursor: '',
firstPageSize: DEFAULT_PAGE_SIZE,
lastPageSize: null,
};
const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000;
export default {
trackAlertListViewsOptions,
i18n: {
noAlertsMsg: s__(
'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.',
......@@ -60,7 +45,6 @@ export default {
errorMsg: s__(
"AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
),
searchPlaceholder: __('Search or filter results...'),
unassigned: __('Unassigned'),
},
fields: [
......@@ -115,36 +99,23 @@ export default {
severityLabels: ALERTS_SEVERITY_LABELS,
statusTabs: ALERTS_STATUS_TABS,
components: {
GlAlert,
GlLoadingIcon,
GlTable,
GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
TimeAgo,
GlIcon,
GlLink,
GlTabs,
GlTab,
GlBadge,
GlPagination,
GlSearchBoxByType,
GlSprintf,
AlertStatus,
PaginatedTableWithSearchAndTabs,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
projectPath: {
type: String,
required: true,
},
populatingAlertsHelpUrl: {
type: String,
required: true,
},
},
inject: ['projectPath', 'textQuery', 'assigneeUsernameQuery', 'populatingAlertsHelpUrl'],
apollo: {
alerts: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
......@@ -152,6 +123,7 @@ export default {
variables() {
return {
searchTerm: this.searchTerm,
assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath,
statuses: this.statusFilter,
sort: this.sort,
......@@ -182,14 +154,16 @@ export default {
};
},
error() {
this.hasError = true;
this.errored = true;
},
},
alertsCount: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getAlertsCountByStatus,
variables() {
return {
searchTerm: this.searchTerm,
assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath,
};
},
......@@ -200,288 +174,234 @@ export default {
},
data() {
return {
searchTerm: '',
hasError: false,
errorMessage: '',
isAlertDismissed: false,
errored: false,
serverErrorMessage: '',
isErrorAlertDismissed: false,
sort: 'STARTED_AT_DESC',
statusFilter: [],
filteredByStatus: '',
pagination: initialPaginationState,
alerts: {},
alertsCount: {},
sortBy: 'startedAt',
sortDesc: true,
sortDirection: 'desc',
searchTerm: this.textQuery,
assigneeUsername: this.assigneeUsernameQuery,
pagination: initialPaginationState,
};
},
computed: {
showErrorMsg() {
return this.errored && !this.isErrorAlertDismissed;
},
showNoAlertsMsg() {
return (
!this.hasError &&
!this.errored &&
!this.loading &&
this.alertsCount?.all === 0 &&
!this.searchTerm &&
!this.isAlertDismissed
!this.assigneeUsername &&
!this.isErrorAlertDismissed
);
},
loading() {
return this.$apollo.queries.alerts.loading;
},
hasAlerts() {
return this.alerts?.list?.length;
},
showPaginationControls() {
return Boolean(this.prevPage || this.nextPage);
},
alertsForCurrentTab() {
return this.alertsCount ? this.alertsCount[this.filteredByStatus.toLowerCase()] : 0;
},
prevPage() {
return Math.max(this.pagination.currentPage - 1, 0);
},
nextPage() {
const nextPage = this.pagination.currentPage + 1;
return nextPage > Math.ceil(this.alertsForCurrentTab / DEFAULT_PAGE_SIZE) ? null : nextPage;
isEmpty() {
return !this.alerts?.list?.length;
},
},
mounted() {
this.trackPageViews();
},
methods: {
filterAlertsByStatus(tabIndex) {
this.resetPagination();
const { filters, status } = this.$options.statusTabs[tabIndex];
this.statusFilter = filters;
this.filteredByStatus = status;
},
fetchSortedData({ sortBy, sortDesc }) {
const sortingDirection = sortDesc ? 'DESC' : 'ASC';
const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
this.resetPagination();
this.pagination = initialPaginationState;
this.sort = `${sortingColumn}_${sortingDirection}`;
},
onInputChange: debounce(function debounceSearch(input) {
const trimmedInput = trim(input);
if (trimmedInput !== this.searchTerm) {
this.resetPagination();
this.searchTerm = trimmedInput;
}
}, 500),
navigateToAlertDetails({ iid }, index, { metaKey }) {
return visitUrl(joinPaths(window.location.pathname, iid, 'details'), metaKey);
},
trackPageViews() {
const { category, action } = trackAlertListViewsOptions;
Tracking.event(category, action);
},
trackStatusUpdate(status) {
const { category, action, label } = trackAlertStatusUpdateOptions;
Tracking.event(category, action, { label, property: status });
},
hasAssignees(assignees) {
return Boolean(assignees.nodes?.length);
},
getIssueLink(item) {
return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid);
},
handlePageChange(page) {
const { startCursor, endCursor } = this.alerts.pageInfo;
if (page > this.pagination.currentPage) {
this.pagination = {
...initialPaginationState,
nextPageCursor: endCursor,
currentPage: page,
};
} else {
this.pagination = {
lastPageSize: DEFAULT_PAGE_SIZE,
firstPageSize: null,
prevPageCursor: startCursor,
nextPageCursor: '',
currentPage: page,
};
}
},
resetPagination() {
this.pagination = initialPaginationState;
},
tbodyTrClass(item) {
return {
[bodyTrClass]: !this.loading && this.hasAlerts,
[bodyTrClass]: !this.loading && !this.isEmpty,
'new-alert': item?.isNew,
};
},
handleAlertError(errorMessage) {
this.hasError = true;
this.errorMessage = errorMessage;
this.errored = true;
this.serverErrorMessage = errorMessage;
},
dismissError() {
this.hasError = false;
this.errorMessage = '';
handleStatusUpdate() {
this.$apollo.queries.alerts.refetch();
this.$apollo.queries.alertsCount.refetch();
},
pageChanged(pagination) {
this.pagination = pagination;
},
statusChanged({ filters, status }) {
this.statusFilter = filters;
this.filteredByStatus = status;
},
filtersChanged({ searchTerm, assigneeUsername }) {
this.searchTerm = searchTerm;
this.assigneeUsername = assigneeUsername;
},
errorAlertDismissed() {
this.errored = false;
this.serverErrorMessage = '';
this.isErrorAlertDismissed = true;
},
},
};
</script>
<template>
<div>
<div class="incident-management-list">
<gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true">
<gl-sprintf :message="$options.i18n.noAlertsMsg">
<template #link="{ content }">
<gl-link
class="gl-display-inline-block"
:href="populatingAlertsHelpUrl"
target="_blank"
>
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<gl-alert v-if="hasError" variant="danger" data-testid="alert-error" @dismiss="dismissError">
<p v-html="errorMessage || $options.i18n.errorMsg"></p>
</gl-alert>
<gl-tabs
content-class="gl-p-0 gl-border-b-solid gl-border-b-1 gl-border-gray-100"
@input="filterAlertsByStatus"
>
<gl-tab v-for="tab in $options.statusTabs" :key="tab.status">
<template slot="title">
<span>{{ tab.title }}</span>
<gl-badge v-if="alertsCount" pill size="sm" class="gl-tab-counter-badge">
{{ alertsCount[tab.status.toLowerCase()] }}
</gl-badge>
</template>
</gl-tab>
</gl-tabs>
<gl-alert v-if="showNoAlertsMsg" @dismiss="errorAlertDismissed">
<gl-sprintf :message="$options.i18n.noAlertsMsg">
<template #link="{ content }">
<gl-link class="gl-display-inline-block" :href="populatingAlertsHelpUrl" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100">
<gl-search-box-by-type
class="gl-bg-white"
:placeholder="$options.i18n.searchPlaceholder"
@input="onInputChange"
/>
</div>
<paginated-table-with-search-and-tabs
:show-error-msg="showErrorMsg"
:i18n="$options.i18n"
:items="alerts.list || []"
:page-info="alerts.pageInfo"
:items-count="alertsCount"
:status-tabs="$options.statusTabs"
:track-views-options="$options.trackAlertListViewsOptions"
:server-error-message="serverErrorMessage"
:filter-search-tokens="['assignee_username']"
filter-search-key="alerts"
@page-changed="pageChanged"
@tabs-changed="statusChanged"
@filters-changed="filtersChanged"
@error-alert-dismissed="errorAlertDismissed"
>
<template #header-actions></template>
<h4 class="d-block d-md-none my-3">
<template #title>
{{ s__('AlertManagement|Alerts') }}
</h4>
<gl-table
class="alert-management-table"
:items="alerts ? alerts.list : []"
:fields="$options.fields"
:show-empty="true"
:busy="loading"
stacked="md"
:tbody-tr-class="tbodyTrClass"
:no-local-sorting="true"
:sort-direction="sortDirection"
:sort-desc.sync="sortDesc"
:sort-by.sync="sortBy"
sort-icon-left
fixed
@row-clicked="navigateToAlertDetails"
@sort-changed="fetchSortedData"
>
<template #cell(severity)="{ item }">
<div
class="d-inline-flex align-items-center justify-content-between"
data-testid="severityField"
>
<gl-icon
class="mr-2"
:size="12"
:name="`severity-${item.severity.toLowerCase()}`"
:class="`icon-${item.severity.toLowerCase()}`"
/>
{{ $options.severityLabels[item.severity] }}
</div>
</template>
</template>
<template #cell(startedAt)="{ item }">
<time-ago v-if="item.startedAt" :time="item.startedAt" />
</template>
<template #table>
<gl-table
class="alert-management-table"
:items="alerts ? alerts.list : []"
:fields="$options.fields"
:show-empty="true"
:busy="loading"
stacked="md"
:tbody-tr-class="tbodyTrClass"
:no-local-sorting="true"
:sort-direction="sortDirection"
:sort-desc.sync="sortDesc"
:sort-by.sync="sortBy"
sort-icon-left
fixed
@row-clicked="navigateToAlertDetails"
@sort-changed="fetchSortedData"
>
<template #cell(severity)="{ item }">
<div
class="d-inline-flex align-items-center justify-content-between"
data-testid="severityField"
>
<gl-icon
class="mr-2"
:size="12"
:name="`severity-${item.severity.toLowerCase()}`"
:class="`icon-${item.severity.toLowerCase()}`"
/>
{{ $options.severityLabels[item.severity] }}
</div>
</template>
<template #cell(eventCount)="{ item }">
{{ item.eventCount }}
</template>
<template #cell(startedAt)="{ item }">
<time-ago v-if="item.startedAt" :time="item.startedAt" />
</template>
<template #cell(alertLabel)="{ item }">
<div
class="gl-max-w-full text-truncate"
:title="`${item.iid} - ${item.title}`"
data-testid="idField"
>
#{{ item.iid }} {{ item.title }}
</div>
</template>
<template #cell(eventCount)="{ item }">
{{ item.eventCount }}
</template>
<template #cell(issue)="{ item }">
<gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)">
#{{ item.issueIid }}
</gl-link>
<div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div>
</template>
<template #cell(alertLabel)="{ item }">
<div
class="gl-max-w-full text-truncate"
:title="`${item.iid} - ${item.title}`"
data-testid="idField"
>
#{{ item.iid }} {{ item.title }}
</div>
</template>
<template #cell(assignees)="{ item }">
<div data-testid="assigneesField">
<template v-if="hasAssignees(item.assignees)">
<gl-avatars-inline
:avatars="item.assignees.nodes"
:collapsed="true"
:max-visible="4"
:avatar-size="24"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="100"
>
<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 #cell(issue)="{ item }">
<gl-link v-if="item.issueIid" data-testid="issueField" :href="getIssueLink(item)">
#{{ item.issueIid }}
</gl-link>
<div v-else data-testid="issueField">{{ s__('AlertManagement|None') }}</div>
</template>
<template #cell(status)="{ item }">
<alert-status
:alert="item"
:project-path="projectPath"
:is-sidebar="false"
@alert-error="handleAlertError"
/>
</template>
<template #cell(assignees)="{ item }">
<div data-testid="assigneesField">
<template v-if="hasAssignees(item.assignees)">
<gl-avatars-inline
:avatars="item.assignees.nodes"
:collapsed="true"
:max-visible="4"
:avatar-size="24"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="100"
>
<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 #empty>
{{ s__('AlertManagement|No alerts to display.') }}
</template>
<template #cell(status)="{ item }">
<alert-status
:alert="item"
:project-path="projectPath"
:is-sidebar="false"
@alert-error="handleAlertError"
@hide-dropdown="handleStatusUpdate"
/>
</template>
<template #table-busy>
<gl-loading-icon size="lg" color="dark" class="mt-3" />
</template>
</gl-table>
<template #empty>
{{ s__('AlertManagement|No alerts to display.') }}
</template>
<gl-pagination
v-if="showPaginationControls"
:value="pagination.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
</div>
<template #table-busy>
<gl-loading-icon size="lg" color="dark" class="mt-3" />
</template>
</gl-table>
</template>
</paginated-table-with-search-and-tabs>
</div>
</template>
......@@ -3,7 +3,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import { trackAlertStatusUpdateOptions } from '../constants';
import updateAlertStatus from '../graphql/mutations/update_alert_status.mutation.graphql';
import updateAlertStatusMutation from '../graphql/mutations/update_alert_status.mutation.graphql';
export default {
i18n: {
......@@ -50,7 +50,7 @@ export default {
this.$emit('handle-updating', true);
this.$apollo
.mutate({
mutation: updateAlertStatus,
mutation: updateAlertStatusMutation,
variables: {
iid: this.alert.iid,
status: status.toUpperCase(),
......@@ -59,8 +59,6 @@ export default {
})
.then(resp => {
this.trackStatusUpdate(status);
this.$emit('hide-dropdown');
const errors = resp.data?.updateAlertStatus?.errors || [];
if (errors[0]) {
......@@ -69,6 +67,8 @@ export default {
`${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`,
);
}
this.$emit('hide-dropdown');
})
.catch(() => {
this.$emit(
......
......@@ -63,5 +63,3 @@ export const trackAlertStatusUpdateOptions = {
action: 'update_alert_status',
label: 'Status',
};
export const DEFAULT_PAGE_SIZE = 20;
#import "../fragments/list_item.fragment.graphql"
query getAlerts(
$searchTerm: String
$projectPath: ID!
$statuses: [AlertManagementStatus!]
$sort: AlertManagementAlertSort
......@@ -9,10 +8,13 @@ query getAlerts(
$lastPageSize: Int
$prevPageCursor: String = ""
$nextPageCursor: String = ""
$searchTerm: String = ""
$assigneeUsername: String = ""
) {
project(fullPath: $projectPath) {
alertManagementAlerts(
search: $searchTerm
assigneeUsername: $assigneeUsername
statuses: $statuses
sort: $sort
first: $firstPageSize
......
query getAlertsCount($searchTerm: String, $projectPath: ID!) {
query getAlertsCount($searchTerm: String, $projectPath: ID!, $assigneeUsername: String = "") {
project(fullPath: $projectPath) {
alertManagementAlertStatusCounts(search: $searchTerm) {
alertManagementAlertStatusCounts(search: $searchTerm, assigneeUsername: $assigneeUsername) {
all
open
acknowledged
......
......@@ -18,12 +18,12 @@ export default () => {
populatingAlertsHelpUrl,
alertsHelpUrl,
opsgenieMvcTargetUrl,
textQuery,
assigneeUsernameQuery,
alertManagementEnabled,
userCanEnableAlertManagement,
opsgenieMvcEnabled,
} = domEl.dataset;
let { alertManagementEnabled, userCanEnableAlertManagement, opsgenieMvcEnabled } = domEl.dataset;
alertManagementEnabled = parseBoolean(alertManagementEnabled);
userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement);
opsgenieMvcEnabled = parseBoolean(opsgenieMvcEnabled);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
......@@ -50,23 +50,24 @@ export default () => {
return new Vue({
el: selector,
provide: {
projectPath,
textQuery,
assigneeUsernameQuery,
enableAlertManagementPath,
populatingAlertsHelpUrl,
emptyAlertSvgPath,
opsgenieMvcTargetUrl,
alertManagementEnabled: parseBoolean(alertManagementEnabled),
userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement),
opsgenieMvcEnabled: parseBoolean(opsgenieMvcEnabled),
},
apolloProvider,
components: {
AlertManagementList,
},
render(createElement) {
return createElement('alert-management-list', {
props: {
projectPath,
enableAlertManagementPath,
populatingAlertsHelpUrl,
emptyAlertSvgPath,
alertManagementEnabled,
userCanEnableAlertManagement,
opsgenieMvcTargetUrl,
opsgenieMvcEnabled,
},
});
return createElement('alert-management-list');
},
});
};
......@@ -2,41 +2,32 @@
import {
GlLoadingIcon,
GlTable,
GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
GlTooltipDirective,
GlButton,
GlIcon,
GlPagination,
GlTabs,
GlTab,
GlBadge,
GlEmptyState,
} from '@gitlab/ui';
import Api from '~/api';
import Tracking from '~/tracking';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
import {
visitUrl,
mergeUrlParams,
joinPaths,
updateHistory,
setUrlParams,
} from '~/lib/utils/url_utility';
tdClass,
thClass,
bodyTrClass,
initialPaginationState,
} from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
import { visitUrl, mergeUrlParams, joinPaths } from '~/lib/utils/url_utility';
import getIncidents from '../graphql/queries/get_incidents.query.graphql';
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
import {
I18N,
DEFAULT_PAGE_SIZE,
INCIDENT_STATUS_TABS,
TH_CREATED_AT_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
......@@ -44,24 +35,12 @@ import {
TH_PUBLISHED_TEST_ID,
INCIDENT_DETAILS_PATH,
trackIncidentCreateNewOptions,
trackIncidentListViewsOptions,
} from '../constants';
const tdClass =
'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap';
const thClass = 'gl-hover-bg-blue-50';
const bodyTrClass =
'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200';
const initialPaginationState = {
currentPage: 1,
prevPageCursor: '',
nextPageCursor: '',
firstPageSize: DEFAULT_PAGE_SIZE,
lastPageSize: null,
};
export default {
trackIncidentCreateNewOptions,
trackIncidentListViewsOptions,
i18n: I18N,
statusTabs: INCIDENT_STATUS_TABS,
fields: [
......@@ -112,23 +91,18 @@ export default {
components: {
GlLoadingIcon,
GlTable,
GlAlert,
GlAvatarsInline,
GlAvatarLink,
GlAvatar,
GlButton,
TimeAgoTooltip,
GlIcon,
GlPagination,
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,
FilteredSearchBar,
PaginatedTableWithSearchAndTabs,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -142,8 +116,8 @@ export default {
'publishedAvailable',
'emptyListSvgPath',
'textQuery',
'authorUsernamesQuery',
'assigneeUsernamesQuery',
'authorUsernameQuery',
'assigneeUsernameQuery',
'slaFeatureAvailable',
],
apollo: {
......@@ -152,16 +126,16 @@ export default {
variables() {
return {
searchTerm: this.searchTerm,
status: this.statusFilter,
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,
authorUsername: this.authorUsername,
assigneeUsernames: this.assigneeUsernames,
};
},
update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) {
......@@ -180,7 +154,7 @@ export default {
return {
searchTerm: this.searchTerm,
authorUsername: this.authorUsername,
assigneeUsernames: this.assigneeUsernames,
assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath,
issueTypes: ['INCIDENT'],
};
......@@ -195,17 +169,17 @@ export default {
errored: false,
isErrorAlertDismissed: false,
redirecting: false,
searchTerm: this.textQuery,
pagination: initialPaginationState,
incidents: {},
incidentsCount: {},
sort: 'created_desc',
sortBy: 'createdAt',
sortDesc: true,
statusFilter: '',
filteredByStatus: '',
authorUsername: this.authorUsernamesQuery,
assigneeUsernames: this.assigneeUsernamesQuery,
filterParams: {},
searchTerm: this.textQuery,
authorUsername: this.authorUsernameQuery,
assigneeUsername: this.assigneeUsernameQuery,
pagination: initialPaginationState,
};
},
computed: {
......@@ -215,29 +189,15 @@ export default {
loading() {
return this.$apollo.queries.incidents.loading;
},
hasIncidents() {
return this.incidents?.list?.length;
},
incidentsForCurrentTab() {
return this.incidentsCount?.[this.filteredByStatus.toLowerCase()] ?? 0;
},
showPaginationControls() {
return Boolean(
this.incidents?.pageInfo?.hasNextPage || this.incidents?.pageInfo?.hasPreviousPage,
);
},
prevPage() {
return Math.max(this.pagination.currentPage - 1, 0);
isEmpty() {
return !this.incidents?.list?.length;
},
nextPage() {
const nextPage = this.pagination.currentPage + 1;
return nextPage > Math.ceil(this.incidentsForCurrentTab / DEFAULT_PAGE_SIZE)
? null
: nextPage;
showList() {
return !this.isEmpty || this.errored || this.loading;
},
tbodyTrClass() {
return {
[bodyTrClass]: !this.loading && this.hasIncidents,
[bodyTrClass]: !this.loading && !this.isEmpty,
};
},
newIncidentPath() {
......@@ -257,12 +217,6 @@ export default {
return this.$options.fields.filter(({ key }) => !isHidden[key]);
},
isEmpty() {
return !this.incidents.list?.length;
},
showList() {
return !this.isEmpty || this.errored || this.loading;
},
activeClosedTabHasNoIncidents() {
const { all, closed } = this.incidentsCount || {};
const isClosedTabActive = this.statusFilter === this.$options.statusTabs[1].filters;
......@@ -285,63 +239,8 @@ export default {
btnText: createIncidentBtnLabel,
};
},
filteredSearchTokens() {
return [
{
type: 'author_username',
icon: 'user',
title: __('Author'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
{
type: 'assignee_username',
icon: 'user',
title: __('Assignees'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
];
},
filteredSearchValue() {
const value = [];
if (this.authorUsername) {
value.push({
type: 'author_username',
value: { data: this.authorUsername },
});
}
if (this.assigneeUsernames) {
value.push({
type: 'assignee_username',
value: { data: this.assigneeUsernames },
});
}
if (this.searchTerm) {
value.push(this.searchTerm);
}
return value;
},
},
methods: {
filterIncidentsByStatus(tabIndex) {
this.resetPagination();
const { filters, status } = this.$options.statusTabs[tabIndex];
this.statusFilter = filters;
this.filteredByStatus = status;
},
hasAssignees(assignees) {
return Boolean(assignees.nodes?.length);
},
......@@ -353,255 +252,170 @@ export default {
Tracking.event(category, action);
this.redirecting = true;
},
handlePageChange(page) {
const { startCursor, endCursor } = this.incidents.pageInfo;
if (page > this.pagination.currentPage) {
this.pagination = {
...initialPaginationState,
nextPageCursor: endCursor,
currentPage: page,
};
} else {
this.pagination = {
lastPageSize: DEFAULT_PAGE_SIZE,
firstPageSize: null,
prevPageCursor: startCursor,
nextPageCursor: '',
currentPage: page,
};
}
},
resetPagination() {
this.pagination = initialPaginationState;
},
fetchSortedData({ sortBy, sortDesc }) {
const sortingDirection = sortDesc ? 'DESC' : 'ASC';
const sortingColumn = convertToSnakeCase(sortBy)
.replace(/_.*/, '')
.toUpperCase();
this.resetPagination();
this.pagination = initialPaginationState;
this.sort = `${sortingColumn}_${sortingDirection}`;
},
getSeverity(severity) {
return INCIDENT_SEVERITY[severity];
},
handleFilterIncidents(filters) {
this.resetPagination();
const filterParams = { authorUsername: '', assigneeUsername: '', search: '' };
filters.forEach(filter => {
if (typeof filter === 'object') {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = filter.value.data;
break;
case 'assignee_username':
filterParams.assigneeUsername = filter.value.data;
break;
case 'filtered-search-term':
if (filter.value.data !== '') filterParams.search = filter.value.data;
break;
default:
break;
}
}
});
this.filterParams = filterParams;
this.updateUrl();
this.searchTerm = filterParams?.search;
this.authorUsername = filterParams?.authorUsername;
this.assigneeUsernames = filterParams?.assigneeUsername;
pageChanged(pagination) {
this.pagination = pagination;
},
updateUrl() {
const queryParams = urlParamsToObject(window.location.search);
const { authorUsername, assigneeUsername, search } = this.filterParams || {};
if (authorUsername) {
queryParams.author_username = authorUsername;
} else {
delete queryParams.author_username;
}
if (assigneeUsername) {
queryParams.assignee_username = assigneeUsername;
} else {
delete queryParams.assignee_username;
}
if (search) {
queryParams.search = search;
} else {
delete queryParams.search;
}
updateHistory({
url: setUrlParams(queryParams, window.location.href, true),
title: document.title,
replace: true,
});
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;
},
},
};
</script>
<template>
<div class="incident-management-list">
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true">
{{ $options.i18n.errorMsg }}
</gl-alert>
<div
class="incident-management-list-header gl-display-flex gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-gray-100"
>
<gl-tabs content-class="gl-p-0" @input="filterIncidentsByStatus">
<gl-tab v-for="tab in $options.statusTabs" :key="tab.status" :data-testid="tab.status">
<template #title>
<span>{{ tab.title }}</span>
<gl-badge v-if="incidentsCount" pill size="sm" class="gl-tab-counter-badge">
{{ incidentsCount[tab.status.toLowerCase()] }}
</gl-badge>
</template>
</gl-tab>
</gl-tabs>
<gl-button
v-if="!isEmpty || activeClosedTabHasNoIncidents"
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="success"
:href="newIncidentPath"
@click="navigateToCreateNewIncident"
>
{{ $options.i18n.createIncidentBtnLabel }}
</gl-button>
</div>
<div class="filtered-search-wrapper">
<filtered-search-bar
:namespace="projectPath"
:search-input-placeholder="$options.i18n.searchPlaceholder"
:tokens="filteredSearchTokens"
:initial-filter-value="filteredSearchValue"
initial-sortby="created_desc"
recent-searches-storage-key="incidents"
class="row-content-block"
@onFilter="handleFilterIncidents"
/>
</div>
<h4 class="gl-display-block d-md-none my-3">
{{ s__('IncidentManagement|Incidents') }}
</h4>
<gl-table
v-if="showList"
<div>
<paginated-table-with-search-and-tabs
:show-items="showList"
:show-error-msg="showErrorMsg"
:i18n="$options.i18n"
:items="incidents.list || []"
:fields="availableFields"
:show-empty="true"
:busy="loading"
stacked="md"
:tbody-tr-class="tbodyTrClass"
:no-local-sorting="true"
:sort-direction="'desc'"
:sort-desc.sync="sortDesc"
:sort-by.sync="sortBy"
sort-icon-left
fixed
@row-clicked="navigateToIncidentDetails"
@sort-changed="fetchSortedData"
: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 #cell(severity)="{ item }">
<severity-token :severity="getSeverity(item.severity)" />
<template #header-actions>
<gl-button
v-if="!isEmpty || activeClosedTabHasNoIncidents"
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="success"
:href="newIncidentPath"
@click="redirecting = true"
>
{{ $options.i18n.createIncidentBtnLabel }}
</gl-button>
</template>
<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>
<gl-icon
v-if="item.state === 'closed'"
name="issue-close"
class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0"
:size="16"
data-testid="incident-closed"
/>
</div>
<template #title>
{{ s__('IncidentManagement|Incidents') }}
</template>
<template #cell(createdAt)="{ item }">
<time-ago-tooltip :time="item.createdAt" />
</template>
<template #table>
<gl-table
:items="incidents.list || []"
:fields="availableFields"
:show-empty="true"
:busy="loading"
stacked="md"
:tbody-tr-class="tbodyTrClass"
:no-local-sorting="true"
:sort-direction="'desc'"
:sort-desc.sync="sortDesc"
:sort-by.sync="sortBy"
sort-icon-left
fixed
@row-clicked="navigateToIncidentDetails"
@sort-changed="fetchSortedData"
>
<template #cell(severity)="{ item }">
<severity-token :severity="getSeverity(item.severity)" />
</template>
<template v-if="slaFeatureAvailable" #cell(incidentSla)="{ item }">
<service-level-agreement-cell :sla-due-at="item.slaDueAt" data-testid="incident-sla" />
</template>
<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>
<gl-icon
v-if="item.state === 'closed'"
name="issue-close"
class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0"
:size="16"
data-testid="incident-closed"
/>
</div>
</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="4"
:avatar-size="24"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="100"
>
<template #avatar="{ avatar }">
<gl-avatar-link
:key="avatar.username"
v-gl-tooltip
target="_blank"
:href="avatar.webUrl"
:title="avatar.name"
<template #cell(createdAt)="{ item }">
<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)">
<gl-avatars-inline
:avatars="item.assignees.nodes"
:collapsed="true"
:max-visible="4"
:avatar-size="24"
badge-tooltip-prop="name"
:badge-tooltip-max-chars="100"
>
<gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" />
</gl-avatar-link>
<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>
</gl-avatars-inline>
</div>
</template>
<template v-else>
{{ $options.i18n.unassigned }}
<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>
</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 v-if="errored" #empty>
{{ $options.i18n.noIncidents }}
</template>
</gl-table>
</template>
<template v-if="errored" #empty>
{{ $options.i18n.noIncidents }}
<template #emtpy-state>
<gl-empty-state
:title="emptyStateData.title"
:svg-path="emptyListSvgPath"
:description="emptyStateData.description"
:primary-button-link="emptyStateData.btnLink"
:primary-button-text="emptyStateData.btnText"
/>
</template>
</gl-table>
<gl-empty-state
v-else
:title="emptyStateData.title"
:svg-path="emptyListSvgPath"
:description="emptyStateData.description"
:primary-button-link="emptyStateData.btnLink"
:primary-button-text="emptyStateData.btnText"
/>
<gl-pagination
v-if="showPaginationControls"
:value="pagination.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
</paginated-table-with-search-and-tabs>
</div>
</template>
/* eslint-disable @gitlab/require-i18n-strings */
import { s__, __ } from '~/locale';
import { s__ } from '~/locale';
export const I18N = {
errorMsg: s__('IncidentManagement|There was an error displaying the incidents.'),
......@@ -7,7 +7,6 @@ export const I18N = {
unassigned: s__('IncidentManagement|Unassigned'),
createIncidentBtnLabel: s__('IncidentManagement|Create incident'),
unPublished: s__('IncidentManagement|Unpublished'),
searchPlaceholder: __('Search or filter results…'),
emptyState: {
title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
......@@ -43,6 +42,14 @@ export const trackIncidentCreateNewOptions = {
action: 'create_incident_button_clicks',
};
/**
* Tracks snowplow event when user views incident list
*/
export const trackIncidentListViewsOptions = {
category: 'Incident Management',
action: 'view_incidents_list',
};
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' };
......
......@@ -3,14 +3,14 @@ query getIncidentsCountByStatus(
$projectPath: ID!
$issueTypes: [IssueType!]
$authorUsername: String = ""
$assigneeUsernames: String = ""
$assigneeUsername: String = ""
) {
project(fullPath: $projectPath) {
issueStatusCounts(
search: $searchTerm
types: $issueTypes
authorUsername: $authorUsername
assigneeUsername: $assigneeUsernames
assigneeUsername: $assigneeUsername
) {
all
opened
......
......@@ -11,7 +11,7 @@ query getIncidents(
$nextPageCursor: String = ""
$searchTerm: String = ""
$authorUsername: String = ""
$assigneeUsernames: String = ""
$assigneeUsername: String = ""
) {
project(fullPath: $projectPath) {
issues(
......@@ -20,7 +20,7 @@ query getIncidents(
sort: $sort
state: $status
authorUsername: $authorUsername
assigneeUsername: $assigneeUsernames
assigneeUsername: $assigneeUsername
first: $firstPageSize
last: $lastPageSize
after: $nextPageCursor
......
......@@ -18,8 +18,8 @@ export default () => {
publishedAvailable,
emptyListSvgPath,
textQuery,
authorUsernamesQuery,
assigneeUsernamesQuery,
authorUsernameQuery,
assigneeUsernameQuery,
slaFeatureAvailable,
} = domEl.dataset;
......@@ -38,8 +38,8 @@ export default () => {
publishedAvailable: parseBoolean(publishedAvailable),
emptyListSvgPath,
textQuery,
authorUsernamesQuery,
assigneeUsernamesQuery,
authorUsernameQuery,
assigneeUsernameQuery,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
},
apolloProvider,
......
import { __ } from '~/locale';
export const tdClass =
'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap';
export const thClass = 'gl-hover-bg-blue-50';
export const bodyTrClass =
'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200';
export const defaultPageSize = 20;
export const initialPaginationState = {
page: 1,
prevPageCursor: '',
nextPageCursor: '',
firstPageSize: defaultPageSize,
lastPageSize: null,
};
export const defaultI18n = {
searchPlaceholder: __('Search or filter results…'),
};
<script>
import { GlAlert, GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
import Api from '~/api';
import Tracking from '~/tracking';
import { __ } from '~/locale';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { initialPaginationState, defaultI18n, defaultPageSize } from './constants';
import { isAny } from './utils';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
export default {
defaultI18n,
components: {
GlAlert,
GlBadge,
GlPagination,
GlTabs,
GlTab,
FilteredSearchBar,
},
inject: {
projectPath: {
default: '',
},
textQuery: {
default: '',
},
assigneeUsernameQuery: {
default: '',
},
authorUsernameQuery: {
default: '',
},
},
props: {
items: {
type: Array,
required: true,
},
itemsCount: {
type: Object,
required: false,
default: () => {},
},
pageInfo: {
type: Object,
required: false,
default: () => {},
},
statusTabs: {
type: Array,
required: true,
},
showItems: {
type: Boolean,
required: false,
default: true,
},
showErrorMsg: {
type: Boolean,
required: true,
},
trackViewsOptions: {
type: Object,
required: true,
},
i18n: {
type: Object,
required: true,
},
serverErrorMessage: {
type: String,
required: false,
default: '',
},
filterSearchKey: {
type: String,
required: true,
},
filterSearchTokens: {
type: Array,
required: false,
default: () => ['author_username', 'assignee_username'],
},
},
data() {
return {
searchTerm: this.textQuery,
authorUsername: this.authorUsernameQuery,
assigneeUsername: this.assigneeUsernameQuery,
filterParams: {},
pagination: initialPaginationState,
filteredByStatus: '',
statusFilter: '',
};
},
computed: {
defaultTokens() {
return [
{
type: 'author_username',
icon: 'user',
title: __('Author'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
{
type: 'assignee_username',
icon: 'user',
title: __('Assignee'),
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
fetchPath: this.projectPath,
fetchAuthors: Api.projectUsers.bind(Api),
},
];
},
filteredSearchTokens() {
return this.defaultTokens.filter(({ type }) => this.filterSearchTokens.includes(type));
},
filteredSearchValue() {
const value = [];
if (this.authorUsername) {
value.push({
type: 'author_username',
value: { data: this.authorUsername },
});
}
if (this.assigneeUsername) {
value.push({
type: 'assignee_username',
value: { data: this.assigneeUsername },
});
}
if (this.searchTerm) {
value.push(this.searchTerm);
}
return value;
},
itemsForCurrentTab() {
return this.itemsCount?.[this.filteredByStatus.toLowerCase()] ?? 0;
},
showPaginationControls() {
return Boolean(this.pageInfo?.hasNextPage || this.pageInfo?.hasPreviousPage);
},
previousPage() {
return Math.max(this.pagination.page - 1, 0);
},
nextPage() {
const nextPage = this.pagination.page + 1;
return nextPage > Math.ceil(this.itemsForCurrentTab / defaultPageSize) ? null : nextPage;
},
},
mounted() {
this.trackPageViews();
},
methods: {
filterItemsByStatus(tabIndex) {
this.resetPagination();
const { filters, status } = this.statusTabs[tabIndex];
this.statusFilter = filters;
this.filteredByStatus = status;
this.$emit('tabs-changed', { filters, status });
},
handlePageChange(page) {
const { startCursor, endCursor } = this.pageInfo;
if (page > this.pagination.page) {
this.pagination = {
...initialPaginationState,
nextPageCursor: endCursor,
page,
};
} else {
this.pagination = {
lastPageSize: defaultPageSize,
firstPageSize: null,
prevPageCursor: startCursor,
nextPageCursor: '',
page,
};
}
this.$emit('page-changed', this.pagination);
},
resetPagination() {
this.pagination = initialPaginationState;
this.$emit('page-changed', this.pagination);
},
handleFilterItems(filters) {
this.resetPagination();
const filterParams = { authorUsername: '', assigneeUsername: '', search: '' };
filters.forEach(filter => {
if (typeof filter === 'object') {
switch (filter.type) {
case 'author_username':
filterParams.authorUsername = isAny(filter.value.data);
break;
case 'assignee_username':
filterParams.assigneeUsername = isAny(filter.value.data);
break;
case 'filtered-search-term':
if (filter.value.data !== '') filterParams.search = filter.value.data;
break;
default:
break;
}
}
});
this.filterParams = filterParams;
this.updateUrl();
this.searchTerm = filterParams?.search;
this.authorUsername = filterParams?.authorUsername;
this.assigneeUsername = filterParams?.assigneeUsername;
this.$emit('filters-changed', {
searchTerm: this.searchTerm,
authorUsername: this.authorUsername,
assigneeUsername: this.assigneeUsername,
});
},
updateUrl() {
const { authorUsername, assigneeUsername, search } = this.filterParams || {};
const params = {
...(authorUsername !== '' && { author_username: authorUsername }),
...(assigneeUsername !== '' && { assignee_username: assigneeUsername }),
...(search !== '' && { search }),
};
updateHistory({
url: setUrlParams(params, window.location.href, true),
title: document.title,
replace: true,
});
},
trackPageViews() {
const { category, action } = this.trackViewsOptions;
Tracking.event(category, action);
},
},
};
</script>
<template>
<div class="incident-management-list">
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="$emit('error-alert-dismissed')">
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="serverErrorMessage || i18n.errorMsg"></p>
</gl-alert>
<div
class="list-header gl-display-flex gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-gray-100"
>
<gl-tabs content-class="gl-p-0" @input="filterItemsByStatus">
<gl-tab v-for="tab in statusTabs" :key="tab.status" :data-testid="tab.status">
<template #title>
<span>{{ tab.title }}</span>
<gl-badge v-if="itemsCount" pill size="sm" class="gl-tab-counter-badge">
{{ itemsCount[tab.status.toLowerCase()] }}
</gl-badge>
</template>
</gl-tab>
</gl-tabs>
<slot name="header-actions"></slot>
</div>
<div class="filtered-search-wrapper">
<filtered-search-bar
:namespace="projectPath"
:search-input-placeholder="$options.defaultI18n.searchPlaceholder"
:tokens="filteredSearchTokens"
:initial-filter-value="filteredSearchValue"
initial-sortby="created_desc"
:recent-searches-storage-key="filterSearchKey"
class="row-content-block"
@onFilter="handleFilterItems"
/>
</div>
<h4 class="gl-display-block d-md-none my-3">
<slot name="title"></slot>
</h4>
<slot v-if="showItems" name="table"></slot>
<gl-pagination
v-if="showPaginationControls"
:value="pagination.page"
:prev-page="previousPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
<slot v-if="!showItems" name="emtpy-state"></slot>
</div>
</template>
import { __ } from '~/locale';
/**
* Return a empty string when passed a value of 'Any'
*
* @param {String} value
* @returns {String}
*/
export const isAny = value => {
return value === __('Any') ? '' : value;
};
......@@ -8,7 +8,7 @@
@include gl-text-gray-500;
tbody {
tr {
tr:not(.b-table-busy-slot) {
// TODO replace with gitlab/ui utilities: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1791
&:hover {
border-top-style: double;
......@@ -132,7 +132,7 @@
}
@include media-breakpoint-down(xs) {
.incident-management-list-header {
.list-header {
flex-direction: column-reverse;
}
......
......@@ -9,7 +9,9 @@ module Projects::AlertManagementHelper
'populating-alerts-help-url' => help_page_url('operations/incident_management/index.md', anchor: 'enable-alert-management'),
'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'),
'user-can-enable-alert-management' => can?(current_user, :admin_operations, project).to_s,
'alert-management-enabled' => alert_management_enabled?(project).to_s
'alert-management-enabled' => alert_management_enabled?(project).to_s,
'text-query': params[:search],
'assignee-username-query': params[:assignee_username]
}
end
......
......@@ -10,8 +10,8 @@ module Projects::IncidentsHelper
'issue-path' => project_issues_path(project),
'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg'),
'text-query': params[:search],
'author-usernames-query': params[:author_username],
'assignee-usernames-query': params[:assignee_username]
'author-username-query': params[:author_username],
'assignee-username-query': params[:assignee_username]
}
end
end
......
......@@ -29,8 +29,8 @@ RSpec.describe Projects::IncidentsHelper do
'published-available' => 'false',
'sla-feature-available' => 'false',
'text-query': 'search text',
'author-usernames-query': 'root',
'assignee-usernames-query': 'max.power'
'author-username-query': 'root',
'assignee-username-query': 'max.power'
}
end
......
......@@ -20,18 +20,12 @@ RSpec.describe 'User searches Alert Management alerts', :js do
end
context 'when a developer displays the alert list and the alert service is enabled they can search an alert' do
it 'shows the alert table with an alert for a valid search' do
expect(page).to have_selector('[data-testid="search-icon"]')
find('.gl-search-box-by-type-input').set('Alert')
expect(all('.dropdown-menu-selectable').count).to be(1)
end
it 'shows the an empty table with an invalid search' do
find('.gl-search-box-by-type-input').set('invalid search text')
expect(page).not_to have_selector('.dropdown-menu-selectable')
it 'shows the incident table with an incident for a valid search filter bar' do
expect(page).to have_selector('.filtered-search-wrapper')
expect(page).to have_selector('.gl-table')
expect(page).to have_css('[data-testid="severityField"]')
expect(all('tbody tr').count).to be(1)
expect(page).not_to have_selector('.empty-state')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'User updates Alert Management status', :js do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:alerts_service) { create(:alerts_service, project: project) }
let_it_be(:alert) { create(:alert_management_alert, project: project, status: 'triggered') }
before_all do
project.add_developer(developer)
end
before do
sign_in(developer)
visit project_alert_management_index_path(project)
wait_for_requests
end
context 'when a developer+ displays the alerts list and the alert service is enabled they can update an alert status' do
it 'shows the alert table with an alert status dropdown' do
expect(page).to have_selector('.gl-table')
expect(find('.dropdown-menu-selectable')).to have_content('Triggered')
end
it 'updates the alert status' do
find('.dropdown-menu-selectable').click
find('.dropdown-item', text: 'Acknowledged').click
wait_for_requests
expect(find('.dropdown-menu-selectable')).to have_content('Acknowledged')
end
end
end
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue';
import defaultProvideValues from '../mocks/alerts_provide_config.json';
describe('AlertManagementEmptyState', () => {
let wrapper;
function mountComponent({
props = {
alertManagementEnabled: false,
userCanEnableAlertManagement: false,
},
stubs = {},
} = {}) {
function mountComponent({ provide = {} } = {}) {
wrapper = shallowMount(AlertManagementEmptyState, {
propsData: {
enableAlertManagementPath: '/link',
alertsHelpUrl: '/link',
emptyAlertSvgPath: 'illustration/path',
...props,
provide: {
...defaultProvideValues,
...provide,
},
stubs,
});
}
......@@ -42,7 +34,7 @@ describe('AlertManagementEmptyState', () => {
it('show OpsGenie integration state when OpsGenie mcv is true', () => {
mountComponent({
props: {
provide: {
alertManagementEnabled: false,
userCanEnableAlertManagement: false,
opsgenieMvcEnabled: true,
......
import { shallowMount } from '@vue/test-utils';
import AlertManagementList from '~/alert_management/components/alert_management_list_wrapper.vue';
import { trackAlertListViewsOptions } from '~/alert_management/constants';
import mockAlerts from '../mocks/alerts.json';
import Tracking from '~/tracking';
import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue';
import AlertManagementTable from '~/alert_management/components/alert_management_table.vue';
import defaultProvideValues from '../mocks/alerts_provide_config.json';
describe('AlertManagementList', () => {
let wrapper;
function mountComponent({
props = {
alertManagementEnabled: false,
userCanEnableAlertManagement: false,
},
data = {},
stubs = {},
} = {}) {
function mountComponent({ provide = {} } = {}) {
wrapper = shallowMount(AlertManagementList, {
propsData: {
projectPath: 'gitlab-org/gitlab',
enableAlertManagementPath: '/link',
alertsHelpUrl: '/link',
populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data',
emptyAlertSvgPath: 'illustration/path',
...props,
provide: {
...defaultProvideValues,
...provide,
},
data() {
return data;
},
stubs,
});
}
......@@ -41,18 +26,21 @@ describe('AlertManagementList', () => {
}
});
describe('Snowplow tracking', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
describe('Alert List Wrapper', () => {
it('should show the empty state when alerts are not enabled', () => {
expect(wrapper.find(AlertManagementEmptyState).exists()).toBe(true);
expect(wrapper.find(AlertManagementTable).exists()).toBe(false);
});
it('should show the alerts table when alerts are enabled', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts } },
provide: {
alertManagementEnabled: true,
},
});
});
it('should track alert list page views', () => {
const { category, action } = trackAlertListViewsOptions;
expect(Tracking.event).toHaveBeenCalledWith(category, action);
expect(wrapper.find(AlertManagementEmptyState).exists()).toBe(false);
expect(wrapper.find(AlertManagementTable).exists()).toBe(true);
});
});
});
import { mount } from '@vue/test-utils';
import {
GlTable,
GlAlert,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
GlIcon,
GlTabs,
GlTab,
GlBadge,
GlPagination,
GlSearchBoxByType,
GlAvatar,
} from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import { GlTable, GlAlert, GlLoadingIcon, GlDropdown, GlIcon, GlAvatar } from '@gitlab/ui';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { visitUrl } from '~/lib/utils/url_utility';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import AlertManagementTable from '~/alert_management/components/alert_management_table.vue';
import { ALERTS_STATUS_TABS, trackAlertStatusUpdateOptions } from '~/alert_management/constants';
import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import mockAlerts from '../mocks/alerts.json';
import Tracking from '~/tracking';
import defaultProvideValues from '../mocks/alerts_provide_config.json';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
......@@ -29,26 +16,21 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('AlertManagementTable', () => {
let wrapper;
let mock;
const findAlertsTable = () => wrapper.find(GlTable);
const findAlerts = () => wrapper.findAll('table tbody tr');
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
const findStatusDropdown = () => wrapper.find(GlDropdown);
const findStatusFilterTabs = () => wrapper.findAll(GlTab);
const findStatusTabs = () => wrapper.find(GlTabs);
const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
const findDateFields = () => wrapper.findAll(TimeAgo);
const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
const findPagination = () => wrapper.find(GlPagination);
const findSearch = () => wrapper.find(GlSearchBoxByType);
const findSearch = () => wrapper.find(FilteredSearchBar);
const findSeverityColumnHeader = () =>
wrapper.find('[data-testid="alert-management-severity-sort"]');
const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0);
const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]');
const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]');
const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]');
const findAlertError = () => wrapper.find('[data-testid="alert-error"]');
const alertsCount = {
open: 24,
triggered: 20,
......@@ -56,26 +38,14 @@ describe('AlertManagementTable', () => {
resolved: 11,
all: 26,
};
const selectFirstStatusOption = () => {
findFirstStatusOption().vm.$emit('click');
return waitForPromises();
};
function mountComponent({
props = {
alertManagementEnabled: false,
userCanEnableAlertManagement: false,
},
data = {},
loading = false,
stubs = {},
} = {}) {
function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) {
wrapper = mount(AlertManagementTable, {
propsData: {
projectPath: 'gitlab-org/gitlab',
populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data',
...props,
provide: {
...defaultProvideValues,
alertManagementEnabled: true,
userCanEnableAlertManagement: true,
...provide,
},
data() {
return data;
......@@ -95,41 +65,21 @@ describe('AlertManagementTable', () => {
});
}
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('Status Filter Tabs', () => {
beforeEach(() => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: mockAlerts, alertsCount },
loading: false,
stubs: {
GlTab: true,
},
});
});
it('should display filter tabs with alerts count badge for each status', () => {
const tabs = findStatusFilterTabs().wrappers;
const badges = findStatusFilterBadge();
tabs.forEach((tab, i) => {
const status = ALERTS_STATUS_TABS[i].status.toLowerCase();
expect(tab.text()).toContain(ALERTS_STATUS_TABS[i].title);
expect(badges.at(i).text()).toContain(alertsCount[status]);
});
});
mock.restore();
});
describe('Alerts table', () => {
it('loading state', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: {}, alertsCount: null },
loading: true,
});
......@@ -144,8 +94,7 @@ describe('AlertManagementTable', () => {
it('error state', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { errors: ['error'] }, alertsCount: null, hasError: true },
data: { alerts: { errors: ['error'] }, alertsCount: null, errored: true },
loading: false,
});
expect(findAlertsTable().exists()).toBe(true);
......@@ -161,10 +110,17 @@ describe('AlertManagementTable', () => {
it('empty state', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: [], pageInfo: {} }, alertsCount: { all: 0 }, hasError: false },
data: {
alerts: { list: [], pageInfo: {} },
alertsCount: { all: 0 },
errored: false,
isErrorAlertDismissed: false,
searchTerm: '',
assigneeUsername: '',
},
loading: false,
});
expect(findAlertsTable().exists()).toBe(true);
expect(findAlertsTable().text()).toContain('No alerts to display');
expect(findLoader().exists()).toBe(false);
......@@ -178,8 +134,7 @@ describe('AlertManagementTable', () => {
it('has data state', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
expect(findLoader().exists()).toBe(false);
......@@ -194,8 +149,7 @@ describe('AlertManagementTable', () => {
it('displays the alert ID and title formatted correctly', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
......@@ -205,8 +159,7 @@ describe('AlertManagementTable', () => {
it('displays status dropdown', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
expect(findStatusDropdown().exists()).toBe(true);
......@@ -214,8 +167,7 @@ describe('AlertManagementTable', () => {
it('does not display a dropdown status header', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
expect(
......@@ -225,27 +177,25 @@ describe('AlertManagementTable', () => {
).toBe(false);
});
it('shows correct severity icons', () => {
it('shows correct severity icons', async () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find(GlTable).exists()).toBe(true);
expect(
findAlertsTable()
.find(GlIcon)
.classes('icon-critical'),
).toBe(true);
});
await wrapper.vm.$nextTick();
expect(wrapper.find(GlTable).exists()).toBe(true);
expect(
findAlertsTable()
.find(GlIcon)
.classes('icon-critical'),
).toBe(true);
});
it('renders severity text', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
......@@ -258,8 +208,7 @@ describe('AlertManagementTable', () => {
it('renders Unassigned when no assignee(s) present', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
......@@ -272,8 +221,7 @@ describe('AlertManagementTable', () => {
it('renders user avatar when assignee present', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
......@@ -290,8 +238,7 @@ describe('AlertManagementTable', () => {
it('navigates to the detail page when alert row is clicked', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
......@@ -305,8 +252,7 @@ describe('AlertManagementTable', () => {
it('navigates to the detail page in new tab when alert row is clicked with the metaKey', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
......@@ -324,8 +270,7 @@ describe('AlertManagementTable', () => {
describe('alert issue links', () => {
beforeEach(() => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
});
......@@ -355,7 +300,6 @@ describe('AlertManagementTable', () => {
describe('handle date fields', () => {
it('should display time ago dates when values provided', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: {
alerts: {
list: [
......@@ -369,7 +313,7 @@ describe('AlertManagementTable', () => {
],
},
alertsCount,
hasError: false,
errored: false,
},
loading: false,
});
......@@ -378,7 +322,6 @@ describe('AlertManagementTable', () => {
it('should not display time ago dates when values not provided', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: {
alerts: [
{
......@@ -389,7 +332,7 @@ describe('AlertManagementTable', () => {
},
],
alertsCount,
hasError: false,
errored: false,
},
loading: false,
});
......@@ -403,8 +346,7 @@ describe('AlertManagementTable', () => {
it('should highlight the row when alert is new', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: [newAlert] }, alertsCount, hasError: false },
data: { alerts: { list: [newAlert] }, alertsCount, errored: false },
loading: false,
});
......@@ -417,8 +359,7 @@ describe('AlertManagementTable', () => {
it('should not highlight the row when alert is not new', () => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: [oldAlert] }, alertsCount, hasError: false },
data: { alerts: { list: [oldAlert] }, alertsCount, errored: false },
loading: false,
});
......@@ -435,10 +376,9 @@ describe('AlertManagementTable', () => {
describe('sorting the alert list by column', () => {
beforeEach(() => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: {
alerts: { list: mockAlerts },
hasError: false,
errored: false,
sort: 'STARTED_AT_DESC',
alertsCount,
},
......@@ -458,184 +398,10 @@ describe('AlertManagementTable', () => {
});
});
describe('updating the alert status', () => {
const iid = '1527542';
const mockUpdatedMutationResult = {
data: {
updateAlertStatus: {
errors: [],
alert: {
iid,
status: 'acknowledged',
},
},
},
};
beforeEach(() => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false,
});
});
it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
findFirstStatusOption().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateAlertStatus,
variables: {
iid,
status: 'TRIGGERED',
projectPath: 'gitlab-org/gitlab',
},
});
});
describe('when a request fails', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
});
it('shows an error', async () => {
await selectFirstStatusOption();
expect(findAlertError().text()).toContain(
'There was an error while updating the status of the alert.',
);
});
it('shows an error when triggered a second time', async () => {
await selectFirstStatusOption();
wrapper.find(GlAlert).vm.$emit('dismiss');
await wrapper.vm.$nextTick();
// Assert that the error has been dismissed in the setup
expect(findAlertError().exists()).toBe(false);
await selectFirstStatusOption();
expect(findAlertError().exists()).toBe(true);
});
});
it('shows an error when response includes HTML errors', async () => {
const mockUpdatedMutationErrorResult = {
data: {
updateAlertStatus: {
errors: ['<span data-testid="htmlError" />'],
alert: {
iid,
status: 'acknowledged',
},
},
},
};
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationErrorResult);
await selectFirstStatusOption();
expect(findAlertError().exists()).toBe(true);
expect(
findAlertError()
.find('[data-testid="htmlError"]')
.exists(),
).toBe(true);
});
});
describe('Snowplow tracking', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount },
loading: false,
});
});
it('should track alert status updates', () => {
Tracking.event.mockClear();
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({});
findFirstStatusOption().vm.$emit('click');
const status = findFirstStatusOption().text();
setImmediate(() => {
const { category, action, label } = trackAlertStatusUpdateOptions;
expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status });
});
});
});
describe('Pagination', () => {
beforeEach(() => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts, pageInfo: {} }, alertsCount, hasError: false },
loading: false,
});
});
it('does NOT show pagination control when list is smaller than default page size', () => {
findStatusTabs().vm.$emit('input', 3);
return wrapper.vm.$nextTick(() => {
expect(findPagination().exists()).toBe(false);
});
});
it('shows pagination control when list is larger than default page size', () => {
findStatusTabs().vm.$emit('input', 0);
return wrapper.vm.$nextTick(() => {
expect(findPagination().exists()).toBe(true);
});
});
describe('prevPage', () => {
it('returns prevPage number', () => {
findPagination().vm.$emit('input', 3);
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.prevPage).toBe(2);
});
});
it('returns 0 when it is the first page', () => {
findPagination().vm.$emit('input', 1);
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.prevPage).toBe(0);
});
});
});
describe('nextPage', () => {
it('returns nextPage number', () => {
findPagination().vm.$emit('input', 1);
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.nextPage).toBe(2);
});
});
it('returns `null` when currentPage is already last page', () => {
findStatusTabs().vm.$emit('input', 3);
findPagination().vm.$emit('input', 1);
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.nextPage).toBeNull();
});
});
});
});
describe('Search', () => {
beforeEach(() => {
mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
loading: false,
});
});
......@@ -643,13 +409,5 @@ describe('AlertManagementTable', () => {
it('renders the search component', () => {
expect(findSearch().exists()).toBe(true);
});
it('sets the `searchTerm` graphql variable', () => {
const SEARCH_TERM = 'Simple Alert';
findSearch().vm.$emit('input', SEARCH_TERM);
expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import { trackAlertStatusUpdateOptions } from '~/alert_management/constants';
import AlertManagementStatus from '~/alert_management/components/alert_status.vue';
import updateAlertStatusMutation from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
import Tracking from '~/tracking';
import mockAlerts from '../mocks/alerts.json';
const mockAlert = mockAlerts[0];
describe('AlertManagementStatus', () => {
let wrapper;
const findStatusDropdown = () => wrapper.find(GlDropdown);
const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem);
const selectFirstStatusOption = () => {
findFirstStatusOption().vm.$emit('click');
return waitForPromises();
};
function mountComponent({ props = {}, loading = false, stubs = {} } = {}) {
wrapper = shallowMount(AlertManagementStatus, {
propsData: {
alert: { ...mockAlert },
projectPath: 'gitlab-org/gitlab',
isSidebar: false,
...props,
},
mocks: {
$apollo: {
mutate: jest.fn(),
queries: {
alert: {
loading,
},
},
},
},
stubs,
});
}
beforeEach(() => {
mountComponent();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
describe('updating the alert status', () => {
const iid = '1527542';
const mockUpdatedMutationResult = {
data: {
updateAlertStatus: {
errors: [],
alert: {
iid,
status: 'acknowledged',
},
},
},
};
beforeEach(() => {
mountComponent({});
});
it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult);
findFirstStatusOption().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateAlertStatusMutation,
variables: {
iid,
status: 'TRIGGERED',
projectPath: 'gitlab-org/gitlab',
},
});
});
describe('when a request fails', () => {
beforeEach(() => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
});
it('emits an error', async () => {
await selectFirstStatusOption();
expect(wrapper.emitted('alert-error')[0]).toEqual([
'There was an error while updating the status of the alert. Please try again.',
]);
});
it('emits an error when triggered a second time', async () => {
await selectFirstStatusOption();
await wrapper.vm.$nextTick();
await selectFirstStatusOption();
// Should emit two errors [0,1]
expect(wrapper.emitted('alert-error').length > 1).toBe(true);
});
});
it('shows an error when response includes HTML errors', async () => {
const mockUpdatedMutationErrorResult = {
data: {
updateAlertStatus: {
errors: ['<span data-testid="htmlError" />'],
alert: {
iid,
status: 'acknowledged',
},
},
},
};
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationErrorResult);
await selectFirstStatusOption();
expect(wrapper.emitted('alert-error').length > 0).toBe(true);
expect(wrapper.emitted('alert-error')[0]).toEqual([
'There was an error while updating the status of the alert. <span data-testid="htmlError" />',
]);
});
});
describe('Snowplow tracking', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
mountComponent({});
});
it('should track alert status updates', () => {
Tracking.event.mockClear();
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({});
findFirstStatusOption().vm.$emit('click');
const status = findFirstStatusOption().text();
setImmediate(() => {
const { category, action, label } = trackAlertStatusUpdateOptions;
expect(Tracking.event).toHaveBeenCalledWith(category, action, { label, property: status });
});
});
});
});
......@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { trackAlertStatusUpdateOptions } from '~/alert_management/constants';
import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue';
import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
import updateAlertStatusMutation from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
import Tracking from '~/tracking';
import mockAlerts from '../../mocks/alerts.json';
......@@ -85,7 +85,7 @@ describe('Alert Details Sidebar Status', () => {
findStatusDropdownItem().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateAlertStatus,
mutation: updateAlertStatusMutation,
variables: {
iid: '1527542',
status: 'TRIGGERED',
......
{
"textQuery": "foo",
"authorUsernameQuery": "root",
"assigneeUsernameQuery": "root",
"projectPath": "gitlab-org/gitlab",
"enableAlertManagementPath": "/link",
"populatingAlertsHelpUrl": "/link",
"emptyAlertSvgPath": "/link",
"alertManagementEnabled": false,
"userCanEnableAlertManagement": false,
"opsgenieMvcTargetUrl": "/link",
"opsgenieMvcEnabled": false
}
\ No newline at end of file
import { mount } from '@vue/test-utils';
import {
GlAlert,
GlLoadingIcon,
GlTable,
GlAvatar,
GlPagination,
GlTab,
GlTabs,
GlBadge,
GlEmptyState,
} from '@gitlab/ui';
import { GlAlert, GlLoadingIcon, GlTable, GlAvatar, GlEmptyState } from '@gitlab/ui';
import Tracking from '~/tracking';
import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility';
import IncidentsList from '~/incidents/components/incidents_list.vue';
import SeverityToken from '~/sidebar/components/severity/severity.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import {
I18N,
INCIDENT_STATUS_TABS,
TH_CREATED_AT_TEST_ID,
TH_SEVERITY_TEST_ID,
TH_PUBLISHED_TEST_ID,
trackIncidentCreateNewOptions,
} from '~/incidents/constants';
import mockIncidents from '../mocks/incidents.json';
import mockFilters from '../mocks/incidents_filter.json';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
......@@ -54,15 +39,10 @@ describe('Incidents List', () => {
const findAlert = () => wrapper.find(GlAlert);
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 findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]');
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
const findPagination = () => wrapper.find(GlPagination);
const findStatusFilterTabs = () => wrapper.findAll(GlTab);
const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
const findStatusTabs = () => wrapper.find(GlTabs);
const findEmptyState = () => wrapper.find(GlEmptyState);
const findSeverity = () => wrapper.findAll(SeverityToken);
const findIncidentSla = () => wrapper.findAll("[data-testid='incident-sla']");
......@@ -94,8 +74,8 @@ describe('Incidents List', () => {
publishedAvailable: true,
emptyListSvgPath,
textQuery: '',
authorUsernamesQuery: '',
assigneeUsernamesQuery: '',
authorUsernameQuery: '',
assigneeUsernameQuery: '',
slaFeatureAvailable: true,
...provide,
},
......@@ -275,204 +255,10 @@ describe('Incidents List', () => {
expect(findCreateIncidentBtn().exists()).toBe(false);
});
it('should track alert list page views', async () => {
it('should track create new incident button', async () => {
findCreateIncidentBtn().vm.$emit('click');
await wrapper.vm.$nextTick();
const { category, action } = trackIncidentCreateNewOptions;
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
});
describe('Pagination', () => {
beforeEach(() => {
mountComponent({
data: {
incidents: {
list: mockIncidents,
pageInfo: { hasNextPage: true, hasPreviousPage: true },
},
incidentsCount,
errored: false,
},
loading: false,
});
});
it('should render pagination', () => {
expect(wrapper.find(GlPagination).exists()).toBe(true);
});
describe('prevPage', () => {
it('returns prevPage button', async () => {
findPagination().vm.$emit('input', 3);
await wrapper.vm.$nextTick();
expect(
findPagination()
.findAll('.page-item')
.at(0)
.text(),
).toBe('Prev');
});
it('returns prevPage number', async () => {
findPagination().vm.$emit('input', 3);
await wrapper.vm.$nextTick();
expect(wrapper.vm.prevPage).toBe(2);
});
it('returns 0 when it is the first page', async () => {
findPagination().vm.$emit('input', 1);
await wrapper.vm.$nextTick();
expect(wrapper.vm.prevPage).toBe(0);
});
});
describe('nextPage', () => {
it('returns nextPage button', async () => {
findPagination().vm.$emit('input', 3);
await wrapper.vm.$nextTick();
expect(
findPagination()
.findAll('.page-item')
.at(1)
.text(),
).toBe('Next');
});
it('returns nextPage number', async () => {
mountComponent({
data: {
incidents: {
list: [...mockIncidents, ...mockIncidents, ...mockIncidents],
pageInfo: { hasNextPage: true, hasPreviousPage: true },
},
incidentsCount,
errored: false,
},
loading: false,
});
findPagination().vm.$emit('input', 1);
await wrapper.vm.$nextTick();
expect(wrapper.vm.nextPage).toBe(2);
});
it('returns `null` when currentPage is already last page', async () => {
findStatusTabs().vm.$emit('input', 1);
findPagination().vm.$emit('input', 1);
await wrapper.vm.$nextTick();
expect(wrapper.vm.nextPage).toBeNull();
});
});
describe('Filtered search component', () => {
beforeEach(() => {
mountComponent({
data: {
incidents: {
list: mockIncidents,
pageInfo: { hasNextPage: true, hasPreviousPage: true },
},
incidentsCount,
errored: false,
},
loading: false,
});
});
it('renders the search component for incidents', () => {
expect(findSearch().props('searchInputPlaceholder')).toBe('Search or filter results…');
expect(findSearch().props('tokens')).toEqual([
{
type: 'author_username',
icon: 'user',
title: 'Author',
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchPath: '/project/path',
fetchAuthors: expect.any(Function),
},
{
type: 'assignee_username',
icon: 'user',
title: 'Assignees',
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchPath: '/project/path',
fetchAuthors: expect.any(Function),
},
]);
expect(findSearch().props('recentSearchesStorageKey')).toBe('incidents');
});
it('returns correctly applied filter search values', async () => {
const searchTerm = 'foo';
wrapper.setData({
searchTerm,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]);
});
it('updates props tied to getIncidents GraphQL query', () => {
wrapper.vm.handleFilterIncidents(mockFilters);
expect(wrapper.vm.authorUsername).toBe('root');
expect(wrapper.vm.assigneeUsernames).toEqual('root2');
expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data);
});
it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => {
wrapper.setData({
authorUsername: 'foo',
searchTerm: 'bar',
});
wrapper.vm.handleFilterIncidents([]);
expect(wrapper.vm.authorUsername).toBe('');
expect(wrapper.vm.searchTerm).toBe('');
});
});
describe('Status Filter Tabs', () => {
beforeEach(() => {
mountComponent({
data: { incidents: { list: mockIncidents }, incidentsCount },
loading: false,
stubs: {
GlTab: true,
},
});
});
it('should display filter tabs', () => {
const tabs = findStatusFilterTabs().wrappers;
tabs.forEach((tab, i) => {
expect(tab.attributes('data-testid')).toContain(INCIDENT_STATUS_TABS[i].status);
});
});
it('should display filter tabs with alerts count badge for each status', () => {
const tabs = findStatusFilterTabs().wrappers;
const badges = findStatusFilterBadge();
tabs.forEach((tab, i) => {
const status = INCIDENT_STATUS_TABS[i].status.toLowerCase();
expect(tab.attributes('data-testid')).toContain(INCIDENT_STATUS_TABS[i].status);
expect(badges.at(i).text()).toContain(incidentsCount[status]);
});
});
expect(Tracking.event).toHaveBeenCalled();
});
});
......
[
{
"iid": "1527542",
"title": "SyntaxError: Invalid or unexpected token",
"createdAt": "2020-04-17T23:18:14.996Z",
"assignees": { "nodes": [] }
},
{
"iid": "1527543",
"title": "SyntaxError: Invalid or unexpected token by root",
"createdAt": "2020-04-17T23:19:14.996Z",
"assignees": { "nodes": [] }
}
]
\ No newline at end of file
import { mount } from '@vue/test-utils';
import { GlAlert, GlBadge, GlPagination, GlTabs, GlTab } from '@gitlab/ui';
import PageWrapper from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import Tracking from '~/tracking';
import mockItems from './mocks/items.json';
import mockFilters from './mocks/items_filters.json';
const EmptyStateSlot = {
template: '<div class="empty-state">Empty State</div>',
};
const HeaderActionsSlot = {
template: '<div class="header-actions"><button>Action Button</button></div>',
};
const TitleSlot = {
template: '<div>Page Wrapper Title</div>',
};
const TableSlot = {
template: '<table class="gl-table"></table>',
};
const itemsCount = {
opened: 24,
closed: 10,
all: 34,
};
const ITEMS_STATUS_TABS = [
{
title: 'Opened items',
status: 'OPENED',
filters: ['opened'],
},
{
title: 'Closed items',
status: 'CLOSED',
filters: ['closed'],
},
{
title: 'All items',
status: 'ALL',
filters: ['all'],
},
];
describe('AlertManagementEmptyState', () => {
let wrapper;
function mountComponent({ props = {} } = {}) {
wrapper = mount(PageWrapper, {
provide: {
projectPath: '/link',
},
propsData: {
items: [],
itemsCount: {},
pageInfo: {},
statusTabs: [],
loading: false,
showItems: false,
showErrorMsg: false,
trackViewsOptions: {},
i18n: {},
serverErrorMessage: '',
filterSearchKey: '',
...props,
},
slots: {
'emtpy-state': EmptyStateSlot,
'header-actions': HeaderActionsSlot,
title: TitleSlot,
table: TableSlot,
},
});
}
beforeEach(() => {
mountComponent();
});
afterEach(() => {
if (wrapper) {
wrapper.destroy();
}
});
const EmptyState = () => wrapper.find('.empty-state');
const ItemsTable = () => wrapper.find('.gl-table');
const ErrorAlert = () => wrapper.find(GlAlert);
const Pagination = () => wrapper.find(GlPagination);
const Tabs = () => wrapper.find(GlTabs);
const ActionButton = () => wrapper.find('.header-actions > button');
const Filters = () => wrapper.find(FilteredSearchBar);
const findPagination = () => wrapper.find(GlPagination);
const findStatusFilterTabs = () => wrapper.findAll(GlTab);
const findStatusTabs = () => wrapper.find(GlTabs);
const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
describe('Snowplow tracking', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
mountComponent({
props: { trackViewsOptions: { category: 'category', action: 'action' } },
});
});
it('should track the items list page views', () => {
const { category, action } = wrapper.vm.trackViewsOptions;
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
});
describe('Page wrapper with no items', () => {
it('renders the empty state if there are no items present', () => {
expect(EmptyState().exists()).toBe(true);
});
});
describe('Page wrapper with items', () => {
it('renders the tabs selection with valid tabs', () => {
mountComponent({
props: {
statusTabs: [{ status: 'opened', title: 'Open' }, { status: 'closed', title: 'Closed' }],
},
});
expect(Tabs().exists()).toBe(true);
});
it('renders the header action buttons if present', () => {
expect(ActionButton().exists()).toBe(true);
});
it('renders a error alert if there are errors', () => {
mountComponent({
props: { showErrorMsg: true },
});
expect(ErrorAlert().exists()).toBe(true);
});
it('renders a table of items if items are present', () => {
mountComponent({
props: { showItems: true, items: mockItems },
});
expect(ItemsTable().exists()).toBe(true);
});
it('renders pagination if there the pagination info object has a next or previous page', () => {
mountComponent({
props: { pageInfo: { hasNextPage: true } },
});
expect(Pagination().exists()).toBe(true);
});
it('renders the filter set with the tokens according to the prop filterSearchTokens', () => {
mountComponent({
props: { filterSearchTokens: ['assignee_username'] },
});
expect(Filters().exists()).toBe(true);
});
});
describe('Status Filter Tabs', () => {
beforeEach(() => {
mountComponent({
props: { items: mockItems, itemsCount, statusTabs: ITEMS_STATUS_TABS },
});
});
it('should display filter tabs', () => {
const tabs = findStatusFilterTabs().wrappers;
tabs.forEach((tab, i) => {
expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status);
});
});
it('should display filter tabs with items count badge for each status', () => {
const tabs = findStatusFilterTabs().wrappers;
const badges = findStatusFilterBadge();
tabs.forEach((tab, i) => {
const status = ITEMS_STATUS_TABS[i].status.toLowerCase();
expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status);
expect(badges.at(i).text()).toContain(itemsCount[status]);
});
});
});
describe('Pagination', () => {
beforeEach(() => {
mountComponent({
props: {
items: mockItems,
itemsCount,
statusTabs: ITEMS_STATUS_TABS,
pageInfo: { hasNextPage: true },
},
});
});
it('should render pagination', () => {
expect(wrapper.find(GlPagination).exists()).toBe(true);
});
describe('prevPage', () => {
it('returns prevPage button', async () => {
findPagination().vm.$emit('input', 3);
await wrapper.vm.$nextTick();
expect(
findPagination()
.findAll('.page-item')
.at(0)
.text(),
).toBe('Prev');
});
it('returns prevPage number', async () => {
findPagination().vm.$emit('input', 3);
await wrapper.vm.$nextTick();
expect(wrapper.vm.previousPage).toBe(2);
});
it('returns 0 when it is the first page', async () => {
findPagination().vm.$emit('input', 1);
await wrapper.vm.$nextTick();
expect(wrapper.vm.previousPage).toBe(0);
});
});
describe('nextPage', () => {
it('returns nextPage button', async () => {
findPagination().vm.$emit('input', 3);
await wrapper.vm.$nextTick();
expect(
findPagination()
.findAll('.page-item')
.at(1)
.text(),
).toBe('Next');
});
it('returns nextPage number', async () => {
mountComponent({
props: {
items: mockItems,
itemsCount,
statusTabs: ITEMS_STATUS_TABS,
pageInfo: { hasNextPage: true },
},
});
findPagination().vm.$emit('input', 1);
await wrapper.vm.$nextTick();
expect(wrapper.vm.nextPage).toBe(2);
});
it('returns `null` when currentPage is already last page', async () => {
findStatusTabs().vm.$emit('input', 1);
findPagination().vm.$emit('input', 1);
await wrapper.vm.$nextTick();
expect(wrapper.vm.nextPage).toBeNull();
});
});
});
describe('Filtered search component', () => {
beforeEach(() => {
mountComponent({
props: {
items: mockItems,
itemsCount,
statusTabs: ITEMS_STATUS_TABS,
filterSearchKey: 'items',
},
});
});
it('renders the search component for incidents', () => {
expect(Filters().props('searchInputPlaceholder')).toBe('Search or filter results…');
expect(Filters().props('tokens')).toEqual([
{
type: 'author_username',
icon: 'user',
title: 'Author',
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchPath: '/link',
fetchAuthors: expect.any(Function),
},
{
type: 'assignee_username',
icon: 'user',
title: 'Assignee',
unique: true,
symbol: '@',
token: AuthorToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchPath: '/link',
fetchAuthors: expect.any(Function),
},
]);
expect(Filters().props('recentSearchesStorageKey')).toBe('items');
});
it('returns correctly applied filter search values', async () => {
const searchTerm = 'foo';
wrapper.setData({
searchTerm,
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]);
});
it('updates props tied to getIncidents GraphQL query', () => {
wrapper.vm.handleFilterItems(mockFilters);
expect(wrapper.vm.authorUsername).toBe('root');
expect(wrapper.vm.assigneeUsername).toEqual('root2');
expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data);
});
it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => {
wrapper.setData({
authorUsername: 'foo',
searchTerm: 'bar',
});
wrapper.vm.handleFilterItems([]);
expect(wrapper.vm.authorUsername).toBe('');
expect(wrapper.vm.searchTerm).toBe('');
});
});
});
......@@ -32,7 +32,9 @@ RSpec.describe Projects::AlertManagementHelper do
'populating-alerts-help-url' => 'http://test.host/help/operations/incident_management/index.md#enable-alert-management',
'empty-alert-svg-path' => match_asset_path('/assets/illustrations/alert-management-empty-state.svg'),
'user-can-enable-alert-management' => 'true',
'alert-management-enabled' => 'false'
'alert-management-enabled' => 'false',
'text-query': nil,
'assignee-username-query': nil
)
end
end
......
......@@ -29,8 +29,8 @@ RSpec.describe Projects::IncidentsHelper do
'issue-path' => issue_path,
'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'),
'text-query': 'search text',
'author-usernames-query': 'root',
'assignee-usernames-query': 'max.power'
'author-username-query': 'root',
'assignee-username-query': 'max.power'
)
end
end
......
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