Commit 951e3a94 authored by David Pisek's avatar David Pisek Committed by Illya Klymov

Render profiles list

* Add GraphQL query
* Add profiles-prop to listings component
* Render profiles list
* Specs
parent b8f5c54d
<script>
import * as Sentry from '@sentry/browser';
import { GlButton, GlTab, GlTabs } from '@gitlab/ui';
import ProfilesListing from './dast_profiles_listing.vue';
import ProfilesList from './dast_profiles_list.vue';
import dastSiteProfilesQuery from '../graphql/dast_site_profiles.query.graphql';
export default {
components: {
GlButton,
GlTab,
GlTabs,
ProfilesListing,
ProfilesList,
},
props: {
newDastSiteProfilePath: {
type: String,
required: true,
},
projectFullPath: {
type: String,
required: true,
},
},
data() {
return {
siteProfiles: [],
siteProfilesPageInfo: {},
hasSiteProfilesLoadingError: false,
};
},
apollo: {
siteProfiles: {
query: dastSiteProfilesQuery,
variables() {
return {
fullPath: this.projectFullPath,
first: this.$options.profilesPerPage,
};
},
result({ data, error }) {
if (!error) {
this.siteProfilesPageInfo = data.project.siteProfiles.pageInfo;
}
},
update(data) {
const siteProfileEdges = data?.project?.siteProfiles?.edges ?? [];
return siteProfileEdges.map(({ node }) => node);
},
error(e) {
this.handleLoadingError(e);
},
},
},
computed: {
hasMoreSiteProfiles() {
return this.siteProfilesPageInfo.hasNextPage;
},
isLoadingSiteProfiles() {
return this.$apollo.queries.siteProfiles.loading;
},
},
methods: {
handleLoadingError(e) {
Sentry.captureException(e);
this.hasSiteProfilesLoadingError = true;
},
fetchMoreProfiles() {
const { $apollo, siteProfilesPageInfo } = this;
this.hasSiteProfilesLoadingError = false;
$apollo.queries.siteProfiles
.fetchMore({
variables: { after: siteProfilesPageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
const newResult = { ...fetchMoreResult };
const previousEdges = previousResult.project.siteProfiles.edges;
const newEdges = newResult.project.siteProfiles.edges;
newResult.project.siteProfiles.edges = [...previousEdges, ...newEdges];
return newResult;
},
})
.catch(e => {
this.handleLoadingError(e);
});
},
},
profilesPerPage: 10,
};
</script>
......@@ -42,13 +116,21 @@ export default {
}}
</p>
</header>
<gl-tabs>
<gl-tab>
<template #title>
<span>{{ s__('DastProfiles|Site Profiles') }}</span>
</template>
<profiles-listing />
<profiles-list
:has-error="hasSiteProfilesLoadingError"
:has-more-profiles-to-load="hasMoreSiteProfiles"
:is-loading="isLoadingSiteProfiles"
:profiles-per-page="$options.profilesPerPage"
:profiles="siteProfiles"
@loadMoreProfiles="fetchMoreProfiles"
/>
</gl-tab>
</gl-tabs>
</section>
......
<script>
import {
GlAlert,
GlButton,
GlIcon,
GlSkeletonLoader,
GlTable,
GlTooltipDirective,
} from '@gitlab/ui';
export default {
components: {
GlAlert,
GlButton,
GlIcon,
GlSkeletonLoader,
GlTable,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
profiles: {
type: Array,
required: true,
},
hasError: {
type: Boolean,
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
profilesPerPage: {
type: Number,
required: true,
},
hasMoreProfilesToLoad: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isErrorDismissed: false,
};
},
computed: {
hasProfiles() {
return this.profiles.length > 0;
},
isLoadingInitialProfiles() {
return this.isLoading && !this.hasProfiles;
},
shouldShowTable() {
return this.isLoadingInitialProfiles || this.hasProfiles || this.hasError;
},
},
tableFields: [
{
key: 'profileName',
class: 'gl-word-break-all',
},
{
key: 'targetUrl',
class: 'gl-word-break-all',
},
{
key: 'validationStatus',
// NOTE: hidden for now, since the site validation is still WIP and will be finished in an upcoming iteration
// roadmap: https://gitlab.com/groups/gitlab-org/-/epics/2912#ui-configuration
class: 'gl-display-none',
},
{
key: 'actions',
},
],
};
</script>
<template>
<section>
<div v-if="shouldShowTable">
<gl-table
:aria-label="s__('DastProfiles|Site Profiles')"
:busy="isLoadingInitialProfiles"
:fields="$options.tableFields"
:items="profiles"
stacked="md"
thead-class="gl-display-none"
>
<template #cell(profileName)="{ value }">
<strong>{{ value }}</strong>
</template>
<template #cell(validationStatus)="{ value }">
<span>
<gl-icon
:size="16"
class="gl-vertical-align-text-bottom gl-text-gray-600"
name="information-o"
/>
{{ value }}
</span>
</template>
<template #cell(actions)>
<!--
NOTE: The tooltip and `disable` on the button is temporary until the edit feature has been implemented
further details: https://gitlab.com/gitlab-org/gitlab/-/issues/222479#proposal
-->
<span
v-gl-tooltip.hover
:title="
s__(
'DastProfiles|Edit feature will come soon. Please create a new profile if changes needed',
)
"
>
<gl-button disabled>{{ __('Edit') }}</gl-button>
</span>
</template>
<template #table-busy>
<div v-for="i in profilesPerPage" :key="i" data-testid="loadingIndicator">
<gl-skeleton-loader :width="1248" :height="52">
<rect x="0" y="16" width="300" height="20" rx="4" />
<rect x="380" y="16" width="300" height="20" rx="4" />
<rect x="770" y="16" width="300" height="20" rx="4" />
<rect x="1140" y="11" width="50" height="30" rx="4" />
</gl-skeleton-loader>
</div>
</template>
<template v-if="hasError && !isErrorDismissed" #bottom-row>
<td :colspan="$options.tableFields.length">
<gl-alert class="gl-my-4" variant="danger" :dismissible="false">
{{
s__(
'DastProfiles|Error fetching the profiles list. Please check your network connection and try again.',
)
}}
</gl-alert>
</td>
</template>
</gl-table>
<p v-if="hasMoreProfilesToLoad" class="gl-display-flex gl-justify-content-center">
<gl-button
data-testid="loadMore"
:loading="isLoading && !hasError"
@click="$emit('loadMoreProfiles')"
>{{ __('Load more') }}</gl-button
>
</p>
</div>
<p v-else class="gl-my-4">
{{ s__('DastProfiles|No profiles created yet') }}
</p>
</section>
</template>
<template>
<section class="gl-py-3">
<p>{{ s__('DastProfiles|No profiles created yet') }}</p>
</section>
</template>
......@@ -10,11 +10,12 @@ export default () => {
}
const {
dataset: { newDastSiteProfilePath },
dataset: { newDastSiteProfilePath, projectFullPath },
} = el;
const props = {
newDastSiteProfilePath,
projectFullPath,
};
return new Vue({
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query DASTSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
project(fullPath: $fullPath) {
siteProfiles: DASTSiteProfiles(after: $after, before: $before, first: $first, last: $last) {
pageInfo {
...PageInfo
}
edges {
cursor
node {
id
profileName
targetUrl
validationStatus
}
}
}
}
}
......@@ -2,4 +2,5 @@
- breadcrumb_title s_('DastProfiles|Manage profiles')
- page_title s_('DastProfiles|Manage profiles')
.js-dast-profiles{ data: { new_dast_site_profile_path: new_namespace_project_dast_site_profile_path(namespace_id: @project.namespace, project_id: @project.path) } }
.js-dast-profiles{ data: { new_dast_site_profile_path: new_namespace_project_dast_site_profile_path(namespace_id: @project.namespace, project_id: @project.path),
project_full_path: @project.path_with_namespace } }
import { merge } from 'lodash';
import { mount } from '@vue/test-utils';
import { within } from '@testing-library/dom';
import DastProfilesList from 'ee/dast_profiles/components/dast_profiles_list.vue';
describe('EE - DastProfilesList', () => {
let wrapper;
const createComponent = (options = {}) => {
const defaultProps = {
profiles: [],
hasMorePages: false,
profilesPerPage: 10,
};
wrapper = mount(
DastProfilesList,
merge(
{},
{
propsData: defaultProps,
},
options,
),
);
};
const withinComponent = () => within(wrapper.element);
const getTable = () => withinComponent().getByRole('table', { name: /site profiles/i });
const getAllRowGroups = () => within(getTable()).getAllByRole('rowgroup');
const getTableBody = () => {
// first item is the table head
const [, tableBody] = getAllRowGroups();
return tableBody;
};
const getAllTableRows = () => within(getTableBody()).getAllByRole('row');
const getLoadMoreButton = () => wrapper.find('[data-testid="loadMore"]');
const getAllLoadingIndicators = () => withinComponent().queryAllByTestId('loadingIndicator');
const getErrorMessage = () => withinComponent().queryByText(/error fetching the profiles list/i);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when loading', () => {
const profilesPerPage = 10;
describe('initial load', () => {
beforeEach(() => {
createComponent({ propsData: { isLoading: true, profilesPerPage } });
});
it('shows a loading indicator for each profile item', () => {
expect(getAllLoadingIndicators()).toHaveLength(profilesPerPage);
});
});
describe('with profiles and more to load', () => {
beforeEach(() => {
createComponent({
propsData: {
isLoading: true,
profilesPerPage,
profiles: [{}],
hasMoreProfilesToLoad: true,
},
});
});
it('does not show a loading indicator for each profile item', () => {
expect(getAllLoadingIndicators()).toHaveLength(0);
});
it('sets the the "load more" button into a loading state', () => {
expect(getLoadMoreButton().props('loading')).toBe(true);
});
});
});
describe('with no existing profiles', () => {
it('shows a message to indicate that no profiles exist', () => {
createComponent();
const emptyStateMessage = withinComponent().getByText(/no profiles created yet/i);
expect(emptyStateMessage).not.toBe(null);
});
});
describe('with existing profiles', () => {
const profiles = [
{
id: 1,
profileName: 'Profile 1',
targetUrl: 'http://example-1.com',
validationStatus: 'Pending',
},
{
id: 2,
profileName: 'Profile 2',
targetUrl: 'http://example-2.com',
validationStatus: 'Pending',
},
];
const getTableRowForProfile = profile => getAllTableRows()[profiles.indexOf(profile)];
it('does not show loading indicators', () => {
createComponent({});
expect(getAllLoadingIndicators()).toHaveLength(0);
});
describe('profiles list', () => {
beforeEach(() => {
createComponent({ propsData: { profiles } });
});
it('renders a list of profiles', () => {
expect(getTable()).not.toBe(null);
expect(getAllTableRows()).toHaveLength(profiles.length);
});
it.each(profiles)('renders list item %# correctly', profile => {
const [
profileCell,
targetUrlCell,
validationStatusCell,
actionsCell,
] = getTableRowForProfile(profile).cells;
expect(profileCell.innerText).toContain(profile.profileName);
expect(targetUrlCell.innerText).toContain(profile.targetUrl);
expect(validationStatusCell.innerText).toContain(profile.validationStatus);
expect(within(actionsCell).getByRole('button', { name: /edit/i })).not.toBe(null);
});
});
describe('load more profiles', () => {
it('does not show that there are more projects to be loaded per default', () => {
createComponent({ propsData: { profiles } });
expect(getLoadMoreButton().exists()).toBe(false);
});
describe('with more profiles', () => {
beforeEach(() => {
createComponent({ propsData: { profiles, hasMoreProfilesToLoad: true } });
});
it('shows that there are more projects to be loaded', () => {
expect(getLoadMoreButton().exists()).toBe(true);
});
it('emits "loadMore" when the load-more button is clicked', async () => {
expect(wrapper.emitted('loadMoreProfiles')).toBe(undefined);
await getLoadMoreButton().trigger('click');
expect(wrapper.emitted('loadMoreProfiles')).toEqual(expect.any(Array));
});
});
});
});
describe('errors', () => {
it('does not show an error message by default', () => {
createComponent();
expect(getErrorMessage()).toBe(null);
});
it('shows an error message', () => {
createComponent({ propsData: { hasError: true } });
expect(getErrorMessage()).not.toBe(null);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { within } from '@testing-library/dom';
import DastProfilesListing from 'ee/dast_profiles/components/dast_profiles_listing.vue';
describe('EE - DastProfilesListing', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(DastProfilesListing);
};
const withinComponent = () => within(wrapper.element);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('empty state', () => {
it('shows a message to indicate that no profiles exist', () => {
const emptyStateMessage = withinComponent().getByText(/no profiles created yet/i);
expect(emptyStateMessage).not.toBe(null);
});
});
});
import { mount } from '@vue/test-utils';
import { mount, shallowMount } from '@vue/test-utils';
import { within } from '@testing-library/dom';
import { merge } from 'lodash';
import DastProfiles from 'ee/dast_profiles/components/dast_profiles.vue';
import DastProfilesList from 'ee/dast_profiles/components/dast_profiles_list.vue';
const TEST_NEW_DAST_SITE_PROFILE_PATH = '/-/on_demand_scans/site_profiles/new';
const TEST_PROJECT_FULL_PATH = '/namespace/project';
describe('EE - DastProfiles', () => {
let wrapper;
const createComponent = () => {
const createComponentFactory = (mountFn = shallowMount) => (options = {}) => {
const defaultProps = {
newDastSiteProfilePath: TEST_NEW_DAST_SITE_PROFILE_PATH,
projectFullPath: TEST_PROJECT_FULL_PATH,
};
wrapper = mount(DastProfiles, {
propsData: defaultProps,
});
const defaultMocks = {
$apollo: {
queries: {
siteProfiles: {},
},
},
};
wrapper = mountFn(
DastProfiles,
merge(
{},
{
propsData: defaultProps,
mocks: defaultMocks,
},
options,
),
);
};
const withinComponent = () => within(wrapper.element);
const createComponent = createComponentFactory();
const createFullComponent = createComponentFactory(mount);
beforeEach(() => {
createComponent();
});
const withinComponent = () => within(wrapper.element);
const getSiteProfilesComponent = () => wrapper.find(DastProfilesList);
afterEach(() => {
wrapper.destroy();
});
describe('header', () => {
beforeEach(() => {
createFullComponent();
});
it('shows a heading that describes the purpose of the page', () => {
const heading = withinComponent().getByRole('heading', { name: /manage profiles/i });
......@@ -42,6 +66,10 @@ describe('EE - DastProfiles', () => {
});
describe('tabs', () => {
beforeEach(() => {
createFullComponent();
});
it('shows a tab-list that contains the different profile categories', () => {
const tabList = withinComponent().getByRole('tablist');
......@@ -63,4 +91,51 @@ describe('EE - DastProfiles', () => {
},
);
});
describe('site profiles', () => {
beforeEach(() => {
createComponent();
});
it('passes down the correct default props', () => {
expect(getSiteProfilesComponent().props()).toEqual({
hasError: false,
hasMoreProfilesToLoad: false,
isLoading: false,
profilesPerPage: expect.any(Number),
profiles: [],
});
});
it.each([true, false])('passes down the error state', async hasError => {
wrapper.setData({ hasSiteProfilesLoadingError: hasError });
await wrapper.vm.$nextTick();
expect(getSiteProfilesComponent().props('hasError')).toBe(hasError);
});
it.each([true, false])('passes down the pagination information', async hasNextPage => {
wrapper.setData({ siteProfilesPageInfo: { hasNextPage } });
await wrapper.vm.$nextTick();
expect(getSiteProfilesComponent().props('hasMoreProfilesToLoad')).toBe(hasNextPage);
});
it.each([true, false])('passes down the loading state', loading => {
createComponent({ mocks: { $apollo: { queries: { siteProfiles: { loading } } } } });
expect(getSiteProfilesComponent().props('isLoading')).toBe(loading);
});
it('passes down the profiles data', async () => {
const siteProfiles = [{}];
wrapper.setData({ siteProfiles });
await wrapper.vm.$nextTick();
expect(getSiteProfilesComponent().props('profiles')).toBe(siteProfiles);
});
});
});
......@@ -15,4 +15,8 @@ RSpec.describe "projects/dast_profiles/index", type: :view do
it 'passes new dast site profile path' do
expect(rendered).to include '/on_demand_scans/profiles/dast_site_profiles/new'
end
it 'passes project\'s full path' do
expect(rendered).to include @project.path_with_namespace
end
end
......@@ -7507,6 +7507,12 @@ msgstr ""
msgid "DastProfiles|Do you want to discard this site profile?"
msgstr ""
msgid "DastProfiles|Edit feature will come soon. Please create a new profile if changes needed"
msgstr ""
msgid "DastProfiles|Error fetching the profiles list. Please check your network connection and try again."
msgstr ""
msgid "DastProfiles|Manage Profiles"
msgstr ""
......@@ -14222,6 +14228,9 @@ msgstr ""
msgid "Live preview"
msgstr ""
msgid "Load more"
msgstr ""
msgid "Loading"
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