diff --git a/app/assets/javascripts/integrations/beyond_identity/components/add_exclusions_drawer.vue b/app/assets/javascripts/integrations/beyond_identity/components/add_exclusions_drawer.vue new file mode 100644 index 0000000000000000000000000000000000000000..64bbd0e30e6314ba6b430fa5621711df0a44ea88 --- /dev/null +++ b/app/assets/javascripts/integrations/beyond_identity/components/add_exclusions_drawer.vue @@ -0,0 +1,84 @@ + + + diff --git a/app/assets/javascripts/integrations/beyond_identity/components/exclusions_list.vue b/app/assets/javascripts/integrations/beyond_identity/components/exclusions_list.vue new file mode 100644 index 0000000000000000000000000000000000000000..89d4695b86f3e16e649b57e4955b1744409bf240 --- /dev/null +++ b/app/assets/javascripts/integrations/beyond_identity/components/exclusions_list.vue @@ -0,0 +1,126 @@ + + + diff --git a/app/assets/javascripts/integrations/beyond_identity/components/exclusions_list_item.vue b/app/assets/javascripts/integrations/beyond_identity/components/exclusions_list_item.vue new file mode 100644 index 0000000000000000000000000000000000000000..e44bf92b7427e4c87c7506a07fd14a7e7c49fa1a --- /dev/null +++ b/app/assets/javascripts/integrations/beyond_identity/components/exclusions_list_item.vue @@ -0,0 +1,46 @@ + + + diff --git a/app/assets/javascripts/integrations/beyond_identity/components/exclusions_tabs.vue b/app/assets/javascripts/integrations/beyond_identity/components/exclusions_tabs.vue new file mode 100644 index 0000000000000000000000000000000000000000..8dd4becee6cbf688785896b322b30515b7998c41 --- /dev/null +++ b/app/assets/javascripts/integrations/beyond_identity/components/exclusions_tabs.vue @@ -0,0 +1,39 @@ + + + diff --git a/app/assets/javascripts/integrations/beyond_identity/components/remove_exclusion_confirmation_modal.vue b/app/assets/javascripts/integrations/beyond_identity/components/remove_exclusion_confirmation_modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..6ba61e97b34ca04db4d4d0e3934787d5cae78a0f --- /dev/null +++ b/app/assets/javascripts/integrations/beyond_identity/components/remove_exclusion_confirmation_modal.vue @@ -0,0 +1,58 @@ + + diff --git a/app/assets/javascripts/pages/admin/integrations/overrides/index.js b/app/assets/javascripts/pages/admin/integrations/overrides/index.js index b150470914433fac8149178606c5bd5c652b5b8e..437c77dd25c1a55e9c95ffe4a825aee2164cd17e 100644 --- a/app/assets/javascripts/pages/admin/integrations/overrides/index.js +++ b/app/assets/javascripts/pages/admin/integrations/overrides/index.js @@ -1,3 +1,33 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import initIntegrationOverrides from '~/integrations/overrides'; +import ExclusionsList from '~/integrations/beyond_identity/components/exclusions_list.vue'; + +const initBeyondIdentityExclusions = () => { + const el = document.querySelector('.js-vue-beyond-identity-exclusions'); + + if (!el) { + return null; + } + + const { editPath } = el.dataset; + + return new Vue({ + el, + + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), + provide: { + editPath, + }, + render(createElement) { + return createElement(ExclusionsList); + }, + }); +}; + +initBeyondIdentityExclusions(); initIntegrationOverrides(); diff --git a/app/assets/javascripts/vue_shared/components/list_selector/constants.js b/app/assets/javascripts/vue_shared/components/list_selector/constants.js index ad826c6f3e56b08bba1d6bcbfc1258f819f9475c..5ff5d59ef6b6e280df4c4653f6bab27a26ace6a5 100644 --- a/app/assets/javascripts/vue_shared/components/list_selector/constants.js +++ b/app/assets/javascripts/vue_shared/components/list_selector/constants.js @@ -2,6 +2,7 @@ import { __ } from '~/locale'; import UserItem from './user_item.vue'; import GroupItem from './group_item.vue'; import DeployKeyItem from './deploy_key_item.vue'; +import ProjectItem from './project_item.vue'; export const CONFIG = { users: { @@ -23,4 +24,10 @@ export const CONFIG = { filterKey: 'name', component: DeployKeyItem, }, + projects: { + title: __('Projects'), + icon: 'project', + filterKey: 'id', + component: ProjectItem, + }, }; diff --git a/app/assets/javascripts/vue_shared/components/list_selector/index.vue b/app/assets/javascripts/vue_shared/components/list_selector/index.vue index d79a8d6a00c1c681d0e3f4c74122064cb8232692..67b2e341ce0d264d58698c6743c030cac6740288 100644 --- a/app/assets/javascripts/vue_shared/components/list_selector/index.vue +++ b/app/assets/javascripts/vue_shared/components/list_selector/index.vue @@ -1,10 +1,11 @@ + + diff --git a/app/views/shared/integrations/_tabs.html.haml b/app/views/shared/integrations/_tabs.html.haml index 781db59592ea92f3dde7c83c9b67cb5310f46360..bb94590bb9889dd5b2cc95fd7f6c60bee73ea6a1 100644 --- a/app/views/shared/integrations/_tabs.html.haml +++ b/app/views/shared/integrations/_tabs.html.haml @@ -1,9 +1,10 @@ +- custom_settings_title = @integration.title == 'Beyond Identity' && Feature.enabled?(:beyond_identity_exclusions) ? s_('Integrations|Exclusions') : s_('Integrations|Projects using custom settings') - if integration.instance_level? .tabs.gl-tabs %div = gl_tabs_nav({ class: 'gl-mb-5' }) do = gl_tab_link_to _('Settings'), scoped_edit_integration_path(integration, project: @project, group: @group) - = gl_tab_link_to s_('Integrations|Projects using custom settings'), scoped_overrides_integration_path(integration) + = gl_tab_link_to custom_settings_title, scoped_overrides_integration_path(integration) = yield diff --git a/app/views/shared/integrations/overrides.html.haml b/app/views/shared/integrations/overrides.html.haml index c25527a605c37fb38877c2e08fb4842c677d2ed2..5d67f63bb2a93dc86dc4dfc84c401811a820bef5 100644 --- a/app/views/shared/integrations/overrides.html.haml +++ b/app/views/shared/integrations/overrides.html.haml @@ -5,4 +5,8 @@ %h1.page-title.gl-font-size-h-display = @integration.title -.js-vue-integration-overrides{ data: integration_overrides_data(@integration, project: @project, group: @group) } +- if @integration.title == 'Beyond Identity' && Feature.enabled?(:beyond_identity_exclusions) + .js-vue-beyond-identity-exclusions{ data: integration_overrides_data(@integration) } + +- else + .js-vue-integration-overrides{ data: integration_overrides_data(@integration, project: @project, group: @group) } diff --git a/config/feature_flags/beta/beyond_identity_exclusions.yml b/config/feature_flags/beta/beyond_identity_exclusions.yml new file mode 100644 index 0000000000000000000000000000000000000000..e599a4a93c1479ab540c6e767c92fd0f6897ce85 --- /dev/null +++ b/config/feature_flags/beta/beyond_identity_exclusions.yml @@ -0,0 +1,9 @@ +--- +name: beyond_identity_exclusions +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/454372 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/150664 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/457893 +milestone: '17.0' +group: group::source code +type: beta +default_enabled: false diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d1f4bf53cdd5086b96151911d01eab3cd1ad6eb9..6112f55689e11b7129c03409e7ea1209aa07d486 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3094,6 +3094,9 @@ msgstr "" msgid "Add environment" msgstr "" +msgid "Add exclusions" +msgstr "" + msgid "Add existing confidential %{issuableType}" msgstr "" @@ -27416,6 +27419,9 @@ msgstr "" msgid "Integrations|Add an integration" msgstr "" +msgid "Integrations|Add exclusions" +msgstr "" + msgid "Integrations|All details" msgstr "" @@ -27455,6 +27461,9 @@ msgstr "" msgid "Integrations|Configure the scope of notifications." msgstr "" +msgid "Integrations|Confirm %{type} exclusion removal" +msgstr "" + msgid "Integrations|Connection details" msgstr "" @@ -27500,6 +27509,9 @@ msgstr "" msgid "Integrations|Error: You are trying to upload something other than an allowed file." msgstr "" +msgid "Integrations|Exclusions" +msgstr "" + msgid "Integrations|GitLab administrators can set up integrations that all groups and projects inherit and use by default. These integrations apply to all groups and projects that don't already use custom settings. You can override custom settings for a group or project if the settings are necessary at that level. Learn more about %{integrations_link_start}instance-level integration management%{link_end}." msgstr "" @@ -27545,6 +27557,12 @@ msgstr "" msgid "Integrations|Perform common tasks with slash commands." msgstr "" +msgid "Integrations|Project exclusion removed" +msgstr "" + +msgid "Integrations|Projects in this list no longer require commits to be signed." +msgstr "" + msgid "Integrations|Projects using custom settings" msgstr "" @@ -27554,6 +27572,9 @@ msgstr "" msgid "Integrations|Projects using custom settings will not be impacted unless the project owner chooses to use parent level defaults." msgstr "" +msgid "Integrations|Remove exclusion" +msgstr "" + msgid "Integrations|Reset integration?" msgstr "" @@ -27593,6 +27614,9 @@ msgstr "" msgid "Integrations|The slash command verification request has expired. Please run the command again." msgstr "" +msgid "Integrations|There are no exclusions" +msgstr "" + msgid "Integrations|There are no projects using custom settings" msgstr "" @@ -27623,6 +27647,9 @@ msgstr "" msgid "Integrations|You haven't activated any integrations yet." msgstr "" +msgid "Integrations|You're removing an exclusion for %{name}. Are you sure you want to continue?" +msgstr "" + msgid "Integrations|You've activated every integration 🎉" msgstr "" @@ -33842,6 +33869,9 @@ msgstr "" msgid "No %{providerTitle} repositories found" msgstr "" +msgid "No %{title} have been added." +msgstr "" + msgid "No Epic" msgstr "" @@ -42553,6 +42583,9 @@ msgstr "" msgid "Remove epic reference" msgstr "" +msgid "Remove exclusion for %{name}" +msgstr "" + msgid "Remove favicon" msgstr "" @@ -45592,6 +45625,9 @@ msgstr "" msgid "Search templates" msgstr "" +msgid "Search to add %{title}" +msgstr "" + msgid "Search users" msgstr "" diff --git a/spec/frontend/integrations/beyond_identity/components/add_exclusions_drawer_spec.js b/spec/frontend/integrations/beyond_identity/components/add_exclusions_drawer_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..fb153f53343c81679f0691a1e01e064e7bc4796d --- /dev/null +++ b/spec/frontend/integrations/beyond_identity/components/add_exclusions_drawer_spec.js @@ -0,0 +1,99 @@ +import { GlDrawer } from '@gitlab/ui'; +import AddExclusionsDrawer from '~/integrations/beyond_identity/components/add_exclusions_drawer.vue'; +import ListSelector from '~/vue_shared/components/list_selector/index.vue'; +import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; +import { exclusionsMock } from './mock_data'; + +jest.mock('~/lib/utils/dom_utils', () => ({ + getContentWrapperHeight: jest.fn(), +})); + +describe('AddExclusionsDrawer component', () => { + let wrapper; + + const findTitle = () => wrapper.findByTestId('title'); + const findDrawer = () => wrapper.findComponent(GlDrawer); + const findListSelector = () => wrapper.findComponent(ListSelector); + const findAddExclusionsButton = () => wrapper.findByTestId('add-button'); + + const createComponent = (props) => { + return shallowMountExtended(AddExclusionsDrawer, { + propsData: { isOpen: true, ...props }, + }); + }; + + describe('default behavior', () => { + const mockHeaderHeight = '50px'; + + beforeEach(() => { + getContentWrapperHeight.mockReturnValue(mockHeaderHeight); + wrapper = createComponent(); + }); + + it('configures the drawer with header height and z-index', () => { + expect(findDrawer().props()).toMatchObject({ + headerHeight: mockHeaderHeight, + zIndex: DRAWER_Z_INDEX, + }); + }); + }); + + describe('when closed', () => { + beforeEach(() => { + wrapper = createComponent({ isOpen: false }); + }); + + it('the drawer is not shown', () => { + expect(findDrawer().props('open')).toBe(false); + }); + }); + + describe('when open', () => { + beforeEach(() => { + wrapper = createComponent({ isOpen: true }); + }); + + it('opens the drawer', () => { + expect(findDrawer().props('open')).toBe(true); + }); + + it('renders a title', () => { + expect(findTitle().text()).toEqual('Add exclusions'); + }); + + it('renders a project list selector', () => { + expect(findListSelector().props('type')).toBe('projects'); + }); + + it('renders a button for adding exclusions', () => { + expect(findAddExclusionsButton().exists()).toBe(true); + }); + }); + + describe('when exclusions are selected', () => { + beforeEach(() => { + wrapper = createComponent({ showDrawer: true }); + + findListSelector().vm.$emit('select', exclusionsMock[0]); + findListSelector().vm.$emit('select', exclusionsMock[1]); + }); + + it('adds it to the list of selected exclusions', () => { + expect(findListSelector().props('selectedItems')).toEqual(exclusionsMock); + }); + + describe('when Add exclusions button is clicked', () => { + beforeEach(() => findAddExclusionsButton().vm.$emit('click')); + + it('emits the selected exclusions', () => { + expect(wrapper.emitted('add')).toEqual([[exclusionsMock]]); + }); + + it('clears the list of selected exclusions', () => { + expect(findListSelector().props('selectedItems')).toEqual([]); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/beyond_identity/components/exclusions_list_item_spec.js b/spec/frontend/integrations/beyond_identity/components/exclusions_list_item_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1fb9141c98379b4034dd1828c652a73f9508bf18 --- /dev/null +++ b/spec/frontend/integrations/beyond_identity/components/exclusions_list_item_spec.js @@ -0,0 +1,59 @@ +import { GlButton, GlIcon, GlAvatar } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ExclusionsListItem from '~/integrations/beyond_identity/components/exclusions_list_item.vue'; +import { exclusionsMock } from './mock_data'; + +describe('ExclusionsListItem component', () => { + let wrapper; + const exclusion = exclusionsMock[0]; + + const findIcon = () => wrapper.findComponent(GlIcon); + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findByText = (text) => wrapper.findByText(text); + const findFindRemoveButton = () => wrapper.findComponent(GlButton); + + const createComponent = () => + shallowMountExtended(ExclusionsListItem, { propsData: { exclusion } }); + + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('default behavior', () => { + it('renders an icon', () => { + expect(findIcon().props('name')).toBe(exclusion.icon); + }); + + it('renders an avatar', () => { + expect(findAvatar().props()).toMatchObject({ + alt: exclusion.name, + entityName: exclusion.name, + size: 32, + shape: 'rect', + src: exclusion.avatarUrl, + fallbackOnError: true, + }); + }); + + it('renders a name', () => { + expect(findByText(exclusion.name).exists()).toBe(true); + }); + + it('renders a remove button', () => { + expect(findFindRemoveButton().attributes('aria-label')).toBe('Remove'); + + expect(findFindRemoveButton().props()).toMatchObject({ + icon: 'remove', + category: 'tertiary', + }); + }); + }); + + describe('remove button', () => { + it('emits remove event when clicked', () => { + findFindRemoveButton().vm.$emit('click'); + + expect(wrapper.emitted('remove')).toBeDefined(); + }); + }); +}); diff --git a/spec/frontend/integrations/beyond_identity/components/exclusions_list_spec.js b/spec/frontend/integrations/beyond_identity/components/exclusions_list_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..875e9f0a6f546f27dd8b85b8a58bc8375faeaec1 --- /dev/null +++ b/spec/frontend/integrations/beyond_identity/components/exclusions_list_spec.js @@ -0,0 +1,111 @@ +import { nextTick } from 'vue'; +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ExclusionsList from '~/integrations/beyond_identity/components/exclusions_list.vue'; +import AddExclusionsDrawer from '~/integrations/beyond_identity/components/add_exclusions_drawer.vue'; +import ExclusionsTabs from '~/integrations/beyond_identity/components/exclusions_tabs.vue'; +import ExclusionsListItem from '~/integrations/beyond_identity/components/exclusions_list_item.vue'; +import ConfirmRemovalModal from '~/integrations/beyond_identity/components/remove_exclusion_confirmation_modal.vue'; +import showToast from '~/vue_shared/plugins/global_toast'; +import { exclusionsMock } from './mock_data'; + +jest.mock('~/vue_shared/plugins/global_toast'); + +describe('ExclusionsList component', () => { + let wrapper; + + const findTabs = () => wrapper.findComponent(ExclusionsTabs); + const findListItems = () => wrapper.findAllComponents(ExclusionsListItem); + const findConfirmRemoveModal = () => wrapper.findComponent(ConfirmRemovalModal); + const findByText = (text) => wrapper.findByText(text); + const findAddExclusionsButton = () => findByText('Add exclusions'); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findDrawer = () => wrapper.findComponent(AddExclusionsDrawer); + + const createComponent = () => shallowMountExtended(ExclusionsList); + + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('default behavior', () => { + it('renders tabs', () => { + expect(findTabs().exists()).toBe(true); + }); + + it('renders help text', () => { + expect( + findByText('Projects in this list no longer require commits to be signed.').exists(), + ).toBe(true); + }); + + it('renders an Add exclusions button', () => { + expect(findAddExclusionsButton().exists()).toBe(true); + }); + + it('renders an Empty state', () => { + expect(findEmptyState().props('title')).toBe('There are no exclusions'); + }); + + it('does not render an open drawer', () => { + expect(findDrawer().props('isOpen')).toBe(false); + }); + }); + + describe('adding Exclusions', () => { + beforeEach(() => findAddExclusionsButton().vm.$emit('click')); + + it('opens a drawer', () => { + expect(findDrawer().props('isOpen')).toBe(true); + }); + + describe('Exclusions added', () => { + beforeEach(() => findDrawer().vm.$emit('add', exclusionsMock)); + + it('lists the added exclusions, sorted by name', async () => { + await nextTick(); + + expect(findListItems().at(0).props('exclusion')).toMatchObject(exclusionsMock[1]); + expect(findListItems().at(1).props('exclusion')).toMatchObject(exclusionsMock[0]); + }); + + it('closes the drawer', () => { + expect(findDrawer().props('isOpen')).toBe(false); + }); + }); + }); + + describe('removing Exclusions', () => { + beforeEach(async () => { + findAddExclusionsButton().vm.$emit('click'); + findDrawer().vm.$emit('add', exclusionsMock); + await nextTick(); + findListItems().at(1).vm.$emit('remove'); + }); + + it('opens a confirmation modal', () => { + expect(findConfirmRemoveModal().props()).toMatchObject({ + name: 'foo', + type: 'project', + visible: true, + }); + }); + + describe('confirmation modal primary action', () => { + beforeEach(() => findConfirmRemoveModal().vm.$emit('primary')); + + it('removes the exclusion', () => { + expect(findListItems().length).toBe(1); + }); + + it('renders a toast', () => { + expect(showToast).toHaveBeenCalledWith('Project exclusion removed', { + action: { + text: 'Undo', + onClick: expect.any(Function), + }, + }); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/beyond_identity/components/exclusions_tabs_spec.js b/spec/frontend/integrations/beyond_identity/components/exclusions_tabs_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..271122cd00f8ffb49b6869a4cf3ca268f2ad9d04 --- /dev/null +++ b/spec/frontend/integrations/beyond_identity/components/exclusions_tabs_spec.js @@ -0,0 +1,39 @@ +import { GlNavItem, GlTabs, GlTab } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ExclusionsTabs from '~/integrations/beyond_identity/components/exclusions_tabs.vue'; + +describe('ExclusionsTabs component', () => { + let wrapper; + const editPath = 'path/to/edit'; + + const findTabs = () => wrapper.findComponent(GlTabs); + const findNavItem = () => wrapper.findComponent(GlNavItem); + const findTab = () => wrapper.findComponent(GlTab); + + const createComponent = () => + shallowMountExtended(ExclusionsTabs, { + provide: { + editPath, + }, + stubs: { GlTabs }, + }); + + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('default behavior', () => { + it('renders a tabs component', () => { + expect(findTabs().exists()).toBe(true); + }); + + it('renders a nav item for Settings', () => { + expect(findNavItem().text()).toBe('Settings'); + expect(findNavItem().attributes('href')).toBe(editPath); + }); + + it('renders a tab for Exclusions', () => { + expect(findTab().text()).toBe('Exclusions'); + }); + }); +}); diff --git a/spec/frontend/integrations/beyond_identity/components/mock_data.js b/spec/frontend/integrations/beyond_identity/components/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..2759d1ce6caaf6591de6ea19125d05c1863f9309 --- /dev/null +++ b/spec/frontend/integrations/beyond_identity/components/mock_data.js @@ -0,0 +1,4 @@ +export const exclusionsMock = [ + { id: 1, name: 'foo', type: 'project', icon: 'project', avatarUrl: 'foo.png' }, + { id: 2, name: 'bar', type: 'project', icon: 'project', avatarUrl: 'bar.png' }, +]; diff --git a/spec/frontend/integrations/beyond_identity/components/remove_exclusion_confirmation_modal_spec.js b/spec/frontend/integrations/beyond_identity/components/remove_exclusion_confirmation_modal_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..92e40fca314c4b82de84b04a9110dcbe3a7637d5 --- /dev/null +++ b/spec/frontend/integrations/beyond_identity/components/remove_exclusion_confirmation_modal_spec.js @@ -0,0 +1,40 @@ +import { GlModal, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ConfirmRemovalModal from '~/integrations/beyond_identity/components/remove_exclusion_confirmation_modal.vue'; + +describe('ConfirmRemovalModal component', () => { + let wrapper; + const findModal = () => wrapper.findComponent(GlModal); + + const createComponent = () => + shallowMountExtended(ConfirmRemovalModal, { + propsData: { + visible: true, + name: 'Some project', + type: 'project', + }, + stubs: { GlSprintf }, + }); + + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('default behavior', () => { + it('renders a modal component', () => { + expect(findModal().props()).toMatchObject({ + actionPrimary: { text: 'Remove exclusion', attributes: { variant: 'danger' } }, + actionSecondary: { text: 'Cancel', attributes: { category: 'secondary' } }, + modalId: 'confirm-remove-exclusion', + title: 'Confirm project exclusion removal', + visible: true, + }); + }); + + it('renders body content', () => { + expect(findModal().text()).toBe( + "You're removing an exclusion for Some project. Are you sure you want to continue?", + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/list_selector/index_spec.js b/spec/frontend/vue_shared/components/list_selector/index_spec.js index 6de9a77582cd2a897816c8262047a6294c6765db..a51fd2eb65c42e856e58063f8f83d81167c7c2f0 100644 --- a/spec/frontend/vue_shared/components/list_selector/index_spec.js +++ b/spec/frontend/vue_shared/components/list_selector/index_spec.js @@ -2,11 +2,13 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { GlCard, GlIcon, GlCollapsibleListbox, GlSearchBoxByType } from '@gitlab/ui'; import Api from '~/api'; +import RestApi from '~/rest_api'; import { createAlert } from '~/alert'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ListSelector from '~/vue_shared/components/list_selector/index.vue'; import UserItem from '~/vue_shared/components/list_selector/user_item.vue'; import GroupItem from '~/vue_shared/components/list_selector/group_item.vue'; +import ProjectItem from '~/vue_shared/components/list_selector/project_item.vue'; import DeployKeyItem from '~/vue_shared/components/list_selector/deploy_key_item.vue'; import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -14,6 +16,14 @@ import waitForPromises from 'helpers/wait_for_promises'; import { USERS_RESPONSE_MOCK, GROUPS_RESPONSE_MOCK } from './mock_data'; jest.mock('~/alert'); +jest.mock('~/rest_api', () => ({ + getProjects: jest.fn().mockResolvedValue({ + data: [ + { name: 'Project 1', id: '1' }, + { name: 'Project 2', id: '2' }, + ], + }), +})); Vue.use(VueApollo); describe('List Selector spec', () => { @@ -36,6 +46,10 @@ describe('List Selector spec', () => { type: 'deployKeys', }; + const PROJECTS_MOCK_PROPS = { + type: 'projects', + }; + const groupsAutocompleteQuerySuccess = jest.fn().mockResolvedValue(GROUPS_RESPONSE_MOCK); const createComponent = async (props) => { @@ -60,6 +74,7 @@ describe('List Selector spec', () => { const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findAllUserComponents = () => wrapper.findAllComponents(UserItem); const findAllGroupComponents = () => wrapper.findAllComponents(GroupItem); + const findAllProjectComponents = () => wrapper.findAllComponents(ProjectItem); const findAllDeployKeyComponents = () => wrapper.findAllComponents(DeployKeyItem); beforeEach(() => { @@ -67,6 +82,14 @@ describe('List Selector spec', () => { jest.spyOn(Api, 'groupMembers').mockResolvedValue({ data: USERS_RESPONSE_MOCK }); }); + describe('empty state', () => { + beforeEach(() => createComponent(USERS_MOCK_PROPS)); + + it('renders an empty placeholder', () => { + expect(wrapper.findByText('No users have been added.').exists()).toBe(true); + }); + }); + describe('Users type', () => { const search = 'foo'; @@ -131,7 +154,6 @@ describe('List Selector spec', () => { it('renders a List box component with the correct props', () => { expect(findSearchResultsDropdown().props()).toMatchObject({ - multiple: true, items: searchResponse, }); }); @@ -142,7 +164,7 @@ describe('List Selector spec', () => { it('emits an event when a search result is selected', () => { const firstSearchResult = searchResponse[0]; - findAllUserComponents().at(0).vm.$emit('select', firstSearchResult.username); + findSearchResultsDropdown().vm.$emit('select', firstSearchResult.username); expect(wrapper.emitted('select')).toEqual([ [{ ...firstSearchResult, text: 'Administrator', value: 'root' }], @@ -214,7 +236,6 @@ describe('List Selector spec', () => { it('renders a dropdown for the search results', () => { expect(findSearchResultsDropdown().props()).toMatchObject({ - multiple: true, items: searchResponse, }); }); @@ -225,7 +246,7 @@ describe('List Selector spec', () => { it('emits an event when a search result is selected', () => { const firstSearchResult = searchResponse[0]; - findAllGroupComponents().at(0).vm.$emit('select', firstSearchResult.name); + findSearchResultsDropdown().vm.$emit('select', firstSearchResult.name); expect(wrapper.emitted('select')).toEqual([ [{ ...firstSearchResult, text: 'Flightjs', value: 'Flightjs' }], @@ -301,4 +322,71 @@ describe('List Selector spec', () => { // https://gitlab.com/gitlab-org/gitlab/-/issues/432494 }); }); + + describe('Projects type', () => { + beforeEach(() => createComponent(PROJECTS_MOCK_PROPS)); + + it('renders a correct title', () => { + expect(findTitle().text()).toContain('Projects'); + }); + + it('renders the correct icon', () => { + expect(findIcon().props('name')).toBe('project'); + }); + + describe('searching', () => { + const searchResponse = [ + { name: 'Project 1', id: '1' }, + { name: 'Project 2', id: '2' }, + ]; + const search = 'Project'; + + const emitSearchInput = async () => { + findSearchBox().vm.$emit('input', search); + await waitForPromises(); + }; + + beforeEach(() => emitSearchInput()); + + it('calls query with correct variables when Search box receives an input', () => { + expect(RestApi.getProjects).toHaveBeenCalledWith(search, { membership: false }); + }); + + it('renders a dropdown for the search results', () => { + expect(findSearchResultsDropdown().props()).toMatchObject({ + items: searchResponse, + }); + }); + + it('renders a project component for each search result', () => { + expect(findAllProjectComponents().length).toBe(searchResponse.length); + }); + }); + + describe('selected items', () => { + const selectedGroup = { name: 'Flightjs' }; + const selectedItems = [selectedGroup]; + beforeEach(() => createComponent({ ...GROUPS_MOCK_PROPS, selectedItems })); + + it('renders a heading with the total selected items', () => { + expect(findTitle().text()).toContain('Groups'); + expect(findTitle().text()).toContain('1'); + }); + + it('renders a group component for each selected item', () => { + expect(findAllGroupComponents().length).toBe(selectedItems.length); + expect(findAllGroupComponents().at(0).props()).toMatchObject({ + data: selectedGroup, + canDelete: true, + }); + }); + + it('emits a delete event when a delete event is emitted from the group component', () => { + const name = 'Flightjs'; + findAllGroupComponents().at(0).vm.$emit('delete', name); + + expect(wrapper.emitted('delete')).toEqual([[name]]); + }); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/list_selector/project_item_spec.js b/spec/frontend/vue_shared/components/list_selector/project_item_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..f0b0b241e5b776dafb99c6c03b529a704d45f935 --- /dev/null +++ b/spec/frontend/vue_shared/components/list_selector/project_item_spec.js @@ -0,0 +1,59 @@ +import { GlAvatar } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ProjectItem from '~/vue_shared/components/list_selector/project_item.vue'; + +describe('GroupItem spec', () => { + let wrapper; + + const MOCK_PROJECT = { + name: 'Project 1', + avatarUrl: 'some/avatar.jpg', + id: 1, + nameWithNamespace: 'Group 1 / Project 1', + }; + + const createComponent = (props) => { + wrapper = mountExtended(ProjectItem, { + propsData: { + data: MOCK_PROJECT, + ...props, + }, + }); + }; + + const findAvatar = () => wrapper.findComponent(GlAvatar); + const findDeleteButton = () => wrapper.findByRole('button', { fullName: 'Delete Group 1' }); + + beforeEach(() => createComponent()); + + it('renders an Avatar component', () => { + expect(findAvatar().props('size')).toBe(32); + expect(findAvatar().attributes()).toMatchObject({ + src: MOCK_PROJECT.avatarUrl, + alt: MOCK_PROJECT.name, + }); + }); + + it('renders a name and namespace', () => { + expect(wrapper.text()).toContain(MOCK_PROJECT.name); + expect(wrapper.text()).toContain(MOCK_PROJECT.nameWithNamespace); + }); + + it('does not render a delete button by default', () => { + expect(findDeleteButton().exists()).toBe(false); + }); + + describe('Delete button', () => { + beforeEach(() => createComponent({ canDelete: true })); + + it('renders a delete button', () => { + expect(findDeleteButton().props('icon')).toBe('remove'); + }); + + it('emits a delete event if the delete button is clicked', () => { + findDeleteButton().trigger('click'); + + expect(wrapper.emitted('delete')).toEqual([[MOCK_PROJECT.id]]); + }); + }); +});