Commit e974547c authored by Alexander Turinske's avatar Alexander Turinske

Implement inifinite scrolling of alerts

- modify list styling
- add skeleton loading component on initial load
- add inifinite scrolling
- add tests
parent 16776549
<script>
import { GlAlert, GlLoadingIcon, GlTable, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import {
GlAlert,
GlIntersectionObserver,
GlLoadingIcon,
GlTable,
GlLink,
GlSkeletonLoading,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import produce from 'immer';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
// TODO once backend is settled, update by either abstracting this out to app/assets/javascripts/graphql_shared or create new, modified query in #287757
import getAlerts from '~/alert_management/graphql/queries/get_alerts.query.graphql';
import { FIELDS, MESSAGES, STATUSES } from './constants';
import { FIELDS, MESSAGES, PAGE_SIZE, STATUSES } from './constants';
export default {
i18n: {
FIELDS,
MESSAGES,
PAGE_SIZE,
STATUSES,
},
components: {
GlAlert,
GlIntersectionObserver,
GlLink,
GlLoadingIcon,
GlSkeletonLoading,
GlSprintf,
GlTable,
TimeAgo,
GlLink,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -29,14 +42,15 @@ export default {
query: getAlerts,
variables() {
return {
firstPageSize: this.$options.i18n.PAGE_SIZE,
projectPath: this.projectPath,
sort: this.sort,
};
},
update: ({ project }) => ({
list: project?.alertManagementAlerts.nodes || [],
pageInfo: project?.alertManagementAlerts.pageInfo || {},
}),
update: ({ project }) => project?.alertManagementAlerts.nodes || [],
result({ data }) {
this.pageInfo = data?.project?.alertManagementAlerts?.pageInfo;
},
error() {
this.errored = true;
},
......@@ -44,9 +58,10 @@ export default {
},
data() {
return {
alerts: {},
alerts: [],
errored: false,
isErrorAlertDismissed: false,
pageInfo: {},
sort: 'STARTED_AT_DESC',
sortBy: 'startedAt',
sortDesc: true,
......@@ -55,13 +70,16 @@ export default {
},
computed: {
isEmpty() {
return !this.alerts?.list?.length;
return !this.alerts.length;
},
loading() {
isLoadingAlerts() {
return this.$apollo.queries.alerts.loading;
},
isLoadingFirstAlerts() {
return this.isLoadingAlerts && this.isEmpty;
},
showNoAlertsMsg() {
return this.isEmpty && !this.loading && !this.errored && !this.isErrorAlertDismissed;
return this.isEmpty && !this.isLoadingAlerts && !this.errored && !this.isErrorAlertDismissed;
},
},
methods: {
......@@ -69,6 +87,23 @@ export default {
this.errored = false;
this.isErrorAlertDismissed = true;
},
fetchNextPage() {
if (this.pageInfo.hasNextPage) {
this.$apollo.queries.alerts.fetchMore({
variables: { nextPageCursor: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
const results = produce(fetchMoreResult, draftData => {
// eslint-disable-next-line no-param-reassign
draftData.project.alertManagementAlerts.nodes = [
...previousResult.project.alertManagementAlerts.nodes,
...draftData.project.alertManagementAlerts.nodes,
];
});
return results;
},
});
}
},
fetchSortedData({ sortBy, sortDesc }) {
const sortingDirection = sortDesc ? 'DESC' : 'ASC';
const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
......@@ -101,17 +136,18 @@ export default {
<gl-table
class="alert-management-table"
:items="alerts ? alerts.list : []"
:busy="isLoadingFirstAlerts"
:items="alerts"
:fields="$options.i18n.FIELDS"
:show-empty="true"
:busy="loading"
stacked="md"
:no-local-sorting="true"
:sort-direction="sortDirection"
:sort-desc.sync="sortDesc"
:sort-by.sync="sortBy"
thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
sort-icon-left
responsive
show-empty
@sort-changed="fetchSortedData"
>
<template #cell(startedAt)="{ item }">
......@@ -138,20 +174,30 @@ export default {
</div>
</template>
<template #table-busy>
<gl-skeleton-loading
v-for="n in $options.i18n.PAGE_SIZE"
:key="n"
class="gl-m-3 js-skeleton-loader"
:lines="1"
data-testid="threat-alerts-busy-state"
/>
</template>
<template #empty>
<div data-testid="threat-alerts-empty-state">
{{ $options.i18n.MESSAGES.NO_ALERTS }}
</div>
</template>
<template #table-busy>
<gl-loading-icon
size="lg"
color="dark"
class="gl-mt-3"
data-testid="threat-alerts-busy-state"
/>
</template>
</gl-table>
<gl-intersection-observer
v-if="pageInfo.hasNextPage"
class="text-center"
@appear="fetchNextPage"
>
<gl-loading-icon v-if="isLoadingAlerts" size="md" />
<span v-else>&nbsp;</span>
</gl-intersection-observer>
</div>
</template>
......@@ -22,21 +22,23 @@ export const FIELDS = [
key: 'startedAt',
label: s__('ThreatMonitoring|Date and time'),
thAttr: { 'data-testid': 'threat-alerts-started-at-header' },
thClass: `gl-w-15p`,
thClass: `gl-bg-white! gl-w-15p`,
tdClass: `gl-pl-6!`,
sortable: true,
},
{
key: 'alertLabel',
label: s__('ThreatMonitoring|Name'),
thClass: `gl-pointer-events-none`,
thClass: `gl-bg-white! gl-pointer-events-none`,
},
{
key: 'status',
label: s__('ThreatMonitoring|Status'),
thAttr: { 'data-testid': 'threat-alerts-status-header' },
thClass: `gl-w-15p`,
thClass: `gl-bg-white! gl-w-15p`,
tdClass: `gl-pl-6!`,
sortable: true,
},
];
export const PAGE_SIZE = 20;
import { GlLoadingIcon } from '@gitlab/ui';
import { GlIntersectionObserver, GlSkeletonLoading } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import AlertsList from 'ee/threat_monitoring/components/alerts/alerts_list.vue';
const alerts = {
list: [
{
iid: '01',
title: 'Issue 01',
status: 'TRIGGERED',
startedAt: '2020-11-19T18:36:23Z',
},
{
iid: '02',
title: 'Issue 02',
status: 'ACKNOWLEDGED',
startedAt: '2020-11-16T21:59:28Z',
},
{
iid: '03',
title: 'Issue 03',
status: 'RESOLVED',
startedAt: '2020-11-13T20:03:04Z',
},
{
iid: '04',
title: 'Issue 04',
status: 'IGNORED',
startedAt: '2020-10-29T13:37:55Z',
},
],
pageInfo: {},
const alerts = [
{
iid: '01',
title: 'Issue 01',
status: 'TRIGGERED',
startedAt: '2020-11-19T18:36:23Z',
},
{
iid: '02',
title: 'Issue 02',
status: 'ACKNOWLEDGED',
startedAt: '2020-11-16T21:59:28Z',
},
{
iid: '03',
title: 'Issue 03',
status: 'RESOLVED',
startedAt: '2020-11-13T20:03:04Z',
},
{
iid: '04',
title: 'Issue 04',
status: 'IGNORED',
startedAt: '2020-10-29T13:37:55Z',
},
];
const pageInfo = {
endCursor: 'eyJpZCI6IjIwIiwic3RhcnRlZF9hdCI6IjIwMjAtMTItMDMgMjM6MTI6NDkuODM3Mjc1MDAwIFVUQyJ9',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'eyJpZCI6IjM5Iiwic3RhcnRlZF9hdCI6IjIwMjAtMTItMDQgMTg6MDE6MDcuNzY1ODgyMDAwIFVUQyJ9',
};
describe('AlertsList component', () => {
let wrapper;
const apolloMock = {
queries: { alerts: { loading: false } },
queries: {
alerts: {
fetchMore: jest.fn().mockResolvedValue(),
loading: false,
},
},
};
const findUnconfiguredAlert = () => wrapper.find("[data-testid='threat-alerts-unconfigured']");
......@@ -47,7 +56,8 @@ describe('AlertsList component', () => {
const findStatusColumn = () => wrapper.find("[data-testid='threat-alerts-status']");
const findStatusColumnHeader = () => wrapper.find("[data-testid='threat-alerts-status-header']");
const findEmptyState = () => wrapper.find("[data-testid='threat-alerts-empty-state']");
const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findGlIntersectionObserver = () => wrapper.find(GlIntersectionObserver);
const findGlSkeletonLoading = () => wrapper.find(GlSkeletonLoading);
const createWrapper = ({ $apollo = apolloMock, data = {} } = {}) => {
wrapper = mount(AlertsList, {
......@@ -61,6 +71,7 @@ describe('AlertsList component', () => {
stubs: {
GlAlert: true,
GlLoadingIcon: true,
GlIntersectionObserver: true,
},
data() {
return data;
......@@ -75,7 +86,7 @@ describe('AlertsList component', () => {
describe('default state', () => {
beforeEach(() => {
createWrapper({ data: { alerts } });
createWrapper({ data: { alerts, pageInfo } });
});
it('does show all columns', () => {
......@@ -96,7 +107,11 @@ describe('AlertsList component', () => {
expect(findErrorAlert().exists()).toBe(false);
});
it('is initially sorted by started at, descending', () => {
it('does show the observer component', () => {
expect(findGlIntersectionObserver().exists()).toBe(true);
});
it('does initially sort by started at, descending', () => {
expect(wrapper.vm.sort).toBe('STARTED_AT_DESC');
expect(findStartedAtColumnHeader().attributes('aria-sort')).toBe('descending');
});
......@@ -120,7 +135,7 @@ describe('AlertsList component', () => {
describe('empty state', () => {
beforeEach(() => {
createWrapper({ data: { alerts: { list: [] } } });
createWrapper({ data: { alerts: [] } });
});
it('does show the empty state', () => {
......@@ -141,7 +156,7 @@ describe('AlertsList component', () => {
});
it('does show the loading state', () => {
expect(findGlLoadingIcon().exists()).toBe(true);
expect(findGlSkeletonLoading().exists()).toBe(true);
});
it('does not show all columns', () => {
......@@ -177,4 +192,18 @@ describe('AlertsList component', () => {
expect(findUnconfiguredAlert().exists()).toBe(false);
});
});
describe('loading more alerts', () => {
it('does request more data', async () => {
createWrapper({
data: {
alerts,
pageInfo,
},
});
findGlIntersectionObserver().vm.$emit('appear');
await wrapper.vm.$nextTick();
expect(wrapper.vm.$apollo.queries.alerts.fetchMore).toHaveBeenCalledTimes(1);
});
});
});
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