Commit 77d2f7e0 authored by Illya Klymov's avatar Illya Klymov

Merge branch '341364-dast-view-scans-all-tab-states' into 'master'

Implement loading and error state in DAST scans view

See merge request gitlab-org/gitlab!72994
parents 55e88eaa 315b3848
<script> <script>
import { GlTab, GlBadge, GlLink, GlTable, GlKeysetPagination } from '@gitlab/ui'; import {
GlTab,
GlBadge,
GlLink,
GlTable,
GlKeysetPagination,
GlAlert,
GlSkeletonLoader,
} from '@gitlab/ui';
import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { DAST_SHORT_NAME } from '~/security_configuration/components/constants'; import { DAST_SHORT_NAME } from '~/security_configuration/components/constants';
import { __ } from '~/locale'; import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { scrollToElement } from '~/lib/utils/common_utils'; import { scrollToElement } from '~/lib/utils/common_utils';
import EmptyState from '../empty_state.vue'; import EmptyState from '../empty_state.vue';
...@@ -26,6 +34,8 @@ export default { ...@@ -26,6 +34,8 @@ export default {
GlLink, GlLink,
GlTable, GlTable,
GlKeysetPagination, GlKeysetPagination,
GlAlert,
GlSkeletonLoader,
CiBadgeLink, CiBadgeLink,
TimeAgoTooltip, TimeAgoTooltip,
EmptyState, EmptyState,
...@@ -78,6 +88,9 @@ export default { ...@@ -78,6 +88,9 @@ export default {
} }
return pipelines; return pipelines;
}, },
error() {
this.hasError = true;
},
pollInterval: PIPELINES_POLL_INTERVAL, pollInterval: PIPELINES_POLL_INTERVAL,
}, },
}, },
...@@ -99,8 +112,14 @@ export default { ...@@ -99,8 +112,14 @@ export default {
}; };
}, },
computed: { computed: {
pipelineNodes() {
return this.pipelines?.nodes ?? [];
},
hasPipelines() { hasPipelines() {
return Boolean(this.pipelines?.nodes?.length); return this.pipelineNodes.length > 0;
},
pageInfo() {
return this.pipelines?.pageInfo;
}, },
tableFields() { tableFields() {
return this.fields.map(({ key, label }) => ({ return this.fields.map(({ key, label }) => ({
...@@ -111,6 +130,13 @@ export default { ...@@ -111,6 +130,13 @@ export default {
})); }));
}, },
}, },
watch: {
hasPipelines(hasPipelines) {
if (this.hasError && hasPipelines) {
this.hasError = false;
}
},
},
methods: { methods: {
resetCursor() { resetCursor() {
this.cursor = { ...defaultCursor }; this.cursor = { ...defaultCursor };
...@@ -142,6 +168,9 @@ export default { ...@@ -142,6 +168,9 @@ export default {
i18n: { i18n: {
previousPage: __('Prev'), previousPage: __('Prev'),
nextPage: __('Next'), nextPage: __('Next'),
errorMessage: s__(
'OnDemandScans|Could not fetch on-demand scans. Please refresh the page, or try again later.',
),
}, },
}; };
</script> </script>
...@@ -152,13 +181,24 @@ export default { ...@@ -152,13 +181,24 @@ export default {
{{ title }} {{ title }}
<gl-badge size="sm" class="gl-tab-counter-badge">{{ itemsCount }}</gl-badge> <gl-badge size="sm" class="gl-tab-counter-badge">{{ itemsCount }}</gl-badge>
</template> </template>
<template v-if="hasPipelines"> <template v-if="$apollo.queries.pipelines.loading || hasPipelines">
<gl-table <gl-table
thead-class="gl-border-b-solid gl-border-gray-100 gl-border-1" thead-class="gl-border-b-solid gl-border-gray-100 gl-border-1"
:fields="tableFields" :fields="tableFields"
:items="pipelines.nodes" :items="pipelineNodes"
:busy="$apollo.queries.pipelines.loading"
stacked="md" stacked="md"
> >
<template #table-busy>
<gl-skeleton-loader v-for="i in 20" :key="i" :width="1000" :height="45">
<rect width="85" height="20" x="0" y="5" rx="4" />
<rect width="100" height="20" x="150" y="5" rx="4" />
<rect width="150" height="20" x="300" y="5" rx="4" />
<rect width="100" height="20" x="500" y="5" rx="4" />
<rect width="150" height="20" x="655" y="5" rx="4" />
<rect width="70" height="20" x="855" y="5" rx="4" />
</gl-skeleton-loader>
</template>
<template #cell(detailedStatus)="{ item }"> <template #cell(detailedStatus)="{ item }">
<div class="gl-my-3"> <div class="gl-my-3">
<ci-badge-link :status="item.detailedStatus" /> <ci-badge-link :status="item.detailedStatus" />
...@@ -181,7 +221,7 @@ export default { ...@@ -181,7 +221,7 @@ export default {
<div class="gl-display-flex gl-justify-content-center"> <div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination <gl-keyset-pagination
data-testid="pagination" data-testid="pagination"
v-bind="pipelines.pageInfo" v-bind="pageInfo"
:prev-text="$options.i18n.previousPage" :prev-text="$options.i18n.previousPage"
:next-text="$options.i18n.nextPage" :next-text="$options.i18n.nextPage"
@prev="prevPage" @prev="prevPage"
...@@ -189,6 +229,15 @@ export default { ...@@ -189,6 +229,15 @@ export default {
/> />
</div> </div>
</template> </template>
<gl-alert
v-else-if="hasError"
variant="danger"
:dismissible="false"
class="gl-my-4"
data-testid="error-alert"
>
{{ $options.i18n.errorMessage }}
</gl-alert>
<empty-state v-else :title="emptyStateTitle" :text="emptyStateText" no-primary-button /> <empty-state v-else :title="emptyStateTitle" :text="emptyStateText" no-primary-button />
</gl-tab> </gl-tab>
</template> </template>
import { GlTab, GlTable } from '@gitlab/ui'; import { GlTab, GlTable, GlAlert } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils'; import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import allPipelinesWithPipelinesMock from 'test_fixtures/graphql/on_demand_scans/graphql/on_demand_scans.query.graphql.with_pipelines.json'; import allPipelinesWithPipelinesMock from 'test_fixtures/graphql/on_demand_scans/graphql/on_demand_scans.query.graphql.with_pipelines.json';
...@@ -32,12 +32,18 @@ describe('BaseTab', () => { ...@@ -32,12 +32,18 @@ describe('BaseTab', () => {
const findTable = () => wrapper.findComponent(GlTable); const findTable = () => wrapper.findComponent(GlTable);
const findEmptyState = () => wrapper.findComponent(EmptyState); const findEmptyState = () => wrapper.findComponent(EmptyState);
const findPagination = () => wrapper.findByTestId('pagination'); const findPagination = () => wrapper.findByTestId('pagination');
const findErrorAlert = () => wrapper.findComponent(GlAlert);
// Helpers // Helpers
const createMockApolloProvider = () => { const createMockApolloProvider = () => {
return createMockApollo([[onDemandScansQuery, requestHandler]]); return createMockApollo([[onDemandScansQuery, requestHandler]]);
}; };
const navigateToPage = (direction) => {
findPagination().vm.$emit(direction);
return wrapper.vm.$nextTick();
};
const createComponent = (propsData) => { const createComponent = (propsData) => {
router = createRouter(); router = createRouter();
wrapper = shallowMountExtended(BaseTab, { wrapper = shallowMountExtended(BaseTab, {
...@@ -66,7 +72,7 @@ describe('BaseTab', () => { ...@@ -66,7 +72,7 @@ describe('BaseTab', () => {
`, `,
}), }),
GlTable: stubComponent(GlTable, { GlTable: stubComponent(GlTable, {
props: ['items'], props: ['items', 'busy'],
}), }),
}, },
}); });
...@@ -95,6 +101,16 @@ describe('BaseTab', () => { ...@@ -95,6 +101,16 @@ describe('BaseTab', () => {
}); });
}); });
it('puts the table in the busy state until the request resolves', async () => {
createComponent();
expect(findTable().props('busy')).toBe(true);
await waitForPromises();
expect(findTable().props('busy')).toBe(false);
});
it('resets the route if no pipeline matches the cursor', async () => { it('resets the route if no pipeline matches the cursor', async () => {
setWindowLocation('#?after=nothingToSeeHere'); setWindowLocation('#?after=nothingToSeeHere');
requestHandler = jest.fn().mockResolvedValue(allPipelinesWithoutPipelinesMock); requestHandler = jest.fn().mockResolvedValue(allPipelinesWithoutPipelinesMock);
...@@ -128,8 +144,8 @@ describe('BaseTab', () => { ...@@ -128,8 +144,8 @@ describe('BaseTab', () => {
); );
}); });
it('when navigating to another page, scrolls back to the top', () => { it('when navigating to another page, scrolls back to the top', async () => {
findPagination().vm.$emit('next'); await navigateToPage('next');
expect(scrollToElement).toHaveBeenCalledWith(wrapper.vm.$el); expect(scrollToElement).toHaveBeenCalledWith(wrapper.vm.$el);
}); });
...@@ -138,18 +154,16 @@ describe('BaseTab', () => { ...@@ -138,18 +154,16 @@ describe('BaseTab', () => {
expect(Object.keys(router.currentRoute.query)).not.toContain('after'); expect(Object.keys(router.currentRoute.query)).not.toContain('after');
expect(requestHandler).toHaveBeenCalledTimes(1); expect(requestHandler).toHaveBeenCalledTimes(1);
findPagination().vm.$emit('next'); await navigateToPage('next');
await wrapper.vm.$nextTick();
expect(Object.keys(router.currentRoute.query)).toContain('after'); expect(Object.keys(router.currentRoute.query)).toContain('after');
expect(requestHandler).toHaveBeenCalledTimes(2); expect(requestHandler).toHaveBeenCalledTimes(2);
}); });
it('when navigating back to the previous page, the route is updated and pipelines are fetched', async () => { it('when navigating back to the previous page, the route is updated and pipelines are fetched', async () => {
findPagination().vm.$emit('next'); await navigateToPage('next');
await wrapper.vm.$nextTick(); await waitForPromises();
findPagination().vm.$emit('prev'); await navigateToPage('prev');
await wrapper.vm.$nextTick();
expect(Object.keys(router.currentRoute.query)).not.toContain('after'); expect(Object.keys(router.currentRoute.query)).not.toContain('after');
expect(Object.keys(router.currentRoute.query)).toContain('before'); expect(Object.keys(router.currentRoute.query)).toContain('before');
...@@ -167,4 +181,34 @@ describe('BaseTab', () => { ...@@ -167,4 +181,34 @@ describe('BaseTab', () => {
expect(findEmptyState().exists()).toBe(true); expect(findEmptyState().exists()).toBe(true);
}); });
}); });
describe('when the request errors out', () => {
let respondWithError;
beforeEach(async () => {
respondWithError = true;
requestHandler = () => {
const response = respondWithError
? Promise.reject()
: Promise.resolve(allPipelinesWithPipelinesMock);
respondWithError = false;
return response;
};
createComponent();
await waitForPromises();
});
it('shows an error alert', () => {
expect(findErrorAlert().exists()).toBe(true);
});
it('removes the alert if the next request succeeds', async () => {
expect(findErrorAlert().exists()).toBe(true);
wrapper.vm.$apollo.queries.pipelines.refetch();
await waitForPromises();
expect(findErrorAlert().exists()).toBe(false);
});
});
}); });
...@@ -23913,6 +23913,9 @@ msgstr "" ...@@ -23913,6 +23913,9 @@ msgstr ""
msgid "OnCallSchedules|Your schedule has been successfully created. To add individual users to this schedule, use the Add a rotation button. To enable notifications for this schedule, you must also create an %{linkStart}escalation policy%{linkEnd}." msgid "OnCallSchedules|Your schedule has been successfully created. To add individual users to this schedule, use the Add a rotation button. To enable notifications for this schedule, you must also create an %{linkStart}escalation policy%{linkEnd}."
msgstr "" msgstr ""
msgid "OnDemandScans|Could not fetch on-demand scans. Please refresh the page, or try again later."
msgstr ""
msgid "OnDemandScans|Could not fetch scanner profiles. Please refresh the page, or try again later." msgid "OnDemandScans|Could not fetch scanner profiles. Please refresh the page, or try again later."
msgstr "" msgstr ""
......
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