From 26f68ff8c378138e72b93fbd3fbac85bcb6b31d4 Mon Sep 17 00:00:00 2001 From: Miranda Fluharty Date: Wed, 1 May 2024 22:34:52 -0600 Subject: [PATCH 1/3] Improve token access add form Show form feedback when group/project path is already in allowlist Replace text box with searchable collapsible listbox Get groups and projects via graphql, list them in the box Once selected, add group or project to allowlist when form is submitted Changelog: changed --- .../groups_and_projects_listbox.vue | 127 ++++++++++++++++++ .../components/inbound_token_access.vue | 62 ++++++--- .../get_groups_and_projects.query.graphql | 18 +++ locale/gitlab.pot | 9 +- .../groups_and_projects_listbox_spec.js | 105 +++++++++++++++ .../token_access/inbound_token_access_spec.js | 70 ++++++++-- spec/frontend/token_access/mock_data.js | 17 +++ 7 files changed, 373 insertions(+), 35 deletions(-) create mode 100644 app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue create mode 100644 app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql create mode 100644 spec/frontend/token_access/groups_and_projects_listbox_spec.js diff --git a/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue new file mode 100644 index 00000000000000..e5ab08d73a4e12 --- /dev/null +++ b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue @@ -0,0 +1,127 @@ + + diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue index 29dbc49f835aed..ac6aab537a3552 100644 --- a/app/assets/javascripts/token_access/components/inbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -3,7 +3,8 @@ import { GlAlert, GlButton, GlCard, - GlFormInput, + GlForm, + GlFormGroup, GlLink, GlIcon, GlLoadingIcon, @@ -21,6 +22,7 @@ import inboundRemoveGroupCIJobTokenScopeMutation from '../graphql/mutations/inbo import inboundUpdateCIJobTokenScopeMutation from '../graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql'; import inboundGetCIJobTokenScopeQuery from '../graphql/queries/inbound_get_ci_job_token_scope.query.graphql'; import inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery from '../graphql/queries/inbound_get_groups_and_projects_with_ci_job_token_scope.query.graphql'; +import GroupsAndProjectsListbox from './groups_and_projects_listbox.vue'; import TokenAccessTable from './token_access_table.vue'; export default { @@ -36,9 +38,7 @@ export default { addGroupOrProject: __('Add group or project'), add: __('Add'), cancel: __('Cancel'), - addProjectPlaceholder: __( - 'Paste group path (i.e. gitlab-org) or project path (i.e. gitlab-org/gitlab)', - ), + addProjectPlaceholder: __('Pick a group or project'), projectsFetchError: __('There was a problem fetching the projects'), scopeFetchError: __('There was a problem fetching the job token scope value'), }, @@ -46,12 +46,14 @@ export default { GlAlert, GlButton, GlCard, - GlFormInput, + GlForm, + GlFormGroup, GlLink, GlIcon, GlLoadingIcon, GlSprintf, GlToggle, + GroupsAndProjectsListbox, TokenAccessTable, }, directives: { @@ -112,6 +114,11 @@ export default { isGroupOrProjectPathEmpty() { return this.groupOrProjectPath === ''; }, + isGroupOrProjectPathInScope() { + return this.groupsAndProjectsWithAccess.some( + (item) => item.fullPath === this.groupOrProjectPath, + ); + }, ciJobTokenHelpPage() { return helpPagePath('ci/jobs/ci_job_token#control-job-token-access-to-your-project'); }, @@ -219,6 +226,9 @@ export default { this.getGroupsAndProjects(); } }, + setGroupOrProjectPath(path) { + this.groupOrProjectPath = path; + }, clearGroupOrProjectPath() { this.groupOrProjectPath = ''; this.isAddFormVisible = false; @@ -312,23 +322,33 @@ export default {
-

{{ $options.i18n.addGroupOrProject }}

- -
- {{ $options.i18n.addGroupOrProject }} + + - {{ $options.i18n.add }} - - {{ $options.i18n.cancel }} -
+ + +
+ + {{ $options.i18n.add }} + + {{ $options.i18n.cancel }} +
+
diff --git a/app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql b/app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql new file mode 100644 index 00000000000000..4d59dcebd3970e --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/queries/get_groups_and_projects.query.graphql @@ -0,0 +1,18 @@ +query getTokenAccessGroupsAndProjects($search: String) { + groups(search: $search, first: 4) { + nodes { + id + name + avatarUrl + fullPath + } + } + projects(search: $search, first: 4) { + nodes { + id + name + avatarUrl + fullPath + } + } +} diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1afd8e113877a0..82185d52cb4555 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -10172,6 +10172,9 @@ msgstr "" msgid "CICD|Prevent CI/CD job tokens from this project from being used to access other projects unless the other project is added to the allowlist. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API. %{linkStart}Learn more%{linkEnd}." msgstr "" +msgid "CICD|Target project is already in the job token scope." +msgstr "" + msgid "CICD|The %{boldStart}Limit access %{boldEnd}%{italicAndBoldStart}from%{italicAndBoldEnd}%{boldStart} this project%{boldEnd} setting is deprecated and will be removed in the 18.0 milestone. Use the %{boldStart}Limit access %{boldEnd}%{italicAndBoldStart}to%{italicAndBoldEnd}%{boldStart} this project%{boldEnd} setting and allowlist instead. %{linkStart}How do I do this?%{linkEnd}" msgstr "" @@ -38036,9 +38039,6 @@ msgstr "" msgid "Paste a public key here. %{link_start}How do I generate it?%{link_end}" msgstr "" -msgid "Paste group path (i.e. gitlab-org) or project path (i.e. gitlab-org/gitlab)" -msgstr "" - msgid "Paste project path (i.e. gitlab-org/gitlab)" msgstr "" @@ -38306,6 +38306,9 @@ msgstr "" msgid "PhoneVerification|You've reached the maximum number of tries. Wait %{interval} and try again." msgstr "" +msgid "Pick a group or project" +msgstr "" + msgid "Pick a name" msgstr "" diff --git a/spec/frontend/token_access/groups_and_projects_listbox_spec.js b/spec/frontend/token_access/groups_and_projects_listbox_spec.js new file mode 100644 index 00000000000000..60737e572f815c --- /dev/null +++ b/spec/frontend/token_access/groups_and_projects_listbox_spec.js @@ -0,0 +1,105 @@ +import { GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import GroupsAndProjectsListbox from '~/token_access/components/groups_and_projects_listbox.vue'; +import getGroupsAndProjectsQuery from '~/token_access/graphql/queries/get_groups_and_projects.query.graphql'; +import { getGroupsAndProjectsResponse } from './mock_data'; + +const placeholder = 'Pick a group or project'; + +Vue.use(VueApollo); + +describe('GroupsAndProjectsListbox component', () => { + let wrapper; + + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const openListbox = () => findListbox().vm.$emit('shown'); + const findListboxItems = () => wrapper.findAllComponents(GlListboxItem); + + const createComponent = ({ + mountFn = mountExtended, + requestHandlers = [ + [ + getGroupsAndProjectsQuery, + jest.fn().mockImplementation(({ search }) => { + if (search !== '') { + return { + data: { + groups: { + nodes: getGroupsAndProjectsResponse.data.groups.nodes.filter((group) => + group.name.includes(search), + ), + }, + projects: { + nodes: getGroupsAndProjectsResponse.data.projects.nodes.filter((project) => + project.name.includes(search), + ), + }, + }, + }; + } + return getGroupsAndProjectsResponse; + }), + ], + ], + value = '', + } = {}) => { + wrapper = mountFn(GroupsAndProjectsListbox, { + apolloProvider: createMockApollo(requestHandlers), + propsData: { + value, + placeholder, + }, + }); + }; + + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('displays a listbox', () => { + expect(findListbox().exists()).toBe(true); + }); + + it('displays the placeholder as the toggle text', () => { + expect(findListbox().props('toggleText')).toBe(placeholder); + }); + + it('lists groups and projects', () => { + expect(findListbox().props('items')).toMatchObject([ + { text: 'Groups', options: getGroupsAndProjectsResponse.data.groups.nodes }, + { text: 'Projects', options: getGroupsAndProjectsResponse.data.projects.nodes }, + ]); + }); + + it('searches for groups and projects', async () => { + expect(findListbox().props('items')[0].options).toHaveLength( + getGroupsAndProjectsResponse.data.groups.nodes.length, + ); + expect(findListbox().props('items')[1].options).toHaveLength( + getGroupsAndProjectsResponse.data.projects.nodes.length, + ); + + openListbox(); + findListbox().vm.$emit('search', 'gitlab'); + + await waitForPromises(); + + expect(findListbox().props('items')[0].options).toHaveLength(1); + expect(findListbox().props('items')[1].options).toHaveLength(1); + }); + + it('emits select event with the fullPath when an item is selected', () => { + openListbox(); + findListboxItems().at(0).trigger('click'); + + expect(wrapper.emitted('select')[0][0]).toEqual( + getGroupsAndProjectsResponse.data.groups.nodes[0].fullPath, + ); + }); +}); diff --git a/spec/frontend/token_access/inbound_token_access_spec.js b/spec/frontend/token_access/inbound_token_access_spec.js index 045156075c9d68..78a33817d928b9 100644 --- a/spec/frontend/token_access/inbound_token_access_spec.js +++ b/spec/frontend/token_access/inbound_token_access_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlFormInput, GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlCollapsibleListbox, GlLoadingIcon, GlToggle } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -6,17 +6,20 @@ import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_help import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; import InboundTokenAccess from '~/token_access/components/inbound_token_access.vue'; +import GroupsAndProjectsListbox from '~/token_access/components/groups_and_projects_listbox.vue'; import inboundAddGroupOrProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_add_group_or_project_ci_job_token_scope.mutation.graphql'; import inboundRemoveGroupCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_remove_group_ci_job_token_scope.mutation.graphql'; import inboundRemoveProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_remove_project_ci_job_token_scope.mutation.graphql'; import inboundUpdateCIJobTokenScopeMutation from '~/token_access/graphql/mutations/inbound_update_ci_job_token_scope.mutation.graphql'; import inboundGetCIJobTokenScopeQuery from '~/token_access/graphql/queries/inbound_get_ci_job_token_scope.query.graphql'; import inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery from '~/token_access/graphql/queries/inbound_get_groups_and_projects_with_ci_job_token_scope.query.graphql'; +import getGroupsAndProjectsQuery from '~/token_access/graphql/queries/get_groups_and_projects.query.graphql'; import { inboundJobTokenScopeEnabledResponse, inboundJobTokenScopeDisabledResponse, inboundGroupsAndProjectsWithScopeResponse, inboundGroupsAndProjectsWithScopeResponseWithAddedItem, + getGroupsAndProjectsResponse, inboundAddGroupOrProjectSuccessResponse, inboundRemoveGroupSuccess, inboundRemoveProjectSuccess, @@ -45,6 +48,9 @@ describe('TokenAccess component', () => { const inboundGroupsAndProjectsWithScopeResponseHandler = jest .fn() .mockResolvedValue(inboundGroupsAndProjectsWithScopeResponse); + const getGroupsAndProjectsSuccessResponseHandler = jest + .fn() + .mockResolvedValue(getGroupsAndProjectsResponse); const inboundAddGroupOrProjectSuccessResponseHandler = jest .fn() .mockResolvedValue(inboundAddGroupOrProjectSuccessResponse); @@ -61,7 +67,9 @@ describe('TokenAccess component', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAddProjectBtn = () => wrapper.findByTestId('add-project-btn'); const findCancelBtn = () => wrapper.findByRole('button', { name: 'Cancel' }); - const findProjectInput = () => wrapper.findComponent(GlFormInput); + const findGroupOrProjectFormGroup = () => wrapper.findByTestId('group-or-project-form-group'); + const findGroupsAndProjectsListbox = () => wrapper.findComponent(GroupsAndProjectsListbox); + const findListboxInput = () => wrapper.findComponent(GlCollapsibleListbox); const findRemoveProjectBtnAt = (i) => wrapper.findAllByRole('button', { name: 'Remove access' }).at(i); const findToggleFormBtn = () => wrapper.findByTestId('toggle-form-btn'); @@ -242,6 +250,7 @@ describe('TokenAccess component', () => { inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery, inboundGroupsAndProjectsWithScopeResponseHandler, ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], [ inboundAddGroupOrProjectCIJobTokenScopeMutation, inboundAddGroupOrProjectSuccessResponseHandler, @@ -253,7 +262,7 @@ describe('TokenAccess component', () => { await waitForPromises(); await findToggleFormBtn().trigger('click'); - await findProjectInput().vm.$emit('input', testPath); + await findListboxInput().vm.$emit('select', testPath); findAddProjectBtn().trigger('click'); expect(inboundAddGroupOrProjectSuccessResponseHandler).toHaveBeenCalledWith({ @@ -273,6 +282,7 @@ describe('TokenAccess component', () => { .mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponse) .mockResolvedValueOnce(inboundGroupsAndProjectsWithScopeResponseWithAddedItem), ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], [ inboundAddGroupOrProjectCIJobTokenScopeMutation, inboundAddGroupOrProjectSuccessResponseHandler, @@ -289,7 +299,7 @@ describe('TokenAccess component', () => { ); await findToggleFormBtn().trigger('click'); - await findProjectInput().vm.$emit('input', testPath); + await findListboxInput().vm.$emit('select', testPath); findAddProjectBtn().trigger('click'); await waitForPromises(); @@ -308,6 +318,7 @@ describe('TokenAccess component', () => { inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery, inboundGroupsAndProjectsWithScopeResponseHandler, ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], [inboundAddGroupOrProjectCIJobTokenScopeMutation, failureHandler], ], mountExtended, @@ -316,8 +327,8 @@ describe('TokenAccess component', () => { await waitForPromises(); await findToggleFormBtn().trigger('click'); - await findProjectInput().vm.$emit('input', testPath); - findAddProjectBtn().trigger('click'); + await findListboxInput().vm.$emit('select', testPath); + await findAddProjectBtn().trigger('click'); await waitForPromises(); @@ -332,6 +343,7 @@ describe('TokenAccess component', () => { inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery, inboundGroupsAndProjectsWithScopeResponseHandler, ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], ], mountExtended, ); @@ -340,17 +352,53 @@ describe('TokenAccess component', () => { await findToggleFormBtn().trigger('click'); - expect(findProjectInput().exists()).toBe(true); - - await findProjectInput().vm.$emit('input', testPath); + expect(findListboxInput().exists()).toBe(true); + await findListboxInput().vm.$emit('select', testPath); await findCancelBtn().trigger('click'); - expect(findProjectInput().exists()).toBe(false); + expect(findListboxInput().exists()).toBe(false); await findToggleFormBtn().trigger('click'); - expect(findProjectInput().element.value).toBe(''); + expect(findListboxInput().props('selected')).toEqual(''); + }); + }); + + describe.each` + type | testPath + ${'group'} | ${inboundGroupsAndProjectsWithScopeResponse.data.project.ciJobTokenScope.inboundAllowlist.nodes[0].fullPath} + ${'project'} | ${inboundGroupsAndProjectsWithScopeResponse.data.project.ciJobTokenScope.groupsAllowlist.nodes[0].fullPath} + `('add a duplicate $type', ({ testPath }) => { + it(`validates whether path is already in the allowlist`, async () => { + createComponent( + [ + [inboundGetCIJobTokenScopeQuery, inboundJobTokenScopeEnabledResponseHandler], + [ + inboundGetGroupsAndProjectsWithCIJobTokenScopeQuery, + inboundGroupsAndProjectsWithScopeResponseHandler, + ], + [getGroupsAndProjectsQuery, getGroupsAndProjectsSuccessResponseHandler], + [ + inboundAddGroupOrProjectCIJobTokenScopeMutation, + inboundAddGroupOrProjectSuccessResponseHandler, + ], + ], + mountExtended, + ); + + await waitForPromises(); + + await findToggleFormBtn().trigger('click'); + + expect(findGroupOrProjectFormGroup().attributes('aria-invalid')).toBe(undefined); + expect(findGroupsAndProjectsListbox().props('isValid')).toBe(true); + + await findListboxInput().vm.$emit('select', testPath); + + expect(findGroupOrProjectFormGroup().attributes('aria-invalid')).toBe('true'); + expect(findGroupsAndProjectsListbox().props('isValid')).toBe(false); + expect(findAddProjectBtn().props('disabled')).toBe(true); }); }); diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js index ab8f6d63c59152..54cb7788e2048f 100644 --- a/spec/frontend/token_access/mock_data.js +++ b/spec/frontend/token_access/mock_data.js @@ -236,6 +236,23 @@ export const inboundGroupsAndProjectsWithScopeResponseWithAddedItem = { }, }; +export const getGroupsAndProjectsResponse = { + data: { + groups: { + nodes: [ + { id: 1, name: 'gitlab-org', avatarUrl: '', fullPath: 'gitlab-org' }, + { id: 2, name: 'ci-group', avatarUrl: '', fullPath: 'root/ci-group' }, + ], + }, + projects: { + nodes: [ + { id: 1, name: 'gitlab', avatarUrl: '', fullPath: 'gitlab-org/gitlab' }, + { id: 2, name: 'ci-project', avatarUrl: '', fullPath: 'root/ci-project' }, + ], + }, + }, +}; + export const inboundAddGroupOrProjectSuccessResponse = { data: { ciJobTokenScopeAddProject: { -- GitLab From 8d8303ab8cc4aba3209b3f08a4d755498d40ebf2 Mon Sep 17 00:00:00 2001 From: Miranda Fluharty Date: Mon, 24 Jun 2024 16:04:20 -0600 Subject: [PATCH 2/3] Improvements from feedback Move in scope error string to $options.i18n Move groups header and projects header strings to $options.i18n --- .../components/groups_and_projects_listbox.vue | 15 ++++++++------- .../components/inbound_token_access.vue | 3 ++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue index e5ab08d73a4e12..a988c7a032e8f7 100644 --- a/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue +++ b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue @@ -4,10 +4,11 @@ import { __ } from '~/locale'; import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import getGroupsAndProjectsQuery from '../graphql/queries/get_groups_and_projects.query.graphql'; -const GROUPS_HEADER = __('Groups'); -const PROJECTS_HEADER = __('Projects'); - export default { + i18n: { + groupsHeader: __('Groups'), + projectsHeader: __('Projects'), + }, components: { GlCollapsibleListbox, ProjectAvatar, @@ -32,11 +33,11 @@ export default { return { groupsAndProjects: [ { - text: GROUPS_HEADER, + text: this.$options.i18n.groupsHeader, options: [], }, { - text: PROJECTS_HEADER, + text: this.$options.i18n.projectsHeader, options: [], }, ], @@ -64,11 +65,11 @@ export default { return [ { - text: GROUPS_HEADER, + text: this.$options.i18n.groupsHeader, options: groups, }, { - text: PROJECTS_HEADER, + text: this.$options.i18n.projectsHeader, options: projects, }, ]; diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue index ac6aab537a3552..ef5be9f499a17e 100644 --- a/app/assets/javascripts/token_access/components/inbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -41,6 +41,7 @@ export default { addProjectPlaceholder: __('Pick a group or project'), projectsFetchError: __('There was a problem fetching the projects'), scopeFetchError: __('There was a problem fetching the job token scope value'), + projectInScopeError: s__('CICD|Target project is already in the job token scope.'), }, components: { GlAlert, @@ -326,7 +327,7 @@ export default { Date: Tue, 2 Jul 2024 22:32:29 -0600 Subject: [PATCH 3/3] More suggested changes Simplify search mock Separate fetching from formatting Now that the strings are only in one place, just put them there --- .../groups_and_projects_listbox.vue | 57 +++++++------------ .../groups_and_projects_listbox_spec.js | 8 +-- 2 files changed, 24 insertions(+), 41 deletions(-) diff --git a/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue index a988c7a032e8f7..6bcc65694aa072 100644 --- a/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue +++ b/app/assets/javascripts/token_access/components/groups_and_projects_listbox.vue @@ -5,10 +5,6 @@ import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import getGroupsAndProjectsQuery from '../graphql/queries/get_groups_and_projects.query.graphql'; export default { - i18n: { - groupsHeader: __('Groups'), - projectsHeader: __('Projects'), - }, components: { GlCollapsibleListbox, ProjectAvatar, @@ -31,16 +27,7 @@ export default { }, data() { return { - groupsAndProjects: [ - { - text: this.$options.i18n.groupsHeader, - options: [], - }, - { - text: this.$options.i18n.projectsHeader, - options: [], - }, - ], + groupsAndProjects: { groups: [], projects: [] }, search: '', }; }, @@ -53,30 +40,27 @@ export default { }; }, update(data) { - const groups = - data?.groups.nodes.map((group) => { - return { ...group, value: group.fullPath }; - }) || []; - - const projects = - data?.projects.nodes.map((project) => { - return { ...project, value: project.fullPath }; - }) || []; - - return [ - { - text: this.$options.i18n.groupsHeader, - options: groups, - }, - { - text: this.$options.i18n.projectsHeader, - options: projects, - }, - ]; + return { + groups: data?.groups?.nodes, + projects: data?.projects?.nodes, + }; }, }, }, computed: { + listboxItems() { + const { groups = [], projects = [] } = this.groupsAndProjects; + return [ + { + text: __('Groups'), + options: groups.map(this.setValueToFullPath), + }, + { + text: __('Projects'), + options: projects.map(this.setValueToFullPath), + }, + ]; + }, loading() { return this.$apollo.queries.groupsAndProjects.loading; }, @@ -94,12 +78,15 @@ export default { onSearch(query) { this.search = query; }, + setValueToFullPath(item) { + return { ...item, value: item.fullPath }; + }, }, };