Commit 903ee8c0 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '290705-infinite-scrolling' into 'master'

Implement inifinite scrolling of alerts

See merge request gitlab-org/gitlab!49289
parents dcda91b8 cfd208cd
<script> <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 TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { convertToSnakeCase } from '~/lib/utils/text_utility'; 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 // 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 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 { export default {
PAGE_SIZE,
i18n: { i18n: {
FIELDS, FIELDS,
MESSAGES, MESSAGES,
...@@ -14,11 +25,13 @@ export default { ...@@ -14,11 +25,13 @@ export default {
}, },
components: { components: {
GlAlert, GlAlert,
GlIntersectionObserver,
GlLink,
GlLoadingIcon, GlLoadingIcon,
GlSkeletonLoading,
GlSprintf,
GlTable, GlTable,
TimeAgo, TimeAgo,
GlLink,
GlSprintf,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -29,14 +42,15 @@ export default { ...@@ -29,14 +42,15 @@ export default {
query: getAlerts, query: getAlerts,
variables() { variables() {
return { return {
firstPageSize: this.$options.PAGE_SIZE,
projectPath: this.projectPath, projectPath: this.projectPath,
sort: this.sort, sort: this.sort,
}; };
}, },
update: ({ project }) => ({ update: ({ project }) => project?.alertManagementAlerts.nodes || [],
list: project?.alertManagementAlerts.nodes || [], result({ data }) {
pageInfo: project?.alertManagementAlerts.pageInfo || {}, this.pageInfo = data?.project?.alertManagementAlerts?.pageInfo;
}), },
error() { error() {
this.errored = true; this.errored = true;
}, },
...@@ -44,9 +58,10 @@ export default { ...@@ -44,9 +58,10 @@ export default {
}, },
data() { data() {
return { return {
alerts: {}, alerts: [],
errored: false, errored: false,
isErrorAlertDismissed: false, isErrorAlertDismissed: false,
pageInfo: {},
sort: 'STARTED_AT_DESC', sort: 'STARTED_AT_DESC',
sortBy: 'startedAt', sortBy: 'startedAt',
sortDesc: true, sortDesc: true,
...@@ -55,13 +70,16 @@ export default { ...@@ -55,13 +70,16 @@ export default {
}, },
computed: { computed: {
isEmpty() { isEmpty() {
return !this.alerts?.list?.length; return !this.alerts.length;
}, },
loading() { isLoadingAlerts() {
return this.$apollo.queries.alerts.loading; return this.$apollo.queries.alerts.loading;
}, },
isLoadingFirstAlerts() {
return this.isLoadingAlerts && this.isEmpty;
},
showNoAlertsMsg() { showNoAlertsMsg() {
return this.isEmpty && !this.loading && !this.errored && !this.isErrorAlertDismissed; return this.isEmpty && !this.isLoadingAlerts && !this.errored && !this.isErrorAlertDismissed;
}, },
}, },
methods: { methods: {
...@@ -69,6 +87,23 @@ export default { ...@@ -69,6 +87,23 @@ export default {
this.errored = false; this.errored = false;
this.isErrorAlertDismissed = true; 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 }) { fetchSortedData({ sortBy, sortDesc }) {
const sortingDirection = sortDesc ? 'DESC' : 'ASC'; const sortingDirection = sortDesc ? 'DESC' : 'ASC';
const sortingColumn = convertToSnakeCase(sortBy).toUpperCase(); const sortingColumn = convertToSnakeCase(sortBy).toUpperCase();
...@@ -101,17 +136,18 @@ export default { ...@@ -101,17 +136,18 @@ export default {
<gl-table <gl-table
class="alert-management-table" class="alert-management-table"
:items="alerts ? alerts.list : []" :busy="isLoadingFirstAlerts"
:items="alerts"
:fields="$options.i18n.FIELDS" :fields="$options.i18n.FIELDS"
:show-empty="true"
:busy="loading"
stacked="md" stacked="md"
:no-local-sorting="true" :no-local-sorting="true"
:sort-direction="sortDirection" :sort-direction="sortDirection"
:sort-desc.sync="sortDesc" :sort-desc.sync="sortDesc"
:sort-by.sync="sortBy" :sort-by.sync="sortBy"
thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
sort-icon-left sort-icon-left
responsive responsive
show-empty
@sort-changed="fetchSortedData" @sort-changed="fetchSortedData"
> >
<template #cell(startedAt)="{ item }"> <template #cell(startedAt)="{ item }">
...@@ -138,20 +174,30 @@ export default { ...@@ -138,20 +174,30 @@ export default {
</div> </div>
</template> </template>
<template #table-busy>
<gl-skeleton-loading
v-for="n in $options.PAGE_SIZE"
:key="n"
class="gl-m-3 js-skeleton-loader"
:lines="1"
data-testid="threat-alerts-busy-state"
/>
</template>
<template #empty> <template #empty>
<div data-testid="threat-alerts-empty-state"> <div data-testid="threat-alerts-empty-state">
{{ $options.i18n.MESSAGES.NO_ALERTS }} {{ $options.i18n.MESSAGES.NO_ALERTS }}
</div> </div>
</template> </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-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> </div>
</template> </template>
...@@ -22,21 +22,23 @@ export const FIELDS = [ ...@@ -22,21 +22,23 @@ export const FIELDS = [
key: 'startedAt', key: 'startedAt',
label: s__('ThreatMonitoring|Date and time'), label: s__('ThreatMonitoring|Date and time'),
thAttr: { 'data-testid': 'threat-alerts-started-at-header' }, thAttr: { 'data-testid': 'threat-alerts-started-at-header' },
thClass: `gl-w-15p`, thClass: `gl-bg-white! gl-w-15p`,
tdClass: `gl-pl-6!`, tdClass: `gl-pl-6!`,
sortable: true, sortable: true,
}, },
{ {
key: 'alertLabel', key: 'alertLabel',
label: s__('ThreatMonitoring|Name'), label: s__('ThreatMonitoring|Name'),
thClass: `gl-pointer-events-none`, thClass: `gl-bg-white! gl-pointer-events-none`,
}, },
{ {
key: 'status', key: 'status',
label: s__('ThreatMonitoring|Status'), label: s__('ThreatMonitoring|Status'),
thAttr: { 'data-testid': 'threat-alerts-status-header' }, thAttr: { 'data-testid': 'threat-alerts-status-header' },
thClass: `gl-w-15p`, thClass: `gl-bg-white! gl-w-15p`,
tdClass: `gl-pl-6!`, tdClass: `gl-pl-6!`,
sortable: true, 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 { mount } from '@vue/test-utils';
import AlertsList from 'ee/threat_monitoring/components/alerts/alerts_list.vue'; import AlertsList from 'ee/threat_monitoring/components/alerts/alerts_list.vue';
const alerts = { const alerts = [
list: [
{ {
iid: '01', iid: '01',
title: 'Issue 01', title: 'Issue 01',
...@@ -28,14 +27,24 @@ const alerts = { ...@@ -28,14 +27,24 @@ const alerts = {
status: 'IGNORED', status: 'IGNORED',
startedAt: '2020-10-29T13:37:55Z', startedAt: '2020-10-29T13:37:55Z',
}, },
], ];
pageInfo: {},
const pageInfo = {
endCursor: 'eyJpZCI6IjIwIiwic3RhcnRlZF9hdCI6IjIwMjAtMTItMDMgMjM6MTI6NDkuODM3Mjc1MDAwIFVUQyJ9',
hasNextPage: true,
hasPreviousPage: false,
startCursor: 'eyJpZCI6IjM5Iiwic3RhcnRlZF9hdCI6IjIwMjAtMTItMDQgMTg6MDE6MDcuNzY1ODgyMDAwIFVUQyJ9',
}; };
describe('AlertsList component', () => { describe('AlertsList component', () => {
let wrapper; let wrapper;
const apolloMock = { const apolloMock = {
queries: { alerts: { loading: false } }, queries: {
alerts: {
fetchMore: jest.fn().mockResolvedValue(),
loading: false,
},
},
}; };
const findUnconfiguredAlert = () => wrapper.find("[data-testid='threat-alerts-unconfigured']"); const findUnconfiguredAlert = () => wrapper.find("[data-testid='threat-alerts-unconfigured']");
...@@ -47,7 +56,8 @@ describe('AlertsList component', () => { ...@@ -47,7 +56,8 @@ describe('AlertsList component', () => {
const findStatusColumn = () => wrapper.find("[data-testid='threat-alerts-status']"); const findStatusColumn = () => wrapper.find("[data-testid='threat-alerts-status']");
const findStatusColumnHeader = () => wrapper.find("[data-testid='threat-alerts-status-header']"); const findStatusColumnHeader = () => wrapper.find("[data-testid='threat-alerts-status-header']");
const findEmptyState = () => wrapper.find("[data-testid='threat-alerts-empty-state']"); 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 = {} } = {}) => { const createWrapper = ({ $apollo = apolloMock, data = {} } = {}) => {
wrapper = mount(AlertsList, { wrapper = mount(AlertsList, {
...@@ -61,6 +71,7 @@ describe('AlertsList component', () => { ...@@ -61,6 +71,7 @@ describe('AlertsList component', () => {
stubs: { stubs: {
GlAlert: true, GlAlert: true,
GlLoadingIcon: true, GlLoadingIcon: true,
GlIntersectionObserver: true,
}, },
data() { data() {
return data; return data;
...@@ -75,7 +86,7 @@ describe('AlertsList component', () => { ...@@ -75,7 +86,7 @@ describe('AlertsList component', () => {
describe('default state', () => { describe('default state', () => {
beforeEach(() => { beforeEach(() => {
createWrapper({ data: { alerts } }); createWrapper({ data: { alerts, pageInfo } });
}); });
it('does show all columns', () => { it('does show all columns', () => {
...@@ -96,7 +107,11 @@ describe('AlertsList component', () => { ...@@ -96,7 +107,11 @@ describe('AlertsList component', () => {
expect(findErrorAlert().exists()).toBe(false); 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(wrapper.vm.sort).toBe('STARTED_AT_DESC');
expect(findStartedAtColumnHeader().attributes('aria-sort')).toBe('descending'); expect(findStartedAtColumnHeader().attributes('aria-sort')).toBe('descending');
}); });
...@@ -120,7 +135,7 @@ describe('AlertsList component', () => { ...@@ -120,7 +135,7 @@ describe('AlertsList component', () => {
describe('empty state', () => { describe('empty state', () => {
beforeEach(() => { beforeEach(() => {
createWrapper({ data: { alerts: { list: [] } } }); createWrapper({ data: { alerts: [] } });
}); });
it('does show the empty state', () => { it('does show the empty state', () => {
...@@ -141,7 +156,7 @@ describe('AlertsList component', () => { ...@@ -141,7 +156,7 @@ describe('AlertsList component', () => {
}); });
it('does show the loading state', () => { it('does show the loading state', () => {
expect(findGlLoadingIcon().exists()).toBe(true); expect(findGlSkeletonLoading().exists()).toBe(true);
}); });
it('does not show all columns', () => { it('does not show all columns', () => {
...@@ -177,4 +192,18 @@ describe('AlertsList component', () => { ...@@ -177,4 +192,18 @@ describe('AlertsList component', () => {
expect(findUnconfiguredAlert().exists()).toBe(false); 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