Commit 376781b5 authored by Alexander Turinske's avatar Alexander Turinske Committed by Phil Hughes

Remove vuex from project manager component

- create a project manager component that utilizes GraphQL
  and the GraphQL cache instead of Vuex
- move the remaining Vuex store and actions into the component
- update child components to handle new project structure
- update tests
parent fed9026b
...@@ -17,7 +17,10 @@ export default { ...@@ -17,7 +17,10 @@ export default {
project: { project: {
type: Object, type: Object,
required: true, required: true,
validator: p => Number.isFinite(p.id) && isString(p.name) && isString(p.name_with_namespace), validator: p =>
(Number.isFinite(p.id) || isString(p.id)) &&
isString(p.name) &&
(isString(p.name_with_namespace) || isString(p.nameWithNamespace)),
}, },
selected: { selected: {
type: Boolean, type: Boolean,
...@@ -30,8 +33,11 @@ export default { ...@@ -30,8 +33,11 @@ export default {
}, },
}, },
computed: { computed: {
projectNameWithNamespace() {
return this.project.nameWithNamespace || this.project.name_with_namespace;
},
truncatedNamespace() { truncatedNamespace() {
return truncateNamespace(this.project.name_with_namespace); return truncateNamespace(this.projectNameWithNamespace);
}, },
highlightedProjectName() { highlightedProjectName() {
return highlight(this.project.name, this.matcher); return highlight(this.project.name, this.matcher);
...@@ -58,7 +64,7 @@ export default { ...@@ -58,7 +64,7 @@ export default {
<div class="d-flex flex-wrap project-namespace-name-container"> <div class="d-flex flex-wrap project-namespace-name-container">
<div <div
v-if="truncatedNamespace" v-if="truncatedNamespace"
:title="project.name_with_namespace" :title="projectNameWithNamespace"
class="text-secondary text-truncate js-project-namespace" class="text-secondary text-truncate js-project-namespace"
> >
{{ truncatedNamespace }} {{ truncatedNamespace }}
......
...@@ -41,7 +41,8 @@ export default { ...@@ -41,7 +41,8 @@ export default {
}, },
totalResults: { totalResults: {
type: Number, type: Number,
required: true, required: false,
default: 0,
}, },
}, },
data() { data() {
......
<script> <script>
import { mapActions, mapState, mapGetters } from 'vuex';
import { GlLoadingIcon, GlButton, GlEmptyState, GlLink } from '@gitlab/ui'; import { GlLoadingIcon, GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale'; import createFlash from '~/flash';
import { __, s__ } from '~/locale';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue'; import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue'; import InstanceSecurityVulnerabilities from './first_class_instance_security_dashboard_vulnerabilities.vue';
import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue'; import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerability_severity.vue';
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue'; import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import ProjectManager from './project_manager.vue'; import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql';
import ProjectManager from './first_class_project_manager/project_manager.vue';
import CsvExportButton from './csv_export_button.vue'; import CsvExportButton from './csv_export_button.vue';
import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.graphql'; import vulnerabilityHistoryQuery from '../graphql/instance_vulnerability_history.graphql';
...@@ -38,30 +39,38 @@ export default { ...@@ -38,30 +39,38 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectAddEndpoint: {
type: String,
required: true,
},
projectListEndpoint: {
type: String,
required: true,
},
vulnerabilitiesExportEndpoint: { vulnerabilitiesExportEndpoint: {
type: String, type: String,
required: true, required: true,
}, },
}, },
apollo: {
projects: {
query: projectsQuery,
update(data) {
return data.instanceSecurityDashboard.projects.nodes;
},
error() {
createFlash(__('Something went wrong, unable to get projects'));
},
},
},
data() { data() {
return { return {
filters: {}, filters: {},
graphqlProjectList: [], // TODO: Rename me to projects once we back the project selector with GraphQL as well
showProjectSelector: false, showProjectSelector: false,
vulnerabilityHistoryQuery, vulnerabilityHistoryQuery,
projects: [],
isManipulatingProjects: false,
}; };
}, },
computed: { computed: {
...mapState('projectSelector', ['projects']), isLoadingProjects() {
...mapGetters('projectSelector', ['isUpdatingProjects']), return this.$apollo.queries.projects.loading;
},
isUpdatingProjects() {
return this.isLoadingProjects || this.isManipulatingProjects;
},
hasProjectsData() { hasProjectsData() {
return !this.isUpdatingProjects && this.projects.length > 0; return !this.isUpdatingProjects && this.projects.length > 0;
}, },
...@@ -81,24 +90,15 @@ export default { ...@@ -81,24 +90,15 @@ export default {
}; };
}, },
}, },
created() {
this.setProjectEndpoints({
add: this.projectAddEndpoint,
list: this.projectListEndpoint,
});
this.fetchProjects();
},
methods: { methods: {
...mapActions('projectSelector', ['setProjectEndpoints', 'fetchProjects']),
handleFilterChange(filters) { handleFilterChange(filters) {
this.filters = filters; this.filters = filters;
}, },
toggleProjectSelector() { toggleProjectSelector() {
this.showProjectSelector = !this.showProjectSelector; this.showProjectSelector = !this.showProjectSelector;
}, },
handleProjectFetch(projects) { handleProjectManipulation(value) {
this.graphqlProjectList = projects; this.isManipulatingProjects = value;
}, },
}, },
}; };
...@@ -119,12 +119,7 @@ export default { ...@@ -119,12 +119,7 @@ export default {
</header> </header>
</template> </template>
<template #sticky> <template #sticky>
<filters <filters v-if="shouldShowDashboard" :projects="projects" @filterChange="handleFilterChange" />
v-if="shouldShowDashboard"
:projects="graphqlProjectList"
@filterChange="handleFilterChange"
@projectFetch="handleProjectFetch"
/>
</template> </template>
<instance-security-vulnerabilities <instance-security-vulnerabilities
v-if="shouldShowDashboard" v-if="shouldShowDashboard"
...@@ -132,7 +127,6 @@ export default { ...@@ -132,7 +127,6 @@ export default {
:dashboard-documentation="dashboardDocumentation" :dashboard-documentation="dashboardDocumentation"
:empty-state-svg-path="emptyStateSvgPath" :empty-state-svg-path="emptyStateSvgPath"
:filters="filters" :filters="filters"
@projectFetch="handleProjectFetch"
/> />
<gl-empty-state <gl-empty-state
v-else-if="shouldShowEmptyState" v-else-if="shouldShowEmptyState"
...@@ -156,7 +150,12 @@ export default { ...@@ -156,7 +150,12 @@ export default {
</template> </template>
</gl-empty-state> </gl-empty-state>
<div v-else class="d-flex justify-content-center"> <div v-else class="d-flex justify-content-center">
<project-manager v-if="showProjectSelector" /> <project-manager
v-if="showProjectSelector"
:projects="projects"
:is-manipulating-projects="isManipulatingProjects"
@handle-project-manipulation="handleProjectManipulation"
/>
<gl-loading-icon v-else size="lg" class="mt-4" /> <gl-loading-icon v-else size="lg" class="mt-4" />
</div> </div>
<template #aside> <template #aside>
......
...@@ -57,7 +57,6 @@ export default { ...@@ -57,7 +57,6 @@ export default {
result({ data, loading }) { result({ data, loading }) {
this.isFirstResultLoading = loading; this.isFirstResultLoading = loading;
this.pageInfo = data.vulnerabilities.pageInfo; this.pageInfo = data.vulnerabilities.pageInfo;
this.$emit('projectFetch', data.instanceSecurityDashboard.projects.nodes);
}, },
error() { error() {
this.errorLoadingVulnerabilities = true; this.errorLoadingVulnerabilities = true;
......
...@@ -53,7 +53,7 @@ export default { ...@@ -53,7 +53,7 @@ export default {
> >
<project-avatar class="flex-shrink-0" :project="project" :size="32" /> <project-avatar class="flex-shrink-0" :project="project" :size="32" />
<span> <span>
{{ project.name_with_namespace }} {{ project.name_with_namespace || project.nameWithNamespace }}
</span> </span>
<gl-deprecated-button <gl-deprecated-button
v-gl-tooltip v-gl-tooltip
......
<script>
import createFlash from '~/flash';
import { __, s__, sprintf } from '~/locale';
import { GlButton } from '@gitlab/ui';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectList from './project_list.vue';
import getProjects from 'ee/security_dashboard/graphql/get_projects.query.graphql';
import projectsQuery from 'ee/security_dashboard/graphql/get_instance_security_dashboard_projects.query.graphql';
import addProjectToSecurityDashboard from 'ee/security_dashboard/graphql/add_project_to_security_dashboard.mutation.graphql';
import deleteProjectFromSecurityDashboard from 'ee/security_dashboard/graphql/delete_project_from_security_dashboard.mutation.graphql';
import { createInvalidProjectMessage } from 'ee/security_dashboard/utils/first_class_project_manager_utils';
export default {
MINIMUM_QUERY_LENGTH: 3,
PROJECTS_PER_PAGE: 20,
components: {
GlButton,
ProjectList,
ProjectSelector,
},
props: {
isManipulatingProjects: {
type: Boolean,
required: true,
},
projects: {
type: Array,
required: true,
},
},
data() {
return {
searchQuery: '',
projectSearchResults: [],
selectedProjects: [],
messages: {
noResults: false,
searchError: false,
minimumQuery: false,
},
searchCount: 0,
pageInfo: {
endCursor: '',
hasNextPage: true,
},
};
},
computed: {
canAddProjects() {
return !this.isManipulatingProjects && this.selectedProjects.length > 0;
},
isSearchingProjects() {
return this.searchCount > 0;
},
},
methods: {
toggleSelectedProject(project) {
const isProjectSelected = this.selectedProjects.some(({ id }) => id === project.id);
if (isProjectSelected) {
this.selectedProjects = this.selectedProjects.filter(p => p.id !== project.id);
} else {
this.selectedProjects.push(project);
}
},
addProjects() {
this.$emit('handleProjectManipulation', true);
const addProjectsPromises = this.selectedProjects.map(project => {
return this.$apollo
.mutate({
mutation: addProjectToSecurityDashboard,
variables: { id: project.id },
update(store, { data: results }) {
const data = store.readQuery({
query: projectsQuery,
});
data.instanceSecurityDashboard.projects.nodes.push(
results.addProjectToSecurityDashboard.project,
);
store.writeQuery({ query: projectsQuery, data });
},
})
.catch(() => {
return { error: true, project };
});
});
return Promise.all(addProjectsPromises)
.then(response => {
const invalidProjects = response.filter(value => value.error).map(value => value.project);
this.$emit('handleProjectManipulation', false);
if (invalidProjects.length) {
const invalidProjectsMessage = createInvalidProjectMessage(invalidProjects);
createFlash(
sprintf(s__('SecurityReports|Unable to add %{invalidProjectsMessage}'), {
invalidProjectsMessage,
}),
);
}
})
.finally(() => {
this.projectSearchResults = [];
this.selectedProjects = [];
});
},
removeProject(project) {
const { id } = project;
this.$emit('handleProjectManipulation', true);
this.$apollo
.mutate({
mutation: deleteProjectFromSecurityDashboard,
variables: { id },
update(store) {
const data = store.readQuery({
query: projectsQuery,
});
data.instanceSecurityDashboard.projects.nodes = data.instanceSecurityDashboard.projects.nodes.filter(
curr => curr.id !== id,
);
store.writeQuery({ query: projectsQuery, data });
},
})
.then(() => {
this.$emit('handleProjectManipulation', false);
})
.catch(() => createFlash(__('Something went wrong, unable to remove project')));
},
searched(query) {
this.searchQuery = query;
this.pageInfo = { endCursor: '', hasNextPage: true };
this.messages.minimumQuery = false;
this.searchCount += 1;
this.fetchSearchResults(true);
},
fetchSearchResults(isFirstSearch) {
if (!this.pageInfo.hasNextPage) {
return Promise.resolve();
}
if (!this.searchQuery || this.searchQuery.length < this.$options.MINIMUM_QUERY_LENGTH) {
return this.cancelSearch();
}
return this.searchProjects(this.searchQuery, this.pageInfo)
.then(payload => {
const {
data: {
projects: { nodes, pageInfo },
},
} = payload;
if (isFirstSearch) {
this.projectSearchResults = nodes;
this.updateMessagesData(this.projectSearchResults.length === 0, false, false);
this.searchCount = Math.max(0, this.searchCount - 1);
} else {
this.projectSearchResults = this.projectSearchResults.concat(nodes);
}
this.pageInfo = pageInfo;
})
.catch(this.fetchSearchResultsError);
},
cancelSearch() {
this.projectSearchResults = [];
this.pageInfo = {
endCursor: '',
hasNextPage: true,
};
this.updateMessagesData(false, false, true);
this.searchCount = Math.max(0, this.searchCount - 1);
},
searchProjects(searchQuery, pageInfo) {
return this.$apollo.query({
query: getProjects,
variables: {
search: searchQuery,
first: this.$options.PROJECTS_PER_PAGE,
after: pageInfo.endCursor,
},
});
},
fetchSearchResultsError() {
this.projectSearchResults = [];
this.updateMessagesData(false, true, false);
this.searchCount = Math.max(0, this.searchCount - 1);
},
updateMessagesData(noResults, searchError, minimumQuery) {
this.messages = {
noResults,
searchError,
minimumQuery,
};
},
},
};
</script>
<template>
<section class="container">
<div class="row justify-content-center mt-md-4">
<div class="col col-lg-7">
<h3 class="text-3 font-weight-bold border-bottom mb-4 pb-3">
{{ s__('SecurityReports|Add or remove projects from your dashboard') }}
</h3>
<div class="d-flex flex-column flex-md-row">
<project-selector
class="flex-grow mr-md-2"
:project-search-results="projectSearchResults"
:selected-projects="selectedProjects"
:show-no-results-message="messages.noResults"
:show-loading-indicator="isSearchingProjects"
:show-minimum-search-query-message="messages.minimumQuery"
:show-search-error-message="messages.searchError"
@searched="searched"
@projectClicked="toggleSelectedProject"
@bottomReached="fetchSearchResults"
/>
<div class="mb-3">
<gl-button
:disabled="!canAddProjects"
variant="success"
category="primary"
@click="addProjects"
>
{{ s__('SecurityReports|Add projects') }}
</gl-button>
</div>
</div>
</div>
</div>
<div class="row justify-content-center mt-md-3">
<project-list
:projects="projects"
:show-loading-indicator="isManipulatingProjects"
class="col col-lg-7"
@projectRemoved="removeProject"
/>
</div>
</section>
</template>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { mapState, mapActions, mapGetters } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import { GlDeprecatedButton } from '@gitlab/ui'; import { GlDeprecatedButton } from '@gitlab/ui';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectList from './project_list.vue'; import ProjectList from './first_class_project_manager/project_list.vue';
export default { export default {
components: { components: {
......
...@@ -7,8 +7,6 @@ import FirstClassInstanceSecurityDashboard from './components/first_class_instan ...@@ -7,8 +7,6 @@ import FirstClassInstanceSecurityDashboard from './components/first_class_instan
import UnavailableState from './components/unavailable_state.vue'; import UnavailableState from './components/unavailable_state.vue';
import createStore from './store'; import createStore from './store';
import createRouter from './router'; import createRouter from './router';
import projectsPlugin from './store/plugins/projects';
import projectSelector from './store/plugins/project_selector';
import apolloProvider from './graphql/provider'; import apolloProvider from './graphql/provider';
const isRequired = message => { const isRequired = message => {
...@@ -63,10 +61,7 @@ export default ( ...@@ -63,10 +61,7 @@ export default (
} }
const router = createRouter(); const router = createRouter();
const store = createStore({ const store = createStore({ dashboardType });
dashboardType,
plugins: [projectSelector, projectsPlugin],
});
return new Vue({ return new Vue({
el, el,
......
#import "ee/security_dashboard/graphql/project.fragment.graphql" #import "ee/security_dashboard/graphql/project.fragment.graphql"
query getInstanceSecurityDashboardProjects { query projectsQuery {
instanceSecurityDashboard { instanceSecurityDashboard {
projects { projects {
nodes { nodes {
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" #import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "ee/security_dashboard/graphql/project.fragment.graphql" #import "ee/security_dashboard/graphql/project.fragment.graphql"
query getProjects($search: String!) { query getProjects($search: String!, $after: String = "", $first: Int!) {
projects(search: $search, membership: true) { projects(search: $search, after: $after, first: $first, membership: true) {
nodes { nodes {
...Project ...Project
avatarUrl avatarUrl
......
import { s__, sprintf } from '~/locale';
/**
* Creates the notification text to show the user regarding projects that failed to get added
* to the dashboard
*
* @param {Array} invalidProjects all the projects that failed to be added
* @returns {String} the invalid projects formated in a user-friendly way
*/
export const createInvalidProjectMessage = invalidProjects => {
const [firstProject, secondProject, ...rest] = invalidProjects.map(project => project.name);
const translationValues = {
firstProject,
secondProject,
rest: rest.join(', '),
};
if (rest.length > 0) {
return sprintf(
s__('SecurityReports|%{firstProject}, %{secondProject}, and %{rest}'),
translationValues,
);
} else if (secondProject) {
return sprintf(s__('SecurityReports|%{firstProject} and %{secondProject}'), translationValues);
}
return firstProject;
};
export default {};
import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlEmptyState, GlButton } from '@gitlab/ui'; import { GlEmptyState, GlButton } from '@gitlab/ui';
import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue'; import SecurityDashboardLayout from 'ee/security_dashboard/components/security_dashboard_layout.vue';
import FirstClassInstanceDashboard from 'ee/security_dashboard/components/first_class_instance_security_dashboard.vue'; import FirstClassInstanceDashboard from 'ee/security_dashboard/components/first_class_instance_security_dashboard.vue';
...@@ -8,21 +7,17 @@ import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerabilit ...@@ -8,21 +7,17 @@ import VulnerabilitySeverity from 'ee/security_dashboard/components/vulnerabilit
import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue'; import VulnerabilityChart from 'ee/security_dashboard/components/first_class_vulnerability_chart.vue';
import CsvExportButton from 'ee/security_dashboard/components/csv_export_button.vue'; import CsvExportButton from 'ee/security_dashboard/components/csv_export_button.vue';
import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue'; import Filters from 'ee/security_dashboard/components/first_class_vulnerability_filters.vue';
import ProjectManager from 'ee/security_dashboard/components/project_manager.vue'; import ProjectManager from 'ee/security_dashboard/components/first_class_project_manager/project_manager.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('First Class Instance Dashboard Component', () => { describe('First Class Instance Dashboard Component', () => {
let wrapper; let wrapper;
let store;
const defaultMocks = { $apollo: { queries: { projects: { loading: false } } } };
const dashboardDocumentation = 'dashboard-documentation'; const dashboardDocumentation = 'dashboard-documentation';
const emptyStateSvgPath = 'empty-state-path'; const emptyStateSvgPath = 'empty-state-path';
const vulnerableProjectsEndpoint = '/vulnerable/projects'; const vulnerableProjectsEndpoint = '/vulnerable/projects';
const vulnerabilitiesExportEndpoint = '/vulnerabilities/exports'; const vulnerabilitiesExportEndpoint = '/vulnerabilities/exports';
const projectAddEndpoint = 'projectAddEndpoint';
const projectListEndpoint = 'projectListEndpoint';
const findInstanceVulnerabilities = () => wrapper.find(FirstClassInstanceVulnerabilities); const findInstanceVulnerabilities = () => wrapper.find(FirstClassInstanceVulnerabilities);
const findVulnerabilitySeverity = () => wrapper.find(VulnerabilitySeverity); const findVulnerabilitySeverity = () => wrapper.find(VulnerabilitySeverity);
...@@ -32,33 +27,15 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -32,33 +27,15 @@ describe('First Class Instance Dashboard Component', () => {
const findEmptyState = () => wrapper.find(GlEmptyState); const findEmptyState = () => wrapper.find(GlEmptyState);
const findFilters = () => wrapper.find(Filters); const findFilters = () => wrapper.find(Filters);
const createWrapper = ({ isUpdatingProjects = false, projects = [], stubs }) => { const createWrapper = ({ data = {}, stubs }) => {
store = new Vuex.Store({
modules: {
projectSelector: {
namespaced: true,
actions: {
fetchProjects() {},
setProjectEndpoints() {},
},
getters: {
isUpdatingProjects: jest.fn().mockReturnValue(isUpdatingProjects),
},
state: {
projects,
},
},
},
});
return shallowMount(FirstClassInstanceDashboard, { return shallowMount(FirstClassInstanceDashboard, {
localVue, data() {
store, return { ...data };
},
mocks: { ...defaultMocks },
propsData: { propsData: {
dashboardDocumentation, dashboardDocumentation,
emptyStateSvgPath, emptyStateSvgPath,
projectAddEndpoint,
projectListEndpoint,
vulnerableProjectsEndpoint, vulnerableProjectsEndpoint,
vulnerabilitiesExportEndpoint, vulnerabilitiesExportEndpoint,
}, },
...@@ -77,8 +54,9 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -77,8 +54,9 @@ describe('First Class Instance Dashboard Component', () => {
describe('when initialized', () => { describe('when initialized', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createWrapper({ wrapper = createWrapper({
isUpdatingProjects: false, data: {
projects: [{ id: 1 }, { id: 2 }], projects: [{ id: 1 }, { id: 2 }],
},
}); });
}); });
...@@ -98,14 +76,6 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -98,14 +76,6 @@ describe('First Class Instance Dashboard Component', () => {
expect(findVulnerabilityChart().props('groupFullPath')).toBeUndefined(); expect(findVulnerabilityChart().props('groupFullPath')).toBeUndefined();
}); });
it('responds to the projectFetch event', () => {
const projects = [{ id: 1, name: 'GitLab Org' }];
findInstanceVulnerabilities().vm.$listeners.projectFetch(projects);
return wrapper.vm.$nextTick(() => {
expect(findFilters().props('projects')).toEqual(projects);
});
});
it('responds to the filterChange event', () => { it('responds to the filterChange event', () => {
const filters = { severity: 'critical' }; const filters = { severity: 'critical' };
findFilters().vm.$listeners.filterChange(filters); findFilters().vm.$listeners.filterChange(filters);
...@@ -129,10 +99,12 @@ describe('First Class Instance Dashboard Component', () => { ...@@ -129,10 +99,12 @@ describe('First Class Instance Dashboard Component', () => {
describe('when uninitialized', () => { describe('when uninitialized', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createWrapper({ wrapper = createWrapper({
isUpdatingProjects: false, data: {
stubs: { isManipulatingProjects: false,
GlEmptyState, stubs: {
GlButton, GlEmptyState,
GlButton,
},
}, },
}); });
}); });
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlDeprecatedBadge as GlBadge, GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; import { GlDeprecatedBadge as GlBadge, GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import ProjectList from 'ee/security_dashboard/components/project_list.vue'; import ProjectList from 'ee/security_dashboard/components/first_class_project_manager/project_list.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue'; import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
const getArrayWithLength = n => [...Array(n).keys()]; const getArrayWithLength = n => [...Array(n).keys()];
...@@ -67,7 +67,7 @@ describe('Project List component', () => { ...@@ -67,7 +67,7 @@ describe('Project List component', () => {
).toBe(true); ).toBe(true);
}); });
it('renders a project-item with the project name', () => { it('renders a project-item with a project name', () => {
const projectNameWithNamespace = 'foo'; const projectNameWithNamespace = 'foo';
factory({ factory({
...@@ -77,6 +77,15 @@ describe('Project List component', () => { ...@@ -77,6 +77,15 @@ describe('Project List component', () => {
expect(getFirstProjectItem().text()).toContain(projectNameWithNamespace); expect(getFirstProjectItem().text()).toContain(projectNameWithNamespace);
}); });
it('renders a project-item with a GraphQL project name', () => {
const projectNameWithNamespace = 'foo';
factory({
projects: generateMockProjects(1, { nameWithNamespace: projectNameWithNamespace }),
});
expect(getFirstProjectItem().text()).toContain(projectNameWithNamespace);
});
it('renders a project-item with a remove button', () => { it('renders a project-item with a remove button', () => {
factory({ projects: generateMockProjects(1) }); factory({ projects: generateMockProjects(1) });
......
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import createFlash from '~/flash';
import ProjectManager from 'ee/security_dashboard/components/first_class_project_manager/project_manager.vue';
import ProjectList from 'ee/security_dashboard/components/first_class_project_manager/project_list.vue';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import getProjects from 'ee/security_dashboard/graphql/get_projects.query.graphql';
import waitForPromises from 'helpers/wait_for_promises';
jest.mock('~/flash');
const mockProject = { id: 1, name: 'Sample Project 1' };
const singleProjectList = [mockProject];
const multipleProjectsList = [
{ id: 2, name: 'Sample Project 2' },
{ id: 3, name: 'Sample Project 3' },
];
const mockPageInfo = { hasNextPage: false, endCursor: '' };
describe('Project Manager component', () => {
let wrapper;
let spyQuery;
let spyMutate;
const defaultMocks = {
$apollo: {
query: jest.fn().mockResolvedValue({
data: { projects: { nodes: singleProjectList, pageInfo: mockPageInfo } },
}),
mutate: jest.fn().mockResolvedValue({}),
},
};
const defaultProps = {
isManipulatingProjects: false,
projects: [],
};
const createWrapper = ({ data = {}, mocks = {}, props = {} }) => {
spyQuery = defaultMocks.$apollo.query;
spyMutate = defaultMocks.$apollo.mutate;
wrapper = shallowMount(ProjectManager, {
data() {
return { ...data };
},
mocks: { ...defaultMocks, ...mocks },
propsData: { ...defaultProps, ...props },
});
};
const findAddProjectsButton = () => wrapper.find(GlButton);
const findProjectList = () => wrapper.find(ProjectList);
const findProjectSelector = () => wrapper.find(ProjectSelector);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('it renders', () => {
beforeEach(() => createWrapper({}));
it('contains a project-selector component', () => {
expect(findProjectSelector().exists()).toBe(true);
});
it('contains a project-list component', () => {
expect(findProjectList().exists()).toBe(true);
});
it('contains the add project button', () => {
expect(findAddProjectsButton().exists()).toBe(true);
});
});
describe('searching projects', () => {
beforeEach(() => createWrapper({}));
it('searches with the query', () => {
findProjectSelector().vm.$emit('searched', 'test');
expect(spyQuery).toHaveBeenCalledTimes(1);
expect(spyQuery).toHaveBeenCalledWith({
query: getProjects,
variables: {
search: 'test',
first: wrapper.vm.$options.PROJECTS_PER_PAGE,
after: '',
},
});
});
it('does not search if the query is below the minimum query limit', () => {
findProjectSelector().vm.$emit('searched', 'te');
expect(spyQuery).not.toHaveBeenCalled();
});
it('passes the search results to the project-selector on a successful search', () => {
findProjectSelector().vm.$emit('searched', 'test');
return waitForPromises().then(() => {
expect(findProjectSelector().props('projectSearchResults')).toBe(singleProjectList);
});
});
it('passes an empty array to the project-selector on a failed search', () => {
const mocks = {
$apollo: {
query: jest.fn().mockRejectedValue(),
},
};
createWrapper({ data: { selectedProjects: singleProjectList }, mocks });
findProjectSelector().vm.$emit('searched', 'test');
return waitForPromises().then(() => {
expect(findProjectSelector().props('projectSearchResults')).toEqual([]);
});
});
});
describe('project selection', () => {
it('adds a project to the list of selected projects', () => {
createWrapper({});
findProjectSelector().vm.$emit('projectClicked', mockProject);
return waitForPromises().then(() => {
expect(findProjectSelector().props('selectedProjects')).toEqual(singleProjectList);
});
});
it('removes a project from the list of selected projects', () => {
createWrapper({ data: { selectedProjects: singleProjectList } });
findProjectSelector().vm.$emit('projectClicked', mockProject);
return waitForPromises().then(() => {
expect(findProjectSelector().props('selectedProjects')).toEqual([]);
});
});
});
describe('adding projects', () => {
it('disables the add project button if no projects are selected', () => {
createWrapper({});
expect(findAddProjectsButton().attributes('disabled')).toBe('true');
});
it('enables the add project button if projects are selected', () => {
createWrapper({ data: { selectedProjects: singleProjectList } });
expect(findAddProjectsButton().attributes('disabled')).toBeFalsy();
});
it('adding a project successfully updates the projects list', () => {
createWrapper({ data: { selectedProjects: singleProjectList } });
findAddProjectsButton().vm.$emit('click');
expect(spyMutate).toHaveBeenCalledTimes(1);
});
it('adding a project unsuccessfully shows a flash', () => {
const mocks = {
$apollo: {
mutate: jest.fn().mockRejectedValue(),
},
};
createWrapper({ data: { selectedProjects: singleProjectList }, mocks });
findAddProjectsButton().vm.$emit('click');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith('Unable to add Sample Project 1');
});
});
it('adding many projects unsuccessfully shows a flash', () => {
const mocks = {
$apollo: {
mutate: jest.fn().mockRejectedValue(),
},
};
createWrapper({ data: { selectedProjects: multipleProjectsList }, mocks });
findAddProjectsButton().vm.$emit('click');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Unable to add Sample Project 2 and Sample Project 3',
);
});
});
});
describe('removing projects', () => {
it('removing a project calls the mutatation', () => {
createWrapper({ props: { projects: singleProjectList } });
findProjectList().vm.$emit('projectRemoved', mockProject);
expect(spyMutate).toHaveBeenCalledTimes(1);
});
it('removing a project unsuccessfully shows a flash', () => {
const mocks = {
$apollo: {
mutate: jest.fn().mockRejectedValue(),
},
};
createWrapper({ props: { selectedProjects: multipleProjectsList }, mocks });
findProjectList().vm.$emit('projectRemoved', mockProject);
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
});
});
describe('infinite scrolling', () => {
it('if the bottom is reached and there is another page, it appends the next page to the projects array', () => {
createWrapper({ data: { searchQuery: 'test' } });
findProjectSelector().vm.$emit('bottomReached');
expect(spyQuery).toHaveBeenCalledTimes(1);
});
it('if the bottom is reached and there is not another page, it does nothing', () => {
createWrapper({ data: { pageInfo: { hasNextPage: false, endCursor: '' } } });
findProjectSelector().vm.$emit('bottomReached');
expect(spyQuery).not.toHaveBeenCalled();
});
});
});
...@@ -7,7 +7,7 @@ import createDefaultState from 'ee/security_dashboard/store/modules/project_sele ...@@ -7,7 +7,7 @@ import createDefaultState from 'ee/security_dashboard/store/modules/project_sele
import { GlDeprecatedButton } from '@gitlab/ui'; import { GlDeprecatedButton } from '@gitlab/ui';
import ProjectManager from 'ee/security_dashboard/components/project_manager.vue'; import ProjectManager from 'ee/security_dashboard/components/project_manager.vue';
import ProjectList from 'ee/security_dashboard/components/project_list.vue'; import ProjectList from 'ee/security_dashboard/components/first_class_project_manager/project_list.vue';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
const localVue = createLocalVue(); const localVue = createLocalVue();
......
...@@ -20112,6 +20112,9 @@ msgstr "" ...@@ -20112,6 +20112,9 @@ msgstr ""
msgid "SecurityReports|There was an error while generating the report." msgid "SecurityReports|There was an error while generating the report."
msgstr "" msgstr ""
msgid "SecurityReports|Unable to add %{invalidProjectsMessage}"
msgstr ""
msgid "SecurityReports|Unable to add %{invalidProjects}" msgid "SecurityReports|Unable to add %{invalidProjects}"
msgstr "" msgstr ""
......
...@@ -74,6 +74,16 @@ describe('ProjectListItem component', () => { ...@@ -74,6 +74,16 @@ describe('ProjectListItem component', () => {
expect(renderedNamespace).toBe('a / ... / e /'); expect(renderedNamespace).toBe('a / ... / e /');
}); });
it(`renders a simple namespace name of a GraphQL project`, () => {
options.propsData.project.name_with_namespace = undefined;
options.propsData.project.nameWithNamespace = 'test';
wrapper = shallowMount(Component, options);
const renderedNamespace = trimText(wrapper.find('.js-project-namespace').text());
expect(renderedNamespace).toBe('test /');
});
it(`renders the project name`, () => { it(`renders the project name`, () => {
options.propsData.project.name = 'my-test-project'; options.propsData.project.name = 'my-test-project';
......
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