Commit 753b2a58 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '6953-instance-lvl-security-dashboard-project-selector-component' into 'master'

Add instance security project-selector component

See merge request gitlab-org/gitlab!18191
parents e281d5e8 10bea69d
<script>
import { GlBadge, GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
export default {
components: {
GlBadge,
GlButton,
GlLoadingIcon,
Icon,
ProjectAvatar,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
projects: {
type: Array,
required: true,
},
},
methods: {
projectRemoved(project) {
this.$emit('projectRemoved', project);
},
},
};
</script>
<template>
<section>
<div>
<h3 class="h5 text-secondary border-bottom mb-3 pb-2">
{{ s__('SecurityDashboard|Projects added') }}
<gl-badge>{{ projects.length }}</gl-badge>
</h3>
<ul v-if="projects.length" class="list-unstyled">
<li
v-for="project in projects"
:key="project.id"
class="d-flex align-items-center py-1 js-projects-list-project-item"
>
<project-avatar class="flex-shrink-0" :project="project" :size="32" />
<span>
{{ project.name_with_namespace }}
</span>
<gl-button
v-gl-tooltip
class="ml-auto bg-transparent border-0 p-0 text-secondary js-projects-list-project-remove"
:title="s__('SecurityDashboard|Remove project from dashboard')"
@click="projectRemoved(project)"
>
<icon name="remove" />
</gl-button>
</li>
</ul>
<p v-else class="text-secondary js-projects-list-empty-message">
{{
s__('SecurityDashboard|Select a project to add by using the project search field above.')
}}
</p>
</div>
</section>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { GlBadge, GlButton, GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectList from './project_list.vue';
export default {
components: {
GlBadge,
GlButton,
GlLoadingIcon,
Icon,
ProjectList,
ProjectSelector,
},
computed: {
...mapState('projectSelector', [
'projects',
'isAddingProjects',
'selectedProjects',
'projectSearchResults',
'searchCount',
'messages',
]),
isSearchingProjects() {
return this.searchCount > 0;
},
hasProjectsSelected() {
return this.selectedProjects.length > 0;
},
},
methods: {
...mapActions('projectSelector', [
'fetchSearchResults',
'addProjects',
'clearSearchResults',
'toggleSelectedProject',
'setSearchQuery',
'removeProject',
]),
addProjectsAndClearSearchResults() {
this.addProjects();
this.clearSearchResults();
},
searched(query) {
this.setSearchQuery(query);
this.fetchSearchResults();
},
projectClicked(project) {
this.toggleSelectedProject(project);
},
projectRemoved(project) {
this.removeProject(project.remove_path);
},
},
};
</script>
<template>
<section class="container">
<div class="row justify-content-center mt-md-4">
<div class="col col-lg-7">
<h2 class="h5 border-bottom mb-4 pb-3">
{{ s__('SecurityDashboard|Add or remove projects from your dashboard') }}
</h2>
<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="projectClicked"
/>
<div class="mb-3">
<gl-button
:disabled="!hasProjectsSelected"
new-style
variant="success"
@click="addProjectsAndClearSearchResults"
>
{{ s__('SecurityDashboard|Add projects') }}
</gl-button>
</div>
</div>
</div>
</div>
<div class="row justify-content-center mt-md-3">
<project-list :projects="projects" class="col col-lg-7" @projectRemoved="projectRemoved" />
<gl-loading-icon v-if="isAddingProjects" size="sm" />
</div>
</section>
</template>
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlBadge, GlButton } from '@gitlab/ui';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import ProjectList from 'ee/security_dashboard/components/project_list.vue';
const localVue = createLocalVue();
const getArrayWithLength = n => [...Array(n).keys()];
const generateMockProjects = (projectsCount, mockProject = {}) =>
getArrayWithLength(projectsCount).map(id => ({ id, ...mockProject }));
describe('Project List component', () => {
let wrapper;
const factory = ({ projects = [], stubs = {} } = {}) => {
wrapper = shallowMount(ProjectList, {
stubs,
localVue,
propsData: {
projects,
},
sync: false,
});
};
const getAllProjectItems = () => wrapper.findAll('.js-projects-list-project-item');
const getFirstProjectItem = () => wrapper.find('.js-projects-list-project-item');
const getFirstRemoveButton = () => getFirstProjectItem().find('.js-projects-list-project-remove');
afterEach(() => wrapper.destroy());
it('shows an empty state if there are no projects', () => {
factory();
expect(wrapper.text()).toContain(
'Select a project to add by using the project search field above.',
);
});
it.each([0, 1, 2])(
'renders a list of projects and displays a count of how many there are',
projectsCount => {
factory({ projects: generateMockProjects(projectsCount) });
expect(getAllProjectItems().length).toBe(projectsCount);
expect(wrapper.find(GlBadge).text()).toBe(`${projectsCount}`);
},
);
it('renders a project-item with an avatar', () => {
factory({ projects: generateMockProjects(1) });
expect(
getFirstProjectItem()
.find(ProjectAvatar)
.exists(),
).toBe(true);
});
it('renders a project-item with the project name', () => {
const projectNameWithNamespace = 'foo';
factory({
projects: generateMockProjects(1, { name_with_namespace: projectNameWithNamespace }),
});
expect(getFirstProjectItem().text()).toContain(projectNameWithNamespace);
});
it('renders a project-item with a remove button', () => {
factory({ projects: generateMockProjects(1) });
expect(getFirstRemoveButton().exists()).toBe(true);
});
it(`emits a 'projectRemoved' event when a project's remove button has been clicked`, () => {
const mockProjects = generateMockProjects(1);
const [projectData] = mockProjects;
factory({ projects: mockProjects, stubs: { GlButton } });
getFirstRemoveButton().trigger('click');
expect(wrapper.emitted('projectRemoved')).toHaveLength(1);
expect(wrapper.emitted('projectRemoved')).toEqual([[projectData]]);
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import createDefaultState from 'ee/security_dashboard/store/modules/project_selector/state';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectManager from 'ee/security_dashboard/components/project_manager.vue';
import ProjectList from 'ee/security_dashboard/components/project_list.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Project Manager component', () => {
let storeOptions;
let store;
let wrapper;
const factory = ({ stateOverrides = {} } = {}) => {
storeOptions = {
modules: {
projectSelector: {
namespaced: true,
actions: {
setSearchQuery: jest.fn(),
fetchSearchResults: jest.fn(),
addProjects: jest.fn(),
clearSearchResults: jest.fn(),
toggleSelectedProject: jest.fn(),
removeProject: jest.fn(),
},
state: {
...createDefaultState(),
...stateOverrides,
},
},
},
};
store = new Vuex.Store(storeOptions);
wrapper = shallowMount(ProjectManager, {
localVue,
store,
sync: false,
});
};
const getMockAction = actionName => storeOptions.modules.projectSelector.actions[actionName];
const getMockActionDispatchedPayload = actionName => getMockAction(actionName).mock.calls[0][1];
const getAddProjectsButton = () => wrapper.find(GlButton);
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
const getProjectList = () => wrapper.find(ProjectList);
const getProjectSelector = () => wrapper.find(ProjectSelector);
afterEach(() => {
wrapper.destroy();
jest.clearAllMocks();
});
describe('given the default state', () => {
beforeEach(factory);
it('contains a project-selector component', () => {
expect(getProjectSelector().exists()).toBe(true);
});
it.each`
actionName | payload
${'setSearchQuery'} | ${'foo'}
${'fetchSearchResults'} | ${undefined}
`(
'dispatches the correct actions when a project-search has been triggered',
({ actionName, payload }) => {
getProjectSelector().vm.$emit('searched', payload);
expect(getMockActionDispatchedPayload(actionName)).toBe(payload);
},
);
it('contains a button for adding selected projects', () => {
expect(getAddProjectsButton().text()).toContain('Add projects');
});
it('disables the button for adding projects per default', () => {
expect(getAddProjectsButton().attributes('disabled')).toBe('true');
});
it.each`
actionName | payload
${'addProjects'} | ${undefined}
${'clearSearchResults'} | ${undefined}
`(
'dispatches the correct actions when the add-projects button has been clicked',
({ actionName, payload }) => {
getAddProjectsButton().vm.$emit('click');
expect(getMockActionDispatchedPayload(actionName)).toBe(payload);
},
);
it('contains a project-list component', () => {
expect(getProjectList().exists()).toBe(true);
});
it('dispatches the right actions when the project-list emits a projectRemoved event', () => {
const mockProject = { remove_path: 'foo' };
const projectList = wrapper.find(ProjectList);
const removeProjectAction = getMockAction('removeProject');
projectList.vm.$emit('projectRemoved', mockProject);
expect(removeProjectAction).toHaveBeenCalledTimes(1);
expect(removeProjectAction.mock.calls[0][1]).toBe(mockProject.remove_path);
});
});
describe('given the state changes', () => {
it.each`
state | projectSelectorPropName | expectedPropValue
${{ searchCount: 1 }} | ${'showLoadingIndicator'} | ${true}
${{ selectedProjects: ['bar'] }} | ${'selectedProjects'} | ${['bar']}
${{ projectSearchResults: ['foo'] }} | ${'projectSearchResults'} | ${['foo']}
${{ messages: { noResults: true } }} | ${'showNoResultsMessage'} | ${true}
${{ messages: { searchError: true } }} | ${'showSearchErrorMessage'} | ${true}
${{ messages: { minimumQuery: true } }} | ${'showMinimumSearchQueryMessage'} | ${true}
`(
'passes the correct prop-values to the project-selector',
({ state, projectSelectorPropName, expectedPropValue }) => {
factory({ stateOverrides: state });
expect(getProjectSelector().props(projectSelectorPropName)).toEqual(expectedPropValue);
},
);
it('enables the add-projects button when at least one projects is selected', () => {
factory({ stateOverrides: { selectedProjects: [{}] } });
expect(getAddProjectsButton().attributes('disabled')).toBe(undefined);
});
it('passes the list of projects to the project-list component', () => {
const projects = [{}];
factory({ stateOverrides: { projects } });
expect(getProjectList().props('projects')).toBe(projects);
});
it('toggles the loading icon when a project is being added', () => {
factory({ stateOverrides: { isAddingProjects: false } });
expect(getLoadingIcon().exists()).toBe(false);
store.state.projectSelector.isAddingProjects = true;
return wrapper.vm.$nextTick().then(() => {
expect(getLoadingIcon().exists()).toBe(true);
});
});
});
});
...@@ -14207,6 +14207,12 @@ msgstr "" ...@@ -14207,6 +14207,12 @@ msgstr ""
msgid "SecurityDashboard|Project" msgid "SecurityDashboard|Project"
msgstr "" msgstr ""
msgid "SecurityDashboard|Projects added"
msgstr ""
msgid "SecurityDashboard|Remove project from dashboard"
msgstr ""
msgid "SecurityDashboard|Report type" msgid "SecurityDashboard|Report type"
msgstr "" msgstr ""
...@@ -14216,6 +14222,9 @@ msgstr "" ...@@ -14216,6 +14222,9 @@ msgstr ""
msgid "SecurityDashboard|Security Dashboard" msgid "SecurityDashboard|Security Dashboard"
msgstr "" msgstr ""
msgid "SecurityDashboard|Select a project to add by using the project search field above."
msgstr ""
msgid "SecurityDashboard|Severity" msgid "SecurityDashboard|Severity"
msgstr "" msgstr ""
......
...@@ -4,9 +4,11 @@ import ProjectSelector from '~/vue_shared/components/project_selector/project_se ...@@ -4,9 +4,11 @@ import ProjectSelector from '~/vue_shared/components/project_selector/project_se
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
import { GlSearchBoxByType } from '@gitlab/ui'; import { GlSearchBoxByType } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { mount, createLocalVue } from '@vue/test-utils';
import { trimText } from 'spec/helpers/text_helper'; import { trimText } from 'spec/helpers/text_helper';
const localVue = createLocalVue();
describe('ProjectSelector component', () => { describe('ProjectSelector component', () => {
let wrapper; let wrapper;
let vm; let vm;
...@@ -22,6 +24,7 @@ describe('ProjectSelector component', () => { ...@@ -22,6 +24,7 @@ describe('ProjectSelector component', () => {
jasmine.clock().install(); jasmine.clock().install();
wrapper = mount(Vue.extend(ProjectSelector), { wrapper = mount(Vue.extend(ProjectSelector), {
localVue,
propsData: { propsData: {
projectSearchResults: searchResults, projectSearchResults: searchResults,
selectedProjects: selected, selectedProjects: selected,
......
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