Skip to content
Snippets Groups Projects
Commit 277ec524 authored by Illya Klymov's avatar Illya Klymov :rocket:
Browse files

Introduce lazy loading of projects list in framework drawer

* show related projects only for top-level group for now

Changelog: added
EE: true
parent e616819d
No related branches found
No related tags found
3 merge requests!181325Fix ambiguous `created_at` in project.rb,!179611Draft: Rebase CR approach for zoekt assignments,!175197Introduce better project display in compliance center
Showing
with 323 additions and 83 deletions
......@@ -2,10 +2,8 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import VueRouter from 'vue-router';
import { parseBoolean } from '~/lib/utils/common_utils';
import createDefaultClient from '~/lib/graphql';
import { createRouter } from 'ee/compliance_dashboard/router';
import { createRouter } from './router';
import { apolloProvider } from './graphql/client';
import {
ROUTE_FRAMEWORKS,
ROUTE_STANDARDS_ADHERENCE,
......@@ -46,10 +44,6 @@ export default () => {
Vue.use(VueApollo);
Vue.use(VueRouter);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const routes = Object.entries({
[ROUTE_STANDARDS_ADHERENCE]: parseBoolean(featureAdherenceReportEnabled),
[ROUTE_VIOLATIONS]: parseBoolean(featureViolationsReportEnabled),
......
<script>
import { GlBadge, GlDrawer, GlButton, GlLabel, GlLink, GlSprintf, GlPopover } from '@gitlab/ui';
import {
GlBadge,
GlDrawer,
GlButton,
GlLabel,
GlLink,
GlLoadingIcon,
GlSprintf,
GlPopover,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { POLICY_TYPE_COMPONENT_OPTIONS } from 'ee/security_orchestration/components/constants';
......@@ -9,6 +18,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
import { isTopLevelGroup } from '../../utils';
import { POLICY_SCOPES_DOCS_URL } from '../../constants';
import projectsInNamespaceWithFrameworkQuery from './graphql/projects_in_namespace_with_framework.query.graphql';
export default {
name: 'FrameworkInfoDrawer',
......@@ -18,6 +28,7 @@ export default {
GlButton,
GlLabel,
GlLink,
GlLoadingIcon,
GlSprintf,
GlPopover,
HelpIcon,
......@@ -46,6 +57,33 @@ export default {
},
},
emits: ['edit', 'close'],
data() {
return {
projects: {
nodes: [],
pageInfo: {
hasNextPage: false,
},
},
};
},
apollo: {
projects: {
query: projectsInNamespaceWithFrameworkQuery,
skip() {
return !this.framework || !this.groupPath;
},
variables() {
return {
fullPath: this.groupPath,
frameworkId: this.framework.id,
};
},
update(data) {
return data.namespace.projects;
},
},
},
computed: {
editDisabled() {
return !isTopLevelGroup(this.groupPath, this.rootAncestor.path);
......@@ -66,7 +104,7 @@ export default {
return this.$options.i18n.associatedProjects;
},
associatedProjectsCount() {
return this.framework.projects.nodes.length;
return this.projects.count;
},
policies() {
return [
......@@ -101,6 +139,15 @@ export default {
return `${this.groupSecurityPoliciesPath}/${policy.name}/edit?type=${urlParameter}`;
},
loadMoreProjects() {
this.$apollo.queries.projects.fetchMore({
variables: {
after: this.projects.pageInfo.endCursor,
},
});
},
copyIdToClipboard() {
navigator?.clipboard?.writeText(this.normalisedFrameworkId);
this.$toast.show(this.$options.i18n.copyIdToastText);
......@@ -179,8 +226,8 @@ export default {
</template>
<template v-if="framework" #default>
<div class="gl-flex gl-flex-col gl-gap-5">
<div class="data-testid" data-testid="sidebar-id">
<div class="gl-flex gl-flex-col">
<div data-testid="sidebar-id" class="gl-mb-5">
<div class="gl-flex gl-items-baseline">
<h3 class="gl-heading-3" data-testid="sidebar-id-title">
{{ $options.i18n.frameworkIdTitle }}
......@@ -216,7 +263,7 @@ export default {
>
</div>
</div>
<div class="gl-border-t">
<div class="gl-border-t gl-mb-5">
<h3 class="gl-heading-3 gl-mt-5" data-testid="sidebar-description-title">
{{ $options.i18n.frameworkDescription }}
</h3>
......@@ -229,17 +276,33 @@ export default {
<h3 data-testid="sidebar-projects-title" class="gl-heading-3 gl-mt-5">
{{ associatedProjectsTitle }}
</h3>
<gl-badge class="gl-ml-3" variant="muted">{{ associatedProjectsCount }}</gl-badge>
<gl-badge class="gl-ml-3" variant="muted">
<template v-if="!associatedProjectsCount && $apollo.queries.projects.loading">
<gl-loading-icon size="sm" />
</template>
<template v-else>{{ associatedProjectsCount }}</template>
</gl-badge>
</div>
<ul class="gl-pl-5">
<ul v-if="projects.nodes.length" class="gl-pl-5">
<li
v-for="associatedProject in framework.projects.nodes"
v-for="associatedProject in projects.nodes"
:key="associatedProject.id"
class="gl-mt-1"
>
<gl-link :href="associatedProject.webUrl">{{ associatedProject.name }}</gl-link>
</li>
</ul>
<gl-button
v-if="projects.pageInfo.hasNextPage"
class="gl-mb-5"
category="tertiary"
variant="confirm"
size="small"
:loading="$apollo.queries.projects.loading"
@click="loadMoreProjects"
>
{{ __('Load more') }}
</gl-button>
</div>
<div class="gl-border-t" data-testid="sidebar-policies">
<div class="gl-flex gl-items-center gl-gap-1">
......
......@@ -6,6 +6,7 @@ import {
GlTable,
GlToast,
GlLink,
GlSprintf,
GlButton,
GlAlert,
GlDisclosureDropdown,
......@@ -35,6 +36,7 @@ export default {
GlAlert,
GlDisclosureDropdown,
GlButton,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -81,7 +83,7 @@ export default {
selectedFramework() {
return this.$route.query.id
? this.frameworksWithFilteredProjects.find(
? this.frameworks.find(
(framework) => framework.id === convertFrameworkIdToGraphQl(this.$route.query.id),
)
: null;
......@@ -93,36 +95,22 @@ export default {
return !this.frameworks.length && !this.isLoading && !this.isTopLevelGroup;
},
tableFields() {
return this.projectPath
return this.projectPath || !this.isTopLevelGroup
? this.$options.fields.filter((f) => f.key !== 'associatedProjects')
: this.$options.fields;
},
frameworksWithFilteredProjects() {
return this.frameworks.map((framework) => {
const filteredProjects =
this.groupPath && framework.projects
? {
...framework.projects,
nodes: framework.projects.nodes.filter((p) =>
p.fullPath.startsWith(this.groupPath),
),
}
: framework.projects;
return {
...framework,
projects: filteredProjects,
};
});
},
},
methods: {
getIdFromGraphQLId,
toggleDrawer(item) {
if (this.selectedFramework?.id === item.id) {
if (this.selectedFramework?.id !== item.id) {
this.closeDrawer();
// eslint-disable-next-line promise/catch-or-return
this.$nextTick().then(() => {
this.openDrawer(item);
});
} else {
this.openDrawer(item);
this.closeDrawer();
}
},
copyFrameworkId(id) {
......@@ -175,6 +163,10 @@ export default {
}
return '';
},
remainingProjectsCount(projects) {
return projects.count - projects.nodes.length;
},
},
fields: [
{
......@@ -227,6 +219,7 @@ export default {
deleteButtonDefaultFrameworkDisabledTooltip: s__(
"ComplianceFrameworks|The default framework can't be deleted",
),
andMore: s__('ComplianceReport|and %{count} more'),
},
CREATE_FRAMEWORKS_DOCS_URL,
};
......@@ -258,7 +251,7 @@ export default {
<gl-table
:fields="tableFields"
:busy="isLoading"
:items="frameworksWithFilteredProjects"
:items="frameworks"
no-local-sorting
show-empty
stacked="md"
......@@ -268,21 +261,20 @@ export default {
<template #cell(frameworkName)="{ item }">
<framework-badge :framework="item" :show-edit="isTopLevelGroup" />
</template>
<template
#cell(associatedProjects)="{
item: {
projects: { nodes: associatedProjects },
},
}"
>
<template #cell(associatedProjects)="{ item: { projects } }">
<div
v-for="(associatedProject, index) in associatedProjects"
v-for="(associatedProject, index) in projects.nodes"
:key="associatedProject.id"
class="gl-inline-block"
>
<gl-link :href="associatedProject.webUrl">{{ associatedProject.name }}</gl-link
><span v-if="!isLastItem(index, associatedProjects)">,&nbsp;</span>
><span v-if="!isLastItem(index, projects.nodes)">,&nbsp;</span>
</div>
<template v-if="projects.pageInfo.hasNextPage">
<gl-sprintf :message="$options.i18n.andMore">
<template #count>{{ remainingProjectsCount(projects) }}</template>
</gl-sprintf>
</template>
</template>
<template #cell(policies)="{ item }">
{{ getPoliciesList(item) }}
......
......@@ -7,6 +7,7 @@ query complianceFrameworksGroupList(
$after: String
$first: Int
$last: Int
$projectLimit: Int = 10
) {
namespace: group(fullPath: $fullPath) {
id
......@@ -21,13 +22,17 @@ query complianceFrameworksGroupList(
...ComplianceFrameworkConnectionDetails
nodes {
id
projects {
projects(first: $projectLimit) {
count
nodes {
id
name
webUrl
fullPath
}
pageInfo {
hasNextPage
}
}
}
}
......
#import "~/graphql_shared/fragments/page_info.fragment.graphql"
query projectsInGroupWithComplianceFramework(
$fullPath: ID!
$frameworkId: ComplianceManagementFrameworkID!
$pageCount: Int = 20
$after: String
) {
namespace(fullPath: $fullPath) {
id
projects(
complianceFrameworkFilters: { ids: [$frameworkId] }
includeSubgroups: true
first: $pageCount
after: $after
) {
nodes {
id
name
webUrl
fullPath
}
count
pageInfo {
...PageInfo
}
}
}
}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { concatPagination } from '@apollo/client/utilities';
import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const cacheConfig = {
typePolicies: {
Namespace: {
fields: {
projects: {
keyArgs(args) {
const KNOWN_PAGINATION_ARGS = ['first', 'last', 'before', 'after'];
return Object.keys(args).filter((key) => !KNOWN_PAGINATION_ARGS.includes(key));
},
},
},
},
ProjectConnection: {
fields: {
nodes: concatPagination(),
},
},
},
};
const defaultClient = createDefaultClient({}, { cacheConfig });
export const apolloProvider = new VueApollo({
defaultClient,
});
import { GlBadge, GlLabel, GlButton, GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
import {
GlBadge,
GlLabel,
GlButton,
GlLink,
GlPopover,
GlSprintf,
GlLoadingIcon,
} from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import FrameworkInfoDrawer from 'ee/compliance_dashboard/components/frameworks_report/framework_info_drawer.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createFramework } from 'ee_jest/compliance_dashboard/mock_data';
import { cacheConfig } from 'ee/compliance_dashboard/graphql/client';
import projectsInNamespaceWithFrameworkQuery from 'ee/compliance_dashboard/components/frameworks_report/graphql/projects_in_namespace_with_framework.query.graphql';
import { shallowMountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import { createFramework, mockPageInfo } from 'ee_jest/compliance_dashboard/mock_data';
import { DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
Vue.use(VueApollo);
describe('FrameworkInfoDrawer component', () => {
let wrapper;
function createMockApolloProvider({ projectsInNamespaceResolverMock }) {
return createMockApollo(
[[projectsInNamespaceWithFrameworkQuery, projectsInNamespaceResolverMock]],
{},
{ cacheConfig },
);
}
const $toast = {
show: jest.fn(),
};
......@@ -16,7 +40,6 @@ describe('FrameworkInfoDrawer component', () => {
const defaultFramework = createFramework({ id: 1, isDefault: true, projects: 3 });
const nonDefaultFramework = createFramework({ id: 2 });
const associatedProjectsCount = defaultFramework.projects.nodes.length;
const policiesCount =
defaultFramework.scanExecutionPolicies.nodes.length +
defaultFramework.scanResultPolicies.nodes.length +
......@@ -38,6 +61,8 @@ describe('FrameworkInfoDrawer component', () => {
const findProjectsTitle = () => wrapper.findByTestId('sidebar-projects-title');
const findProjectsLinks = () =>
wrapper.findByTestId('sidebar-projects').findAllComponents(GlLink);
const findLoadMoreButton = () =>
extendedWrapper(wrapper.findByTestId('sidebar-projects')).findByText('Load more');
const findProjectsCount = () => wrapper.findByTestId('sidebar-projects').findComponent(GlBadge);
const findPoliciesTitle = () => wrapper.findByTestId('sidebar-policies-title');
const findPoliciesLinks = () =>
......@@ -45,17 +70,27 @@ describe('FrameworkInfoDrawer component', () => {
const findPoliciesCount = () => wrapper.findByTestId('sidebar-policies').findComponent(GlBadge);
const findPopover = () => wrapper.findByTestId('edit-framework-popover');
const createComponent = ({ props = {}, vulnerabilityManagementPolicyTypeGroup = true } = {}) => {
const pendingPromiseMock = jest.fn().mockResolvedValue(new Promise(() => {}));
const createComponent = ({
props = {},
vulnerabilityManagementPolicyTypeGroup = true,
projectsInNamespaceResolverMock = pendingPromiseMock,
} = {}) => {
const apolloProvider = createMockApolloProvider({
projectsInNamespaceResolverMock,
});
wrapper = shallowMountExtended(FrameworkInfoDrawer, {
apolloProvider,
propsData: {
showDrawer: true,
...props,
},
stubs: {
GlSprintf,
GlLilnk: {
template: '<a>{{ $attrs.href }}</a>',
},
GlButton,
BButton: false,
},
provide: {
groupSecurityPoliciesPath: '/group-policies',
......@@ -134,16 +169,98 @@ describe('FrameworkInfoDrawer component', () => {
expect(findProjectsTitle().text()).toBe(`Associated Projects`);
});
it('renders the Associated Projects count', () => {
expect(findProjectsCount().text()).toBe(`${associatedProjectsCount}`);
it('renders the Associated Projects count badge as loading', () => {
expect(findProjectsCount().findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('renders the Associated Projects list', () => {
expect(findProjectsLinks().wrappers).toHaveLength(3);
expect(findProjectsLinks().at(0).text()).toContain(defaultFramework.projects.nodes[0].name);
expect(findProjectsLinks().at(0).attributes('href')).toBe(
defaultFramework.projects.nodes[0].webUrl,
);
describe('Associated projects list when loaded', () => {
const TOTAL_COUNT = 30;
const makeProjectsListResponse = ({ pageInfo = mockPageInfo() } = {}) => {
return {
namespace: {
__typename: 'Group',
id: 'gid://gitlab/Group/1',
projects: {
...defaultFramework.projects,
count: TOTAL_COUNT,
pageInfo,
},
},
};
};
let projectsInNamespaceResolverMock;
beforeEach(() => {
projectsInNamespaceResolverMock = jest.fn().mockResolvedValue({
data: makeProjectsListResponse(),
});
createComponent({
projectsInNamespaceResolverMock,
props: {
groupPath: GROUP_PATH,
projectPath: PROJECT_PATH,
rootAncestor: {
path: GROUP_PATH,
},
framework: defaultFramework,
},
});
return waitForPromises();
});
it('renders the Associated Projects count', () => {
expect(findProjectsCount().text()).toBe(`${TOTAL_COUNT}`);
});
it('renders the Associated Projects list', () => {
expect(findProjectsLinks().wrappers).toHaveLength(3);
expect(findProjectsLinks().at(0).text()).toContain(
defaultFramework.projects.nodes[0].name,
);
expect(findProjectsLinks().at(0).attributes('href')).toBe(
defaultFramework.projects.nodes[0].webUrl,
);
});
describe('load more button', () => {
it('renders when we have next page in list', () => {
expect(findLoadMoreButton().exists()).toBe(true);
});
it('clicking button loads next page', async () => {
await findLoadMoreButton().trigger('click');
await waitForPromises();
expect(projectsInNamespaceResolverMock).toHaveBeenCalledWith(
expect.objectContaining({
after: mockPageInfo().endCursor,
}),
);
});
it('does not render when we do not have next page', async () => {
const secondPageResponse = makeProjectsListResponse();
secondPageResponse.namespace.projects.pageInfo.hasNextPage = false;
createComponent({
projectsInNamespaceResolverMock: jest.fn().mockResolvedValue({
data: secondPageResponse,
}),
props: {
groupPath: GROUP_PATH,
projectPath: PROJECT_PATH,
rootAncestor: {
path: GROUP_PATH,
},
framework: defaultFramework,
},
});
await waitForPromises();
expect(findLoadMoreButton().exists()).toBe(false);
});
});
});
it('renders the Policies accordion', () => {
......
......@@ -35,9 +35,11 @@ describe('FrameworksTable component', () => {
const GROUP_PATH = 'group';
const SUBGROUP_PATH = `${GROUP_PATH}/subgroup`;
const PROJECTS_TOTAL_COUNT = 50;
const frameworksResponse = createComplianceFrameworksReportResponse({
count: 2,
projects: 2,
projectsTotalCount: PROJECTS_TOTAL_COUNT,
groupPath: GROUP_PATH,
});
const frameworks = frameworksResponse.data.namespace.complianceFrameworks.nodes;
......@@ -108,6 +110,7 @@ describe('FrameworksTable component', () => {
},
stubs: {
EditForm: true,
FrameworkInfoDrawer: true,
GlModal: GlModalStub,
},
attachTo: document.body,
......@@ -132,7 +135,7 @@ describe('FrameworksTable component', () => {
expect(emptyState.text()).toBe('No frameworks found');
});
it('has the correct table headers', () => {
it('has the correct table headers for top-level group', () => {
wrapper = createComponent({ isLoading: false });
const headerTexts = findTableHeaders().wrappers.map((h) => h.text());
......@@ -263,6 +266,7 @@ describe('FrameworksTable component', () => {
);
expect(frameworkName).toContain(frameworks[idx].name);
expect(associatedProjects).toContain(projects[idx].name);
expect(associatedProjects).toContain(`and ${PROJECTS_TOTAL_COUNT - projects.length} more`);
expect(findTableLinks(idx).wrappers).toHaveLength(2);
expect(findTableLinks(idx).wrappers.map((w) => w.attributes('href'))).toStrictEqual(
projects.map((p) => p.webUrl),
......@@ -479,14 +483,8 @@ describe('FrameworksTable component', () => {
});
});
it('does not include projects not from a subgroup', () => {
const [, associatedProjects] = findTableRowData(0).wrappers.map((d) => d.text());
expect(associatedProjects).not.toContain(projects[0].name);
});
it('include projects from a subgroup', () => {
const [, associatedProjects] = findTableRowData(0).wrappers.map((d) => d.text());
expect(associatedProjects).toContain(projects[1].name);
it('does not render associated projects column in subgroup', () => {
expect(findTableHeaders().wrappers.map((w) => w.text())).not.toContain('Associated projects');
});
it('renders only copy id action in action dropdown', () => {
......
......@@ -43,11 +43,12 @@ describe('ComplianceFrameworksReport component', () => {
const findFrameworksTable = () => wrapper.findComponent({ name: 'FrameworksTable' });
const findPagination = () => wrapper.findComponent({ name: 'GlKeysetPagination' });
const defaultPagination = () => ({
const defaultPaginationAndLimits = () => ({
before: null,
after: null,
first: 20,
search: '',
projectLimit: 10,
});
const defaultInjects = {
......@@ -168,7 +169,7 @@ describe('ComplianceFrameworksReport component', () => {
it('fetches the list of frameworks and projects', () => {
expect(mockGraphQlLoading).toHaveBeenCalledWith({
...defaultPagination(),
...defaultPaginationAndLimits(),
fullPath,
});
});
......@@ -189,7 +190,7 @@ describe('ComplianceFrameworksReport component', () => {
it('fetches the list of frameworks from current group', () => {
expect(mockGraphQlLoading).toHaveBeenCalledWith({
...defaultPagination(),
...defaultPaginationAndLimits(),
fullPath: subgroupPath,
});
});
......@@ -202,7 +203,7 @@ describe('ComplianceFrameworksReport component', () => {
await nextTick();
expect(mockGraphQlLoading).toHaveBeenCalledWith({
...defaultPagination(),
...defaultPaginationAndLimits(),
search: 'test',
fullPath,
});
......@@ -224,7 +225,7 @@ describe('ComplianceFrameworksReport component', () => {
await nextTick();
expect(mockFrameworksGraphQlSuccess).toHaveBeenCalledWith({
...defaultPagination(),
...defaultPaginationAndLimits(),
after: pagination.props('endCursor'),
fullPath,
});
......@@ -235,7 +236,7 @@ describe('ComplianceFrameworksReport component', () => {
pagination.vm.$emit('prev');
await nextTick();
const expectedPagination = defaultPagination();
const expectedPagination = defaultPaginationAndLimits();
expectedPagination.last = expectedPagination.first;
delete expectedPagination.first;
......@@ -255,7 +256,7 @@ describe('ComplianceFrameworksReport component', () => {
await nextTick();
expect(mockFrameworksGraphQlSuccess).toHaveBeenCalledWith({
...defaultPagination(),
...defaultPaginationAndLimits(),
search: 'test',
fullPath,
});
......
......@@ -258,7 +258,7 @@ export const createProjectUpdateComplianceFrameworksResponse = ({ errors } = {})
},
});
const mockPageInfo = () => ({
export const mockPageInfo = () => ({
hasNextPage: true,
hasPreviousPage: true,
startCursor: 'start-cursor',
......@@ -270,6 +270,7 @@ export const createFramework = ({
id,
isDefault = false,
projects = 0,
projectsTotalCount = 100,
groupPath = 'foo',
options,
} = {}) => ({
......@@ -282,6 +283,8 @@ export const createFramework = ({
nodes: [],
},
projects: {
pageInfo: mockPageInfo(),
count: projectsTotalCount,
nodes: Array(projects)
.fill(null)
.map((_, pid) => createProject({ id: pid, groupPath })),
......@@ -418,6 +421,7 @@ pipeline_execution_policy:
export const createComplianceFrameworksReportResponse = ({
count = 1,
projects = 0,
projectsTotalCount = 100,
groupPath = 'group',
} = {}) => {
return {
......@@ -442,7 +446,9 @@ export const createComplianceFrameworksReportResponse = ({
pageInfo: mockPageInfo(),
nodes: Array(count)
.fill(null)
.map((_, id) => createFramework({ id: id + 1, projects, groupPath })),
.map((_, id) =>
createFramework({ id: id + 1, projects, projectsTotalCount, groupPath }),
),
__typename: 'ComplianceFrameworkConnection',
},
__typename: 'Namespace',
......
......@@ -14747,6 +14747,9 @@ msgstr ""
msgid "ComplianceReport|View the framework details"
msgstr ""
 
msgid "ComplianceReport|and %{count} more"
msgstr ""
msgid "ComplianceStandardsAdherence| Standards adherence export"
msgstr ""
 
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment