Commit 7ba65de0 authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '232798-reduce-duplication' into 'master'

Resolve "Reduce code duplication Alert / Incident Management"

See merge request gitlab-org/gitlab!44347
parents aabddcc5 1d1e96d8
...@@ -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>
...@@ -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,
provide: {
projectPath,
textQuery,
assigneeUsernameQuery,
enableAlertManagementPath,
populatingAlertsHelpUrl,
emptyAlertSvgPath,
opsgenieMvcTargetUrl,
alertManagementEnabled: parseBoolean(alertManagementEnabled),
userCanEnableAlertManagement: parseBoolean(userCanEnableAlertManagement),
opsgenieMvcEnabled: parseBoolean(opsgenieMvcEnabled),
},
apolloProvider, apolloProvider,
components: { components: {
AlertManagementList, AlertManagementList,
}, },
render(createElement) { render(createElement) {
return createElement('alert-management-list', { return createElement('alert-management-list');
props: {
projectPath,
enableAlertManagementPath,
populatingAlertsHelpUrl,
emptyAlertSvgPath,
alertManagementEnabled,
userCanEnableAlertManagement,
opsgenieMvcTargetUrl,
opsgenieMvcEnabled,
},
});
}, },
}); });
}; };
/* 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);
expect(wrapper.find(AlertManagementTable).exists()).toBe(false);
});
it('should show the alerts table when alerts are enabled', () => {
mountComponent({ mountComponent({
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, provide: {
data: { alerts: { list: mockAlerts } }, 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 { 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