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 { ...@@ -33,30 +33,13 @@ export default {
query: alertsHelpUrlQuery, query: alertsHelpUrlQuery,
}, },
}, },
props: { inject: [
enableAlertManagementPath: { 'enableAlertManagementPath',
type: String, 'userCanEnableAlertManagement',
required: true, 'emptyAlertSvgPath',
}, 'opsgenieMvcEnabled',
userCanEnableAlertManagement: { 'opsgenieMvcTargetUrl',
type: Boolean, ],
required: true,
},
emptyAlertSvgPath: {
type: String,
required: true,
},
opsgenieMvcEnabled: {
type: Boolean,
required: false,
default: false,
},
opsgenieMvcTargetUrl: {
type: String,
required: false,
default: '',
},
},
data() { data() {
return { return {
alertsHelpUrl: '', alertsHelpUrl: '',
......
<script> <script>
import Tracking from '~/tracking';
import { trackAlertListViewsOptions } from '../constants';
import AlertManagementEmptyState from './alert_management_empty_state.vue'; import AlertManagementEmptyState from './alert_management_empty_state.vue';
import AlertManagementTable from './alert_management_table.vue'; import AlertManagementTable from './alert_management_table.vue';
...@@ -9,67 +7,12 @@ export default { ...@@ -9,67 +7,12 @@ export default {
AlertManagementEmptyState, AlertManagementEmptyState,
AlertManagementTable, AlertManagementTable,
}, },
props: { inject: ['alertManagementEnabled'],
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);
},
},
}; };
</script> </script>
<template> <template>
<div> <div>
<alert-management-table <alert-management-table v-if="alertManagementEnabled" />
v-if="alertManagementEnabled" <alert-management-empty-state v-else />
: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"
/>
</div> </div>
</template> </template>
<script> <script>
/* eslint-disable vue/no-v-html */
import { import {
GlAlert,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
GlAlert,
GlAvatarsInline, GlAvatarsInline,
GlAvatarLink, GlAvatarLink,
GlAvatar, GlAvatar,
GlIcon, GlIcon,
GlLink, GlLink,
GlTabs,
GlTab,
GlBadge,
GlPagination,
GlSearchBoxByType,
GlSprintf, GlSprintf,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { debounce, trim } from 'lodash'; import { s__, __ } from '~/locale';
import { __, s__ } from '~/locale';
import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
import { fetchPolicies } from '~/lib/graphql'; 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 TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { convertToSnakeCase } from '~/lib/utils/text_utility';
import Tracking from '~/tracking';
import getAlerts from '../graphql/queries/get_alerts.query.graphql'; import getAlerts from '../graphql/queries/get_alerts.query.graphql';
import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import getAlertsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import { import {
ALERTS_STATUS_TABS, ALERTS_STATUS_TABS,
ALERTS_SEVERITY_LABELS, ALERTS_SEVERITY_LABELS,
DEFAULT_PAGE_SIZE,
trackAlertListViewsOptions, trackAlertListViewsOptions,
trackAlertStatusUpdateOptions,
} from '../constants'; } from '../constants';
import AlertStatus from './alert_status.vue'; 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 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; const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000;
export default { export default {
trackAlertListViewsOptions,
i18n: { i18n: {
noAlertsMsg: s__( noAlertsMsg: s__(
'AlertManagement|No alerts available to display. See %{linkStart}enabling alert management%{linkEnd} for more information on adding alerts to the list.', '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 { ...@@ -60,7 +45,6 @@ export default {
errorMsg: s__( errorMsg: s__(
"AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.", "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'), unassigned: __('Unassigned'),
}, },
fields: [ fields: [
...@@ -115,36 +99,23 @@ export default { ...@@ -115,36 +99,23 @@ export default {
severityLabels: ALERTS_SEVERITY_LABELS, severityLabels: ALERTS_SEVERITY_LABELS,
statusTabs: ALERTS_STATUS_TABS, statusTabs: ALERTS_STATUS_TABS,
components: { components: {
GlAlert,
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
GlAlert,
GlAvatarsInline, GlAvatarsInline,
GlAvatarLink, GlAvatarLink,
GlAvatar, GlAvatar,
TimeAgo, TimeAgo,
GlIcon, GlIcon,
GlLink, GlLink,
GlTabs,
GlTab,
GlBadge,
GlPagination,
GlSearchBoxByType,
GlSprintf, GlSprintf,
AlertStatus, AlertStatus,
PaginatedTableWithSearchAndTabs,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
props: { inject: ['projectPath', 'textQuery', 'assigneeUsernameQuery', 'populatingAlertsHelpUrl'],
projectPath: {
type: String,
required: true,
},
populatingAlertsHelpUrl: {
type: String,
required: true,
},
},
apollo: { apollo: {
alerts: { alerts: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
...@@ -152,6 +123,7 @@ export default { ...@@ -152,6 +123,7 @@ export default {
variables() { variables() {
return { return {
searchTerm: this.searchTerm, searchTerm: this.searchTerm,
assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath, projectPath: this.projectPath,
statuses: this.statusFilter, statuses: this.statusFilter,
sort: this.sort, sort: this.sort,
...@@ -182,14 +154,16 @@ export default { ...@@ -182,14 +154,16 @@ export default {
}; };
}, },
error() { error() {
this.hasError = true; this.errored = true;
}, },
}, },
alertsCount: { alertsCount: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getAlertsCountByStatus, query: getAlertsCountByStatus,
variables() { variables() {
return { return {
searchTerm: this.searchTerm, searchTerm: this.searchTerm,
assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath, projectPath: this.projectPath,
}; };
}, },
...@@ -200,174 +174,128 @@ export default { ...@@ -200,174 +174,128 @@ export default {
}, },
data() { data() {
return { return {
searchTerm: '', errored: false,
hasError: false, serverErrorMessage: '',
errorMessage: '', isErrorAlertDismissed: false,
isAlertDismissed: false,
sort: 'STARTED_AT_DESC', sort: 'STARTED_AT_DESC',
statusFilter: [], statusFilter: [],
filteredByStatus: '', filteredByStatus: '',
pagination: initialPaginationState, alerts: {},
alertsCount: {},
sortBy: 'startedAt', sortBy: 'startedAt',
sortDesc: true, sortDesc: true,
sortDirection: 'desc', sortDirection: 'desc',
searchTerm: this.textQuery,
assigneeUsername: this.assigneeUsernameQuery,
pagination: initialPaginationState,
}; };
}, },
computed: { computed: {
showErrorMsg() {
return this.errored && !this.isErrorAlertDismissed;
},
showNoAlertsMsg() { showNoAlertsMsg() {
return ( return (
!this.hasError && !this.errored &&
!this.loading && !this.loading &&
this.alertsCount?.all === 0 && this.alertsCount?.all === 0 &&
!this.searchTerm && !this.searchTerm &&
!this.isAlertDismissed !this.assigneeUsername &&
!this.isErrorAlertDismissed
); );
}, },
loading() { loading() {
return this.$apollo.queries.alerts.loading; return this.$apollo.queries.alerts.loading;
}, },
hasAlerts() { isEmpty() {
return this.alerts?.list?.length; 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;
},
},
mounted() {
this.trackPageViews();
}, },
methods: { methods: {
filterAlertsByStatus(tabIndex) {
this.resetPagination();
const { filters, status } = this.$options.statusTabs[tabIndex];
this.statusFilter = filters;
this.filteredByStatus = status;
},
fetchSortedData({ sortBy, sortDesc }) { fetchSortedData({ sortBy, sortDesc }) {
const sortingDirection = sortDesc ? 'DESC' : 'ASC'; const sortingDirection = sortDesc ? 'DESC' : 'ASC';
const sortingColumn = convertToSnakeCase(sortBy).toUpperCase(); const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
this.resetPagination(); this.pagination = initialPaginationState;
this.sort = `${sortingColumn}_${sortingDirection}`; 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 }) { navigateToAlertDetails({ iid }, index, { metaKey }) {
return visitUrl(joinPaths(window.location.pathname, iid, 'details'), 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) { hasAssignees(assignees) {
return Boolean(assignees.nodes?.length); return Boolean(assignees.nodes?.length);
}, },
getIssueLink(item) { getIssueLink(item) {
return joinPaths('/', this.projectPath, '-', 'issues', item.issueIid); 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) { tbodyTrClass(item) {
return { return {
[bodyTrClass]: !this.loading && this.hasAlerts, [bodyTrClass]: !this.loading && !this.isEmpty,
'new-alert': item?.isNew, 'new-alert': item?.isNew,
}; };
}, },
handleAlertError(errorMessage) { handleAlertError(errorMessage) {
this.hasError = true; this.errored = true;
this.errorMessage = errorMessage; this.serverErrorMessage = 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;
}, },
dismissError() { errorAlertDismissed() {
this.hasError = false; this.errored = false;
this.errorMessage = ''; this.serverErrorMessage = '';
this.isErrorAlertDismissed = true;
}, },
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<div class="incident-management-list"> <gl-alert v-if="showNoAlertsMsg" @dismiss="errorAlertDismissed">
<gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true">
<gl-sprintf :message="$options.i18n.noAlertsMsg"> <gl-sprintf :message="$options.i18n.noAlertsMsg">
<template #link="{ content }"> <template #link="{ content }">
<gl-link <gl-link class="gl-display-inline-block" :href="populatingAlertsHelpUrl" target="_blank">
class="gl-display-inline-block"
:href="populatingAlertsHelpUrl"
target="_blank"
>
{{ content }} {{ content }}
</gl-link> </gl-link>
</template> </template>
</gl-sprintf> </gl-sprintf>
</gl-alert> </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 <paginated-table-with-search-and-tabs
content-class="gl-p-0 gl-border-b-solid gl-border-b-1 gl-border-gray-100" :show-error-msg="showErrorMsg"
@input="filterAlertsByStatus" :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"
> >
<gl-tab v-for="tab in $options.statusTabs" :key="tab.status"> <template #header-actions></template>
<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>
<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>
<h4 class="d-block d-md-none my-3"> <template #title>
{{ s__('AlertManagement|Alerts') }} {{ s__('AlertManagement|Alerts') }}
</h4> </template>
<template #table>
<gl-table <gl-table
class="alert-management-table" class="alert-management-table"
:items="alerts ? alerts.list : []" :items="alerts ? alerts.list : []"
...@@ -461,6 +389,7 @@ export default { ...@@ -461,6 +389,7 @@ export default {
:project-path="projectPath" :project-path="projectPath"
:is-sidebar="false" :is-sidebar="false"
@alert-error="handleAlertError" @alert-error="handleAlertError"
@hide-dropdown="handleStatusUpdate"
/> />
</template> </template>
...@@ -472,16 +401,7 @@ export default { ...@@ -472,16 +401,7 @@ export default {
<gl-loading-icon size="lg" color="dark" class="mt-3" /> <gl-loading-icon size="lg" color="dark" class="mt-3" />
</template> </template>
</gl-table> </gl-table>
</template>
<gl-pagination </paginated-table-with-search-and-tabs>
v-if="showPaginationControls"
:value="pagination.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
</div>
</div> </div>
</template> </template>
...@@ -3,7 +3,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; ...@@ -3,7 +3,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { trackAlertStatusUpdateOptions } from '../constants'; 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 { export default {
i18n: { i18n: {
...@@ -50,7 +50,7 @@ export default { ...@@ -50,7 +50,7 @@ export default {
this.$emit('handle-updating', true); this.$emit('handle-updating', true);
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: updateAlertStatus, mutation: updateAlertStatusMutation,
variables: { variables: {
iid: this.alert.iid, iid: this.alert.iid,
status: status.toUpperCase(), status: status.toUpperCase(),
...@@ -59,8 +59,6 @@ export default { ...@@ -59,8 +59,6 @@ export default {
}) })
.then(resp => { .then(resp => {
this.trackStatusUpdate(status); this.trackStatusUpdate(status);
this.$emit('hide-dropdown');
const errors = resp.data?.updateAlertStatus?.errors || []; const errors = resp.data?.updateAlertStatus?.errors || [];
if (errors[0]) { if (errors[0]) {
...@@ -69,6 +67,8 @@ export default { ...@@ -69,6 +67,8 @@ export default {
`${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`, `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`,
); );
} }
this.$emit('hide-dropdown');
}) })
.catch(() => { .catch(() => {
this.$emit( this.$emit(
......
...@@ -63,5 +63,3 @@ export const trackAlertStatusUpdateOptions = { ...@@ -63,5 +63,3 @@ export const trackAlertStatusUpdateOptions = {
action: 'update_alert_status', action: 'update_alert_status',
label: 'Status', label: 'Status',
}; };
export const DEFAULT_PAGE_SIZE = 20;
#import "../fragments/list_item.fragment.graphql" #import "../fragments/list_item.fragment.graphql"
query getAlerts( query getAlerts(
$searchTerm: String
$projectPath: ID! $projectPath: ID!
$statuses: [AlertManagementStatus!] $statuses: [AlertManagementStatus!]
$sort: AlertManagementAlertSort $sort: AlertManagementAlertSort
...@@ -9,10 +8,13 @@ query getAlerts( ...@@ -9,10 +8,13 @@ query getAlerts(
$lastPageSize: Int $lastPageSize: Int
$prevPageCursor: String = "" $prevPageCursor: String = ""
$nextPageCursor: String = "" $nextPageCursor: String = ""
$searchTerm: String = ""
$assigneeUsername: String = ""
) { ) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
alertManagementAlerts( alertManagementAlerts(
search: $searchTerm search: $searchTerm
assigneeUsername: $assigneeUsername
statuses: $statuses statuses: $statuses
sort: $sort sort: $sort
first: $firstPageSize first: $firstPageSize
......
query getAlertsCount($searchTerm: String, $projectPath: ID!) { query getAlertsCount($searchTerm: String, $projectPath: ID!, $assigneeUsername: String = "") {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
alertManagementAlertStatusCounts(search: $searchTerm) { alertManagementAlertStatusCounts(search: $searchTerm, assigneeUsername: $assigneeUsername) {
all all
open open
acknowledged acknowledged
......
...@@ -18,12 +18,12 @@ export default () => { ...@@ -18,12 +18,12 @@ export default () => {
populatingAlertsHelpUrl, populatingAlertsHelpUrl,
alertsHelpUrl, alertsHelpUrl,
opsgenieMvcTargetUrl, opsgenieMvcTargetUrl,
textQuery,
assigneeUsernameQuery,
alertManagementEnabled,
userCanEnableAlertManagement,
opsgenieMvcEnabled,
} = domEl.dataset; } = domEl.dataset;
let { alertManagementEnabled, userCanEnableAlertManagement, opsgenieMvcEnabled } = domEl.dataset;
alertManagementEnabled = parseBoolean(alertManagementEnabled);
userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement);
opsgenieMvcEnabled = parseBoolean(opsgenieMvcEnabled);
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient( defaultClient: createDefaultClient(
...@@ -50,23 +50,24 @@ export default () => { ...@@ -50,23 +50,24 @@ export default () => {
return new Vue({ return new Vue({
el: selector, el: selector,
apolloProvider, provide: {
components: {
AlertManagementList,
},
render(createElement) {
return createElement('alert-management-list', {
props: {
projectPath, projectPath,
textQuery,
assigneeUsernameQuery,
enableAlertManagementPath, enableAlertManagementPath,
populatingAlertsHelpUrl, populatingAlertsHelpUrl,
emptyAlertSvgPath, emptyAlertSvgPath,
alertManagementEnabled,
userCanEnableAlertManagement,
opsgenieMvcTargetUrl, opsgenieMvcTargetUrl,
opsgenieMvcEnabled, alertManagementEnabled: parseBoolean(alertManagementEnabled),
userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement),
opsgenieMvcEnabled: parseBoolean(opsgenieMvcEnabled),
}, },
}); apolloProvider,
components: {
AlertManagementList,
},
render(createElement) {
return createElement('alert-management-list');
}, },
}); });
}; };
...@@ -2,41 +2,32 @@ ...@@ -2,41 +2,32 @@
import { import {
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
GlAlert,
GlAvatarsInline, GlAvatarsInline,
GlAvatarLink, GlAvatarLink,
GlAvatar, GlAvatar,
GlTooltipDirective, GlTooltipDirective,
GlButton, GlButton,
GlIcon, GlIcon,
GlPagination,
GlTabs,
GlTab,
GlBadge,
GlEmptyState, GlEmptyState,
} from '@gitlab/ui'; } from '@gitlab/ui';
import Api from '~/api';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.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 PaginatedTableWithSearchAndTabs from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.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 { import {
visitUrl, tdClass,
mergeUrlParams, thClass,
joinPaths, bodyTrClass,
updateHistory, initialPaginationState,
setUrlParams, } from '~/vue_shared/components/paginated_table_with_search_and_tabs/constants';
} from '~/lib/utils/url_utility'; 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 getIncidents from '../graphql/queries/get_incidents.query.graphql';
import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql';
import SeverityToken from '~/sidebar/components/severity/severity.vue'; import SeverityToken from '~/sidebar/components/severity/severity.vue';
import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants';
import { import {
I18N, I18N,
DEFAULT_PAGE_SIZE,
INCIDENT_STATUS_TABS, INCIDENT_STATUS_TABS,
TH_CREATED_AT_TEST_ID, TH_CREATED_AT_TEST_ID,
TH_INCIDENT_SLA_TEST_ID, TH_INCIDENT_SLA_TEST_ID,
...@@ -44,24 +35,12 @@ import { ...@@ -44,24 +35,12 @@ import {
TH_PUBLISHED_TEST_ID, TH_PUBLISHED_TEST_ID,
INCIDENT_DETAILS_PATH, INCIDENT_DETAILS_PATH,
trackIncidentCreateNewOptions, trackIncidentCreateNewOptions,
trackIncidentListViewsOptions,
} from '../constants'; } 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 { export default {
trackIncidentCreateNewOptions, trackIncidentCreateNewOptions,
trackIncidentListViewsOptions,
i18n: I18N, i18n: I18N,
statusTabs: INCIDENT_STATUS_TABS, statusTabs: INCIDENT_STATUS_TABS,
fields: [ fields: [
...@@ -112,23 +91,18 @@ export default { ...@@ -112,23 +91,18 @@ export default {
components: { components: {
GlLoadingIcon, GlLoadingIcon,
GlTable, GlTable,
GlAlert,
GlAvatarsInline, GlAvatarsInline,
GlAvatarLink, GlAvatarLink,
GlAvatar, GlAvatar,
GlButton, GlButton,
TimeAgoTooltip, TimeAgoTooltip,
GlIcon, GlIcon,
GlPagination,
GlTabs,
GlTab,
PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'), PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'),
ServiceLevelAgreementCell: () => ServiceLevelAgreementCell: () =>
import('ee_component/incidents/components/service_level_agreement_cell.vue'), import('ee_component/incidents/components/service_level_agreement_cell.vue'),
GlBadge,
GlEmptyState, GlEmptyState,
SeverityToken, SeverityToken,
FilteredSearchBar, PaginatedTableWithSearchAndTabs,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -142,8 +116,8 @@ export default { ...@@ -142,8 +116,8 @@ export default {
'publishedAvailable', 'publishedAvailable',
'emptyListSvgPath', 'emptyListSvgPath',
'textQuery', 'textQuery',
'authorUsernamesQuery', 'authorUsernameQuery',
'assigneeUsernamesQuery', 'assigneeUsernameQuery',
'slaFeatureAvailable', 'slaFeatureAvailable',
], ],
apollo: { apollo: {
...@@ -152,16 +126,16 @@ export default { ...@@ -152,16 +126,16 @@ export default {
variables() { variables() {
return { return {
searchTerm: this.searchTerm, searchTerm: this.searchTerm,
status: this.statusFilter, authorUsername: this.authorUsername,
assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath, projectPath: this.projectPath,
status: this.statusFilter,
issueTypes: ['INCIDENT'], issueTypes: ['INCIDENT'],
sort: this.sort, sort: this.sort,
firstPageSize: this.pagination.firstPageSize, firstPageSize: this.pagination.firstPageSize,
lastPageSize: this.pagination.lastPageSize, lastPageSize: this.pagination.lastPageSize,
prevPageCursor: this.pagination.prevPageCursor, prevPageCursor: this.pagination.prevPageCursor,
nextPageCursor: this.pagination.nextPageCursor, nextPageCursor: this.pagination.nextPageCursor,
authorUsername: this.authorUsername,
assigneeUsernames: this.assigneeUsernames,
}; };
}, },
update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) { update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) {
...@@ -180,7 +154,7 @@ export default { ...@@ -180,7 +154,7 @@ export default {
return { return {
searchTerm: this.searchTerm, searchTerm: this.searchTerm,
authorUsername: this.authorUsername, authorUsername: this.authorUsername,
assigneeUsernames: this.assigneeUsernames, assigneeUsername: this.assigneeUsername,
projectPath: this.projectPath, projectPath: this.projectPath,
issueTypes: ['INCIDENT'], issueTypes: ['INCIDENT'],
}; };
...@@ -195,17 +169,17 @@ export default { ...@@ -195,17 +169,17 @@ export default {
errored: false, errored: false,
isErrorAlertDismissed: false, isErrorAlertDismissed: false,
redirecting: false, redirecting: false,
searchTerm: this.textQuery,
pagination: initialPaginationState,
incidents: {}, incidents: {},
incidentsCount: {},
sort: 'created_desc', sort: 'created_desc',
sortBy: 'createdAt', sortBy: 'createdAt',
sortDesc: true, sortDesc: true,
statusFilter: '', statusFilter: '',
filteredByStatus: '', filteredByStatus: '',
authorUsername: this.authorUsernamesQuery, searchTerm: this.textQuery,
assigneeUsernames: this.assigneeUsernamesQuery, authorUsername: this.authorUsernameQuery,
filterParams: {}, assigneeUsername: this.assigneeUsernameQuery,
pagination: initialPaginationState,
}; };
}, },
computed: { computed: {
...@@ -215,29 +189,15 @@ export default { ...@@ -215,29 +189,15 @@ export default {
loading() { loading() {
return this.$apollo.queries.incidents.loading; return this.$apollo.queries.incidents.loading;
}, },
hasIncidents() { isEmpty() {
return this.incidents?.list?.length; 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);
}, },
nextPage() { showList() {
const nextPage = this.pagination.currentPage + 1; return !this.isEmpty || this.errored || this.loading;
return nextPage > Math.ceil(this.incidentsForCurrentTab / DEFAULT_PAGE_SIZE)
? null
: nextPage;
}, },
tbodyTrClass() { tbodyTrClass() {
return { return {
[bodyTrClass]: !this.loading && this.hasIncidents, [bodyTrClass]: !this.loading && !this.isEmpty,
}; };
}, },
newIncidentPath() { newIncidentPath() {
...@@ -257,12 +217,6 @@ export default { ...@@ -257,12 +217,6 @@ export default {
return this.$options.fields.filter(({ key }) => !isHidden[key]); return this.$options.fields.filter(({ key }) => !isHidden[key]);
}, },
isEmpty() {
return !this.incidents.list?.length;
},
showList() {
return !this.isEmpty || this.errored || this.loading;
},
activeClosedTabHasNoIncidents() { activeClosedTabHasNoIncidents() {
const { all, closed } = this.incidentsCount || {}; const { all, closed } = this.incidentsCount || {};
const isClosedTabActive = this.statusFilter === this.$options.statusTabs[1].filters; const isClosedTabActive = this.statusFilter === this.$options.statusTabs[1].filters;
...@@ -285,63 +239,8 @@ export default { ...@@ -285,63 +239,8 @@ export default {
btnText: createIncidentBtnLabel, 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: { methods: {
filterIncidentsByStatus(tabIndex) {
this.resetPagination();
const { filters, status } = this.$options.statusTabs[tabIndex];
this.statusFilter = filters;
this.filteredByStatus = status;
},
hasAssignees(assignees) { hasAssignees(assignees) {
return Boolean(assignees.nodes?.length); return Boolean(assignees.nodes?.length);
}, },
...@@ -353,119 +252,54 @@ export default { ...@@ -353,119 +252,54 @@ export default {
Tracking.event(category, action); Tracking.event(category, action);
this.redirecting = true; 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 }) { fetchSortedData({ sortBy, sortDesc }) {
const sortingDirection = sortDesc ? 'DESC' : 'ASC'; const sortingDirection = sortDesc ? 'DESC' : 'ASC';
const sortingColumn = convertToSnakeCase(sortBy) const sortingColumn = convertToSnakeCase(sortBy)
.replace(/_.*/, '') .replace(/_.*/, '')
.toUpperCase(); .toUpperCase();
this.resetPagination(); this.pagination = initialPaginationState;
this.sort = `${sortingColumn}_${sortingDirection}`; this.sort = `${sortingColumn}_${sortingDirection}`;
}, },
getSeverity(severity) { getSeverity(severity) {
return INCIDENT_SEVERITY[severity]; return INCIDENT_SEVERITY[severity];
}, },
handleFilterIncidents(filters) { pageChanged(pagination) {
this.resetPagination(); this.pagination = pagination;
const filterParams = { authorUsername: '', assigneeUsername: '', search: '' }; },
statusChanged({ filters, status }) {
filters.forEach(filter => { this.statusFilter = filters;
if (typeof filter === 'object') { this.filteredByStatus = status;
switch (filter.type) { },
case 'author_username': filtersChanged({ searchTerm, authorUsername, assigneeUsername }) {
filterParams.authorUsername = filter.value.data; this.searchTerm = searchTerm;
break; this.authorUsername = authorUsername;
case 'assignee_username': this.assigneeUsername = assigneeUsername;
filterParams.assigneeUsername = filter.value.data; },
break; errorAlertDismissed() {
case 'filtered-search-term': this.isErrorAlertDismissed = true;
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;
},
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,
});
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="incident-management-list"> <div>
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true"> <paginated-table-with-search-and-tabs
{{ $options.i18n.errorMsg }} :show-items="showList"
</gl-alert> :show-error-msg="showErrorMsg"
:i18n="$options.i18n"
<div :items="incidents.list || []"
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" :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"
> >
<gl-tabs content-class="gl-p-0" @input="filterIncidentsByStatus"> <template #header-actions>
<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 <gl-button
v-if="!isEmpty || activeClosedTabHasNoIncidents" v-if="!isEmpty || activeClosedTabHasNoIncidents"
class="gl-my-3 gl-mr-5 create-incident-button" class="gl-my-3 gl-mr-5 create-incident-button"
...@@ -476,30 +310,18 @@ export default { ...@@ -476,30 +310,18 @@ export default {
category="primary" category="primary"
variant="success" variant="success"
:href="newIncidentPath" :href="newIncidentPath"
@click="navigateToCreateNewIncident" @click="redirecting = true"
> >
{{ $options.i18n.createIncidentBtnLabel }} {{ $options.i18n.createIncidentBtnLabel }}
</gl-button> </gl-button>
</div> </template>
<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"> <template #title>
{{ s__('IncidentManagement|Incidents') }} {{ s__('IncidentManagement|Incidents') }}
</h4> </template>
<template #table>
<gl-table <gl-table
v-if="showList"
:items="incidents.list || []" :items="incidents.list || []"
:fields="availableFields" :fields="availableFields"
:show-empty="true" :show-empty="true"
...@@ -584,24 +406,16 @@ export default { ...@@ -584,24 +406,16 @@ export default {
{{ $options.i18n.noIncidents }} {{ $options.i18n.noIncidents }}
</template> </template>
</gl-table> </gl-table>
</template>
<template #emtpy-state>
<gl-empty-state <gl-empty-state
v-else
:title="emptyStateData.title" :title="emptyStateData.title"
:svg-path="emptyListSvgPath" :svg-path="emptyListSvgPath"
:description="emptyStateData.description" :description="emptyStateData.description"
:primary-button-link="emptyStateData.btnLink" :primary-button-link="emptyStateData.btnLink"
:primary-button-text="emptyStateData.btnText" :primary-button-text="emptyStateData.btnText"
/> />
</template>
<gl-pagination </paginated-table-with-search-and-tabs>
v-if="showPaginationControls"
:value="pagination.currentPage"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="handlePageChange"
/>
</div> </div>
</template> </template>
/* eslint-disable @gitlab/require-i18n-strings */ /* eslint-disable @gitlab/require-i18n-strings */
import { s__, __ } from '~/locale'; import { s__ } from '~/locale';
export const I18N = { export const I18N = {
errorMsg: s__('IncidentManagement|There was an error displaying the incidents.'), errorMsg: s__('IncidentManagement|There was an error displaying the incidents.'),
...@@ -7,7 +7,6 @@ export const I18N = { ...@@ -7,7 +7,6 @@ export const I18N = {
unassigned: s__('IncidentManagement|Unassigned'), unassigned: s__('IncidentManagement|Unassigned'),
createIncidentBtnLabel: s__('IncidentManagement|Create incident'), createIncidentBtnLabel: s__('IncidentManagement|Create incident'),
unPublished: s__('IncidentManagement|Unpublished'), unPublished: s__('IncidentManagement|Unpublished'),
searchPlaceholder: __('Search or filter results…'),
emptyState: { emptyState: {
title: s__('IncidentManagement|Display your incidents in a dedicated view'), title: s__('IncidentManagement|Display your incidents in a dedicated view'),
emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'), emptyClosedTabTitle: s__('IncidentManagement|There are no closed incidents'),
...@@ -43,6 +42,14 @@ export const trackIncidentCreateNewOptions = { ...@@ -43,6 +42,14 @@ export const trackIncidentCreateNewOptions = {
action: 'create_incident_button_clicks', 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 DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
......
...@@ -3,14 +3,14 @@ query getIncidentsCountByStatus( ...@@ -3,14 +3,14 @@ query getIncidentsCountByStatus(
$projectPath: ID! $projectPath: ID!
$issueTypes: [IssueType!] $issueTypes: [IssueType!]
$authorUsername: String = "" $authorUsername: String = ""
$assigneeUsernames: String = "" $assigneeUsername: String = ""
) { ) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
issueStatusCounts( issueStatusCounts(
search: $searchTerm search: $searchTerm
types: $issueTypes types: $issueTypes
authorUsername: $authorUsername authorUsername: $authorUsername
assigneeUsername: $assigneeUsernames assigneeUsername: $assigneeUsername
) { ) {
all all
opened opened
......
...@@ -11,7 +11,7 @@ query getIncidents( ...@@ -11,7 +11,7 @@ query getIncidents(
$nextPageCursor: String = "" $nextPageCursor: String = ""
$searchTerm: String = "" $searchTerm: String = ""
$authorUsername: String = "" $authorUsername: String = ""
$assigneeUsernames: String = "" $assigneeUsername: String = ""
) { ) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
issues( issues(
...@@ -20,7 +20,7 @@ query getIncidents( ...@@ -20,7 +20,7 @@ query getIncidents(
sort: $sort sort: $sort
state: $status state: $status
authorUsername: $authorUsername authorUsername: $authorUsername
assigneeUsername: $assigneeUsernames assigneeUsername: $assigneeUsername
first: $firstPageSize first: $firstPageSize
last: $lastPageSize last: $lastPageSize
after: $nextPageCursor after: $nextPageCursor
......
...@@ -18,8 +18,8 @@ export default () => { ...@@ -18,8 +18,8 @@ export default () => {
publishedAvailable, publishedAvailable,
emptyListSvgPath, emptyListSvgPath,
textQuery, textQuery,
authorUsernamesQuery, authorUsernameQuery,
assigneeUsernamesQuery, assigneeUsernameQuery,
slaFeatureAvailable, slaFeatureAvailable,
} = domEl.dataset; } = domEl.dataset;
...@@ -38,8 +38,8 @@ export default () => { ...@@ -38,8 +38,8 @@ export default () => {
publishedAvailable: parseBoolean(publishedAvailable), publishedAvailable: parseBoolean(publishedAvailable),
emptyListSvgPath, emptyListSvgPath,
textQuery, textQuery,
authorUsernamesQuery, authorUsernameQuery,
assigneeUsernamesQuery, assigneeUsernameQuery,
slaFeatureAvailable: parseBoolean(slaFeatureAvailable), slaFeatureAvailable: parseBoolean(slaFeatureAvailable),
}, },
apolloProvider, 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 @@ ...@@ -8,7 +8,7 @@
@include gl-text-gray-500; @include gl-text-gray-500;
tbody { tbody {
tr { tr:not(.b-table-busy-slot) {
// TODO replace with gitlab/ui utilities: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1791 // TODO replace with gitlab/ui utilities: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1791
&:hover { &:hover {
border-top-style: double; border-top-style: double;
...@@ -132,7 +132,7 @@ ...@@ -132,7 +132,7 @@
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
.incident-management-list-header { .list-header {
flex-direction: column-reverse; flex-direction: column-reverse;
} }
......
...@@ -9,7 +9,9 @@ module Projects::AlertManagementHelper ...@@ -9,7 +9,9 @@ module Projects::AlertManagementHelper
'populating-alerts-help-url' => help_page_url('operations/incident_management/index.md', anchor: 'enable-alert-management'), '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'), '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, '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 end
......
...@@ -10,8 +10,8 @@ module Projects::IncidentsHelper ...@@ -10,8 +10,8 @@ module Projects::IncidentsHelper
'issue-path' => project_issues_path(project), 'issue-path' => project_issues_path(project),
'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg'), 'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg'),
'text-query': params[:search], 'text-query': params[:search],
'author-usernames-query': params[:author_username], 'author-username-query': params[:author_username],
'assignee-usernames-query': params[:assignee_username] 'assignee-username-query': params[:assignee_username]
} }
end end
end end
......
...@@ -29,8 +29,8 @@ RSpec.describe Projects::IncidentsHelper do ...@@ -29,8 +29,8 @@ RSpec.describe Projects::IncidentsHelper do
'published-available' => 'false', 'published-available' => 'false',
'sla-feature-available' => 'false', 'sla-feature-available' => 'false',
'text-query': 'search text', 'text-query': 'search text',
'author-usernames-query': 'root', 'author-username-query': 'root',
'assignee-usernames-query': 'max.power' 'assignee-username-query': 'max.power'
} }
end end
......
...@@ -20,18 +20,12 @@ RSpec.describe 'User searches Alert Management alerts', :js do ...@@ -20,18 +20,12 @@ RSpec.describe 'User searches Alert Management alerts', :js do
end end
context 'when a developer displays the alert list and the alert service is enabled they can search an alert' do 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 it 'shows the incident table with an incident for a valid search filter bar' do
expect(page).to have_selector('[data-testid="search-icon"]') expect(page).to have_selector('.filtered-search-wrapper')
expect(page).to have_selector('.gl-table')
find('.gl-search-box-by-type-input').set('Alert') expect(page).to have_css('[data-testid="severityField"]')
expect(all('tbody tr').count).to be(1)
expect(all('.dropdown-menu-selectable').count).to be(1) expect(page).not_to have_selector('.empty-state')
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')
end end
end 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 { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui'; import { GlEmptyState } from '@gitlab/ui';
import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue'; import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue';
import defaultProvideValues from '../mocks/alerts_provide_config.json';
describe('AlertManagementEmptyState', () => { describe('AlertManagementEmptyState', () => {
let wrapper; let wrapper;
function mountComponent({ function mountComponent({ provide = {} } = {}) {
props = {
alertManagementEnabled: false,
userCanEnableAlertManagement: false,
},
stubs = {},
} = {}) {
wrapper = shallowMount(AlertManagementEmptyState, { wrapper = shallowMount(AlertManagementEmptyState, {
propsData: { provide: {
enableAlertManagementPath: '/link', ...defaultProvideValues,
alertsHelpUrl: '/link', ...provide,
emptyAlertSvgPath: 'illustration/path',
...props,
}, },
stubs,
}); });
} }
...@@ -42,7 +34,7 @@ describe('AlertManagementEmptyState', () => { ...@@ -42,7 +34,7 @@ describe('AlertManagementEmptyState', () => {
it('show OpsGenie integration state when OpsGenie mcv is true', () => { it('show OpsGenie integration state when OpsGenie mcv is true', () => {
mountComponent({ mountComponent({
props: { provide: {
alertManagementEnabled: false, alertManagementEnabled: false,
userCanEnableAlertManagement: false, userCanEnableAlertManagement: false,
opsgenieMvcEnabled: true, opsgenieMvcEnabled: true,
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import AlertManagementList from '~/alert_management/components/alert_management_list_wrapper.vue'; import AlertManagementList from '~/alert_management/components/alert_management_list_wrapper.vue';
import { trackAlertListViewsOptions } from '~/alert_management/constants'; import AlertManagementEmptyState from '~/alert_management/components/alert_management_empty_state.vue';
import mockAlerts from '../mocks/alerts.json'; import AlertManagementTable from '~/alert_management/components/alert_management_table.vue';
import Tracking from '~/tracking'; import defaultProvideValues from '../mocks/alerts_provide_config.json';
describe('AlertManagementList', () => { describe('AlertManagementList', () => {
let wrapper; let wrapper;
function mountComponent({ function mountComponent({ provide = {} } = {}) {
props = {
alertManagementEnabled: false,
userCanEnableAlertManagement: false,
},
data = {},
stubs = {},
} = {}) {
wrapper = shallowMount(AlertManagementList, { wrapper = shallowMount(AlertManagementList, {
propsData: { provide: {
projectPath: 'gitlab-org/gitlab', ...defaultProvideValues,
enableAlertManagementPath: '/link', ...provide,
alertsHelpUrl: '/link',
populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data',
emptyAlertSvgPath: 'illustration/path',
...props,
},
data() {
return data;
}, },
stubs,
}); });
} }
...@@ -41,18 +26,21 @@ describe('AlertManagementList', () => { ...@@ -41,18 +26,21 @@ describe('AlertManagementList', () => {
} }
}); });
describe('Snowplow tracking', () => { describe('Alert List Wrapper', () => {
beforeEach(() => { it('should show the empty state when alerts are not enabled', () => {
jest.spyOn(Tracking, 'event'); expect(wrapper.find(AlertManagementEmptyState).exists()).toBe(true);
mountComponent({ expect(wrapper.find(AlertManagementTable).exists()).toBe(false);
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: { list: mockAlerts } },
}); });
it('should show the alerts table when alerts are enabled', () => {
mountComponent({
provide: {
alertManagementEnabled: true,
},
}); });
it('should track alert list page views', () => { expect(wrapper.find(AlertManagementEmptyState).exists()).toBe(false);
const { category, action } = trackAlertListViewsOptions; expect(wrapper.find(AlertManagementTable).exists()).toBe(true);
expect(Tracking.event).toHaveBeenCalledWith(category, action);
}); });
}); });
}); });
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { import { GlTable, GlAlert, GlLoadingIcon, GlDropdown, GlIcon, GlAvatar } from '@gitlab/ui';
GlTable, import axios from 'axios';
GlAlert, import MockAdapter from 'axios-mock-adapter';
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
GlIcon,
GlTabs,
GlTab,
GlBadge,
GlPagination,
GlSearchBoxByType,
GlAvatar,
} from '@gitlab/ui';
import waitForPromises from 'helpers/wait_for_promises';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import AlertManagementTable from '~/alert_management/components/alert_management_table.vue'; import AlertManagementTable from '~/alert_management/components/alert_management_table.vue';
import { ALERTS_STATUS_TABS, trackAlertStatusUpdateOptions } from '~/alert_management/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql';
import mockAlerts from '../mocks/alerts.json'; import mockAlerts from '../mocks/alerts.json';
import Tracking from '~/tracking'; import defaultProvideValues from '../mocks/alerts_provide_config.json';
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'), visitUrl: jest.fn().mockName('visitUrlMock'),
...@@ -29,26 +16,21 @@ jest.mock('~/lib/utils/url_utility', () => ({ ...@@ -29,26 +16,21 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('AlertManagementTable', () => { describe('AlertManagementTable', () => {
let wrapper; let wrapper;
let mock;
const findAlertsTable = () => wrapper.find(GlTable); const findAlertsTable = () => wrapper.find(GlTable);
const findAlerts = () => wrapper.findAll('table tbody tr'); const findAlerts = () => wrapper.findAll('table tbody tr');
const findAlert = () => wrapper.find(GlAlert); const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon); const findLoader = () => wrapper.find(GlLoadingIcon);
const findStatusDropdown = () => wrapper.find(GlDropdown); 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 findDateFields = () => wrapper.findAll(TimeAgo);
const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); const findSearch = () => wrapper.find(FilteredSearchBar);
const findPagination = () => wrapper.find(GlPagination);
const findSearch = () => wrapper.find(GlSearchBoxByType);
const findSeverityColumnHeader = () => const findSeverityColumnHeader = () =>
wrapper.find('[data-testid="alert-management-severity-sort"]'); wrapper.find('[data-testid="alert-management-severity-sort"]');
const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0); const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0);
const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]'); const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]');
const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]'); const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]');
const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]'); const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]');
const findAlertError = () => wrapper.find('[data-testid="alert-error"]');
const alertsCount = { const alertsCount = {
open: 24, open: 24,
triggered: 20, triggered: 20,
...@@ -56,26 +38,14 @@ describe('AlertManagementTable', () => { ...@@ -56,26 +38,14 @@ describe('AlertManagementTable', () => {
resolved: 11, resolved: 11,
all: 26, all: 26,
}; };
const selectFirstStatusOption = () => {
findFirstStatusOption().vm.$emit('click');
return waitForPromises(); function mountComponent({ provide = {}, data = {}, loading = false, stubs = {} } = {}) {
};
function mountComponent({
props = {
alertManagementEnabled: false,
userCanEnableAlertManagement: false,
},
data = {},
loading = false,
stubs = {},
} = {}) {
wrapper = mount(AlertManagementTable, { wrapper = mount(AlertManagementTable, {
propsData: { provide: {
projectPath: 'gitlab-org/gitlab', ...defaultProvideValues,
populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data', alertManagementEnabled: true,
...props, userCanEnableAlertManagement: true,
...provide,
}, },
data() { data() {
return data; return data;
...@@ -95,41 +65,21 @@ describe('AlertManagementTable', () => { ...@@ -95,41 +65,21 @@ describe('AlertManagementTable', () => {
}); });
} }
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => { afterEach(() => {
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
} }
}); mock.restore();
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]);
});
});
}); });
describe('Alerts table', () => { describe('Alerts table', () => {
it('loading state', () => { it('loading state', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { alerts: {}, alertsCount: null }, data: { alerts: {}, alertsCount: null },
loading: true, loading: true,
}); });
...@@ -144,8 +94,7 @@ describe('AlertManagementTable', () => { ...@@ -144,8 +94,7 @@ describe('AlertManagementTable', () => {
it('error state', () => { it('error state', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { errors: ['error'] }, alertsCount: null, errored: true },
data: { alerts: { errors: ['error'] }, alertsCount: null, hasError: true },
loading: false, loading: false,
}); });
expect(findAlertsTable().exists()).toBe(true); expect(findAlertsTable().exists()).toBe(true);
...@@ -161,10 +110,17 @@ describe('AlertManagementTable', () => { ...@@ -161,10 +110,17 @@ describe('AlertManagementTable', () => {
it('empty state', () => { it('empty state', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: {
data: { alerts: { list: [], pageInfo: {} }, alertsCount: { all: 0 }, hasError: false }, alerts: { list: [], pageInfo: {} },
alertsCount: { all: 0 },
errored: false,
isErrorAlertDismissed: false,
searchTerm: '',
assigneeUsername: '',
},
loading: false, loading: false,
}); });
expect(findAlertsTable().exists()).toBe(true); expect(findAlertsTable().exists()).toBe(true);
expect(findAlertsTable().text()).toContain('No alerts to display'); expect(findAlertsTable().text()).toContain('No alerts to display');
expect(findLoader().exists()).toBe(false); expect(findLoader().exists()).toBe(false);
...@@ -178,8 +134,7 @@ describe('AlertManagementTable', () => { ...@@ -178,8 +134,7 @@ describe('AlertManagementTable', () => {
it('has data state', () => { it('has data state', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
expect(findLoader().exists()).toBe(false); expect(findLoader().exists()).toBe(false);
...@@ -194,8 +149,7 @@ describe('AlertManagementTable', () => { ...@@ -194,8 +149,7 @@ describe('AlertManagementTable', () => {
it('displays the alert ID and title formatted correctly', () => { it('displays the alert ID and title formatted correctly', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
...@@ -205,8 +159,7 @@ describe('AlertManagementTable', () => { ...@@ -205,8 +159,7 @@ describe('AlertManagementTable', () => {
it('displays status dropdown', () => { it('displays status dropdown', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
expect(findStatusDropdown().exists()).toBe(true); expect(findStatusDropdown().exists()).toBe(true);
...@@ -214,8 +167,7 @@ describe('AlertManagementTable', () => { ...@@ -214,8 +167,7 @@ describe('AlertManagementTable', () => {
it('does not display a dropdown status header', () => { it('does not display a dropdown status header', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
expect( expect(
...@@ -225,14 +177,14 @@ describe('AlertManagementTable', () => { ...@@ -225,14 +177,14 @@ describe('AlertManagementTable', () => {
).toBe(false); ).toBe(false);
}); });
it('shows correct severity icons', () => { it('shows correct severity icons', async () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(wrapper.find(GlTable).exists()).toBe(true); expect(wrapper.find(GlTable).exists()).toBe(true);
expect( expect(
findAlertsTable() findAlertsTable()
...@@ -240,12 +192,10 @@ describe('AlertManagementTable', () => { ...@@ -240,12 +192,10 @@ describe('AlertManagementTable', () => {
.classes('icon-critical'), .classes('icon-critical'),
).toBe(true); ).toBe(true);
}); });
});
it('renders severity text', () => { it('renders severity text', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
...@@ -258,8 +208,7 @@ describe('AlertManagementTable', () => { ...@@ -258,8 +208,7 @@ describe('AlertManagementTable', () => {
it('renders Unassigned when no assignee(s) present', () => { it('renders Unassigned when no assignee(s) present', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
...@@ -272,8 +221,7 @@ describe('AlertManagementTable', () => { ...@@ -272,8 +221,7 @@ describe('AlertManagementTable', () => {
it('renders user avatar when assignee present', () => { it('renders user avatar when assignee present', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
...@@ -290,8 +238,7 @@ describe('AlertManagementTable', () => { ...@@ -290,8 +238,7 @@ describe('AlertManagementTable', () => {
it('navigates to the detail page when alert row is clicked', () => { it('navigates to the detail page when alert row is clicked', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
...@@ -305,8 +252,7 @@ describe('AlertManagementTable', () => { ...@@ -305,8 +252,7 @@ describe('AlertManagementTable', () => {
it('navigates to the detail page in new tab when alert row is clicked with the metaKey', () => { it('navigates to the detail page in new tab when alert row is clicked with the metaKey', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
...@@ -324,8 +270,7 @@ describe('AlertManagementTable', () => { ...@@ -324,8 +270,7 @@ describe('AlertManagementTable', () => {
describe('alert issue links', () => { describe('alert issue links', () => {
beforeEach(() => { beforeEach(() => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
}); });
...@@ -355,7 +300,6 @@ describe('AlertManagementTable', () => { ...@@ -355,7 +300,6 @@ describe('AlertManagementTable', () => {
describe('handle date fields', () => { describe('handle date fields', () => {
it('should display time ago dates when values provided', () => { it('should display time ago dates when values provided', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { data: {
alerts: { alerts: {
list: [ list: [
...@@ -369,7 +313,7 @@ describe('AlertManagementTable', () => { ...@@ -369,7 +313,7 @@ describe('AlertManagementTable', () => {
], ],
}, },
alertsCount, alertsCount,
hasError: false, errored: false,
}, },
loading: false, loading: false,
}); });
...@@ -378,7 +322,6 @@ describe('AlertManagementTable', () => { ...@@ -378,7 +322,6 @@ describe('AlertManagementTable', () => {
it('should not display time ago dates when values not provided', () => { it('should not display time ago dates when values not provided', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { data: {
alerts: [ alerts: [
{ {
...@@ -389,7 +332,7 @@ describe('AlertManagementTable', () => { ...@@ -389,7 +332,7 @@ describe('AlertManagementTable', () => {
}, },
], ],
alertsCount, alertsCount,
hasError: false, errored: false,
}, },
loading: false, loading: false,
}); });
...@@ -403,8 +346,7 @@ describe('AlertManagementTable', () => { ...@@ -403,8 +346,7 @@ describe('AlertManagementTable', () => {
it('should highlight the row when alert is new', () => { it('should highlight the row when alert is new', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: [newAlert] }, alertsCount, errored: false },
data: { alerts: { list: [newAlert] }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
...@@ -417,8 +359,7 @@ describe('AlertManagementTable', () => { ...@@ -417,8 +359,7 @@ describe('AlertManagementTable', () => {
it('should not highlight the row when alert is not new', () => { it('should not highlight the row when alert is not new', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: [oldAlert] }, alertsCount, errored: false },
data: { alerts: { list: [oldAlert] }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
...@@ -435,10 +376,9 @@ describe('AlertManagementTable', () => { ...@@ -435,10 +376,9 @@ describe('AlertManagementTable', () => {
describe('sorting the alert list by column', () => { describe('sorting the alert list by column', () => {
beforeEach(() => { beforeEach(() => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
data: { data: {
alerts: { list: mockAlerts }, alerts: { list: mockAlerts },
hasError: false, errored: false,
sort: 'STARTED_AT_DESC', sort: 'STARTED_AT_DESC',
alertsCount, alertsCount,
}, },
...@@ -458,184 +398,10 @@ describe('AlertManagementTable', () => { ...@@ -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', () => { describe('Search', () => {
beforeEach(() => { beforeEach(() => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, errored: false },
data: { alerts: { list: mockAlerts }, alertsCount, hasError: false },
loading: false, loading: false,
}); });
}); });
...@@ -643,13 +409,5 @@ describe('AlertManagementTable', () => { ...@@ -643,13 +409,5 @@ describe('AlertManagementTable', () => {
it('renders the search component', () => { it('renders the search component', () => {
expect(findSearch().exists()).toBe(true); 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'; ...@@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { trackAlertStatusUpdateOptions } from '~/alert_management/constants'; import { trackAlertStatusUpdateOptions } from '~/alert_management/constants';
import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue'; 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 Tracking from '~/tracking';
import mockAlerts from '../../mocks/alerts.json'; import mockAlerts from '../../mocks/alerts.json';
...@@ -85,7 +85,7 @@ describe('Alert Details Sidebar Status', () => { ...@@ -85,7 +85,7 @@ describe('Alert Details Sidebar Status', () => {
findStatusDropdownItem().vm.$emit('click'); findStatusDropdownItem().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateAlertStatus, mutation: updateAlertStatusMutation,
variables: { variables: {
iid: '1527542', iid: '1527542',
status: 'TRIGGERED', 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 { mount } from '@vue/test-utils';
import { import { GlAlert, GlLoadingIcon, GlTable, GlAvatar, GlEmptyState } from '@gitlab/ui';
GlAlert,
GlLoadingIcon,
GlTable,
GlAvatar,
GlPagination,
GlTab,
GlTabs,
GlBadge,
GlEmptyState,
} from '@gitlab/ui';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility'; import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility';
import IncidentsList from '~/incidents/components/incidents_list.vue'; import IncidentsList from '~/incidents/components/incidents_list.vue';
import SeverityToken from '~/sidebar/components/severity/severity.vue'; import SeverityToken from '~/sidebar/components/severity/severity.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.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 { import {
I18N, I18N,
INCIDENT_STATUS_TABS,
TH_CREATED_AT_TEST_ID, TH_CREATED_AT_TEST_ID,
TH_SEVERITY_TEST_ID, TH_SEVERITY_TEST_ID,
TH_PUBLISHED_TEST_ID, TH_PUBLISHED_TEST_ID,
trackIncidentCreateNewOptions,
} from '~/incidents/constants'; } from '~/incidents/constants';
import mockIncidents from '../mocks/incidents.json'; import mockIncidents from '../mocks/incidents.json';
import mockFilters from '../mocks/incidents_filter.json';
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'), visitUrl: jest.fn().mockName('visitUrlMock'),
...@@ -54,15 +39,10 @@ describe('Incidents List', () => { ...@@ -54,15 +39,10 @@ describe('Incidents List', () => {
const findAlert = () => wrapper.find(GlAlert); const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon); const findLoader = () => wrapper.find(GlLoadingIcon);
const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip); const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip);
const findSearch = () => wrapper.find(FilteredSearchBar);
const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]');
const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]'); const findAssignees = () => wrapper.findAll('[data-testid="incident-assignees"]');
const findIncidentSlaHeader = () => wrapper.find('[data-testid="incident-management-sla"]');
const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]');
const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']"); const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']");
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 findEmptyState = () => wrapper.find(GlEmptyState);
const findSeverity = () => wrapper.findAll(SeverityToken); const findSeverity = () => wrapper.findAll(SeverityToken);
const findIncidentSla = () => wrapper.findAll("[data-testid='incident-sla']"); const findIncidentSla = () => wrapper.findAll("[data-testid='incident-sla']");
...@@ -94,8 +74,8 @@ describe('Incidents List', () => { ...@@ -94,8 +74,8 @@ describe('Incidents List', () => {
publishedAvailable: true, publishedAvailable: true,
emptyListSvgPath, emptyListSvgPath,
textQuery: '', textQuery: '',
authorUsernamesQuery: '', authorUsernameQuery: '',
assigneeUsernamesQuery: '', assigneeUsernameQuery: '',
slaFeatureAvailable: true, slaFeatureAvailable: true,
...provide, ...provide,
}, },
...@@ -275,204 +255,10 @@ describe('Incidents List', () => { ...@@ -275,204 +255,10 @@ describe('Incidents List', () => {
expect(findCreateIncidentBtn().exists()).toBe(false); 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'); findCreateIncidentBtn().vm.$emit('click');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
const { category, action } = trackIncidentCreateNewOptions; expect(Tracking.event).toHaveBeenCalled();
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]);
});
});
}); });
}); });
......
[
{
"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
[ [
{ {
"type": "assignee_username", "type": "assignee_username",
"value": { "data": "root2" } "value": { "data": "root2" }
......
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 ...@@ -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', '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'), 'empty-alert-svg-path' => match_asset_path('/assets/illustrations/alert-management-empty-state.svg'),
'user-can-enable-alert-management' => 'true', 'user-can-enable-alert-management' => 'true',
'alert-management-enabled' => 'false' 'alert-management-enabled' => 'false',
'text-query': nil,
'assignee-username-query': nil
) )
end end
end end
......
...@@ -29,8 +29,8 @@ RSpec.describe Projects::IncidentsHelper do ...@@ -29,8 +29,8 @@ RSpec.describe Projects::IncidentsHelper do
'issue-path' => issue_path, 'issue-path' => issue_path,
'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'), 'empty-list-svg-path' => match_asset_path('/assets/illustrations/incident-empty-state.svg'),
'text-query': 'search text', 'text-query': 'search text',
'author-usernames-query': 'root', 'author-username-query': 'root',
'assignee-usernames-query': 'max.power' 'assignee-username-query': 'max.power'
) )
end end
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