diff options
Diffstat (limited to 'app/assets/javascripts/projects')
13 files changed, 409 insertions, 81 deletions
diff --git a/app/assets/javascripts/projects/compare/components/app.vue b/app/assets/javascripts/projects/compare/components/app.vue index bda58091b97..4ba7156b026 100644 --- a/app/assets/javascripts/projects/compare/components/app.vue +++ b/app/assets/javascripts/projects/compare/components/app.vue @@ -15,7 +15,11 @@ export default { type: String, required: true, }, - refsProjectPath: { + sourceProjectRefsPath: { + type: String, + required: true, + }, + targetProjectRefsPath: { type: String, required: true, }, @@ -37,7 +41,11 @@ export default { type: String, required: true, }, - defaultProject: { + sourceProject: { + type: Object, + required: true, + }, + targetProject: { type: Object, required: true, }, @@ -50,14 +58,14 @@ export default { return { from: { projects: this.projects, - selectedProject: this.defaultProject, + selectedProject: this.targetProject, revision: this.paramsFrom, - refsProjectPath: this.refsProjectPath, + refsProjectPath: this.targetProjectRefsPath, }, to: { - selectedProject: this.defaultProject, + selectedProject: this.sourceProject, revision: this.paramsTo, - refsProjectPath: this.refsProjectPath, + refsProjectPath: this.sourceProjectRefsPath, }, }; }, diff --git a/app/assets/javascripts/projects/compare/index.js b/app/assets/javascripts/projects/compare/index.js index e485a086d39..074b8565c3c 100644 --- a/app/assets/javascripts/projects/compare/index.js +++ b/app/assets/javascripts/projects/compare/index.js @@ -5,13 +5,15 @@ export default function init() { const el = document.getElementById('js-compare-selector'); const { - refsProjectPath, + sourceProjectRefsPath, + targetProjectRefsPath, paramsFrom, paramsTo, projectCompareIndexPath, projectMergeRequestPath, createMrPath, - projectTo, + sourceProject, + targetProject, projectsFrom, } = el.dataset; @@ -23,13 +25,15 @@ export default function init() { render(createElement) { return createElement(CompareApp, { props: { - refsProjectPath, + sourceProjectRefsPath, + targetProjectRefsPath, paramsFrom, paramsTo, projectCompareIndexPath, projectMergeRequestPath, createMrPath, - defaultProject: JSON.parse(projectTo), + sourceProject: JSON.parse(sourceProject), + targetProject: JSON.parse(targetProject), projects: JSON.parse(projectsFrom), }, }); diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js index 28b77f6defd..0cfea401be6 100644 --- a/app/assets/javascripts/projects/pipelines/charts/index.js +++ b/app/assets/javascripts/projects/pipelines/charts/index.js @@ -17,6 +17,7 @@ const mountPipelineChartsApp = (el) => { coverageChartPath, defaultBranch, testRunsEmptyStateImagePath, + projectQualitySummaryFeedbackImagePath, } = el.dataset; const shouldRenderDoraCharts = parseBoolean(el.dataset.shouldRenderDoraCharts); @@ -37,6 +38,7 @@ const mountPipelineChartsApp = (el) => { coverageChartPath, defaultBranch, testRunsEmptyStateImagePath, + projectQualitySummaryFeedbackImagePath, }, render: (createElement) => createElement(ProjectPipelinesCharts, {}), }); diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index fe84660422b..424ea3b61c5 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { debounce } from 'lodash'; import DEFAULT_PROJECT_TEMPLATES from 'any_else_ce/projects/default_project_templates'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import Tracking from '~/tracking'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '../lib/utils/constants'; import { ENTER_KEY } from '../lib/utils/keys'; import axios from '../lib/utils/axios_utils'; @@ -109,8 +110,31 @@ const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => { ); }; + const projectPathValueListener = () => { + // eslint-disable-next-line no-param-reassign + $projectPathInput.oldInputValue = $projectPathInput.value; + }; + + const projectPathTrackListener = () => { + if ($projectPathInput.oldInputValue === $projectPathInput.value) { + // no change made to the input + return; + } + + const trackEvent = 'user_input_path_slug'; + const trackCategory = undefined; // will be default set in event method + + Tracking.event(trackCategory, trackEvent, { + label: 'new_project_form', + }); + }; + $projectPathInput.removeEventListener('keyup', projectPathInputListener); $projectPathInput.addEventListener('keyup', projectPathInputListener); + $projectPathInput.removeEventListener('focus', projectPathValueListener); + $projectPathInput.addEventListener('focus', projectPathValueListener); + $projectPathInput.removeEventListener('blur', projectPathTrackListener); + $projectPathInput.addEventListener('blur', projectPathTrackListener); $projectPathInput.removeEventListener('change', projectPathInputListener); $projectPathInput.addEventListener('change', projectPathInputListener); diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue index 6bbe0ab7d5f..6ba2ef7da99 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/branch_dropdown.vue @@ -1,12 +1,24 @@ <script> -import { GlDropdown, GlDropdownItem, GlDropdownDivider, GlSearchBoxByType } from '@gitlab/ui'; +import { + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlSearchBoxByType, + GlSprintf, + GlLink, +} from '@gitlab/ui'; import { createAlert } from '~/flash'; -import { __, sprintf } from '~/locale'; +import { s__, sprintf } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; import branchesQuery from '../queries/branches.query.graphql'; export const i18n = { - fetchBranchesError: __('An error occurred while fetching branches.'), - noMatch: __('No matching results'), + fetchBranchesError: s__('BranchRules|An error occurred while fetching branches.'), + noMatch: s__('BranchRules|No matching results'), + branchHelpText: s__( + 'BranchRules|%{linkStart}Wildcards%{linkEnd} such as *-stable or production/* are supported.', + ), + wildCardSearchHelp: s__('BranchRules|Create wildcard: %{searchTerm}'), }; export default { @@ -17,6 +29,8 @@ export default { GlDropdownItem, GlDropdownDivider, GlSearchBoxByType, + GlSprintf, + GlLink, }, apollo: { branchNames: { @@ -39,6 +53,10 @@ export default { }, }, }, + searchInputDelay: 250, + wildcardsHelpPath: helpPagePath('user/project/protected_branches', { + anchor: 'configure-multiple-protected-branches-by-using-a-wildcard', + }), props: { projectPath: { type: String, @@ -58,7 +76,9 @@ export default { }, computed: { createButtonLabel() { - return sprintf(__('Create wildcard: %{searchTerm}'), { searchTerm: this.searchTerm }); + return sprintf(this.$options.i18n.wildCardSearchHelp, { + searchTerm: this.searchTerm, + }); }, shouldRenderCreateButton() { return this.searchTerm && !this.branchNames.includes(this.searchTerm); @@ -81,30 +101,37 @@ export default { }; </script> <template> - <gl-dropdown :text="value || branchNames[0]"> - <gl-search-box-by-type - v-model.trim="searchTerm" - data-testid="branch-search" - debounce="250" - :is-loading="isLoading" - /> - <gl-dropdown-item - v-for="branch in branchNames" - :key="branch" - :is-checked="isSelected(branch)" - is-check-item - @click="selectBranch(branch)" - > - {{ branch }} - </gl-dropdown-item> - <gl-dropdown-item v-if="!branchNames.length && !isLoading" data-testid="no-data">{{ - $options.i18n.noMatch - }}</gl-dropdown-item> - <template v-if="shouldRenderCreateButton"> - <gl-dropdown-divider /> - <gl-dropdown-item data-testid="create-wildcard-button" @click="createWildcard"> - {{ createButtonLabel }} + <div> + <gl-dropdown :text="value || branchNames[0]" class="gl-w-full"> + <gl-search-box-by-type + v-model.trim="searchTerm" + data-testid="branch-search" + :debounce="$options.searchInputDelay" + :is-loading="isLoading" + /> + <gl-dropdown-item + v-for="branch in branchNames" + :key="branch" + :is-checked="isSelected(branch)" + is-check-item + @click="selectBranch(branch)" + > + {{ branch }} </gl-dropdown-item> - </template> - </gl-dropdown> + <gl-dropdown-item v-if="!branchNames.length && !isLoading" data-testid="no-data">{{ + $options.i18n.noMatch + }}</gl-dropdown-item> + <template v-if="shouldRenderCreateButton"> + <gl-dropdown-divider /> + <gl-dropdown-item data-testid="create-wildcard-button" @click="createWildcard"> + {{ createButtonLabel }} + </gl-dropdown-item> + </template> + </gl-dropdown> + <gl-sprintf :message="$options.i18n.branchHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.wildcardsHelpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> </template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue new file mode 100644 index 00000000000..bcc0f64d667 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/index.vue @@ -0,0 +1,59 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import PushProtections from './push_protections.vue'; +import MergeProtections from './merge_protections.vue'; + +export const i18n = { + protections: s__('BranchRules|Protections'), + protectionsHelpText: s__( + 'BranchRules|Keep stable branches secure and force developers to use merge requests. %{linkStart}What are protected branches?%{linkEnd}', + ), +}; + +export default { + name: 'BranchProtections', + i18n, + components: { + GlSprintf, + GlLink, + PushProtections, + MergeProtections, + }, + protectedBranchesHelpPath: helpPagePath('user/project/protected_branches'), + props: { + protections: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <h4 class="gl-border-t gl-pt-4">{{ $options.i18n.protections }}</h4> + + <div data-testid="protections-help-text"> + <gl-sprintf :message="$options.i18n.protectionsHelpText"> + <template #link="{ content }"> + <gl-link :href="$options.protectedBranchesHelpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + + <push-protections + class="gl-mt-5" + :members-allowed-to-push="protections.membersAllowedToPush" + :allow-force-push="protections.allowForcePush" + v-on="$listeners" + /> + + <merge-protections + :members-allowed-to-merge="protections.membersAllowedToMerge" + :require-code-owners-approval="protections.requireCodeOwnersApproval" + v-on="$listeners" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue new file mode 100644 index 00000000000..85f168af4a8 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/merge_protections.vue @@ -0,0 +1,46 @@ +<script> +import { GlFormGroup, GlFormCheckbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export const i18n = { + allowedToMerge: s__('BranchRules|Allowed to merge'), + requireApprovalTitle: s__('BranchRules|Require approval from code owners.'), + requireApprovalHelpText: s__( + 'BranchRules|Reject code pushes that change files listed in the CODEOWNERS file.', + ), +}; + +export default { + name: 'BranchMergeProtections', + i18n, + components: { + GlFormGroup, + GlFormCheckbox, + }, + props: { + membersAllowedToMerge: { + type: Array, + required: true, + }, + requireCodeOwnersApproval: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <gl-form-group :label="$options.i18n.allowedToMerge"> + <!-- TODO: add multi-select-dropdown (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) --> + + <gl-form-checkbox + class="gl-mt-5" + :checked="requireCodeOwnersApproval" + @change="$emit('change-require-code-owners-approval', $event)" + > + <span>{{ $options.i18n.requireApprovalTitle }}</span> + <template #help>{{ $options.i18n.requireApprovalHelpText }}</template> + </gl-form-checkbox> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue b/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue new file mode 100644 index 00000000000..541923bb735 --- /dev/null +++ b/app/assets/javascripts/projects/settings/branch_rules/components/protections/push_protections.vue @@ -0,0 +1,52 @@ +<script> +import { GlFormGroup, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const i18n = { + allowedToPush: s__('BranchRules|Allowed to push'), + forcePushTitle: s__( + 'BranchRules|Allow all users with push access to %{linkStart}force push%{linkEnd}.', + ), +}; + +export default { + name: 'BranchPushProtections', + i18n, + components: { + GlFormGroup, + GlSprintf, + GlLink, + GlFormCheckbox, + }, + forcePushHelpPath: helpPagePath('topics/git/git_rebase', { anchor: 'force-push' }), + props: { + membersAllowedToPush: { + type: Array, + required: true, + }, + allowForcePush: { + type: Boolean, + required: true, + }, + }, +}; +</script> + +<template> + <gl-form-group :label="$options.i18n.allowedToPush"> + <!-- TODO: add multi-select-dropdown (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) --> + + <gl-form-checkbox + class="gl-mt-5" + :checked="allowForcePush" + @change="$emit('change-allow-force-push', $event)" + > + <gl-sprintf :message="$options.i18n.forcePushTitle"> + <template #link="{ content }"> + <gl-link :href="$options.forcePushHelpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-form-checkbox> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue index c2e7f4e9b1b..ad3eb7d2899 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/rule_edit.vue @@ -1,15 +1,18 @@ <script> import { GlFormGroup } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import { getParameterByName } from '~/lib/utils/url_utility'; import BranchDropdown from './branch_dropdown.vue'; +import Protections from './protections/index.vue'; export default { name: 'RuleEdit', - i18n: { - branch: __('Branch'), + i18n: { branch: s__('BranchRules|Branch') }, + components: { + BranchDropdown, + GlFormGroup, + Protections, }, - components: { BranchDropdown, GlFormGroup }, props: { projectPath: { type: String, @@ -19,20 +22,35 @@ export default { data() { return { branch: getParameterByName('branch'), + protections: { + membersAllowedToPush: [], + allowForcePush: false, + membersAllowedToMerge: [], + requireCodeOwnersApproval: false, + }, }; }, }; </script> <template> - <gl-form-group :label="$options.i18n.branch"> - <branch-dropdown - id="branches" - v-model="branch" - class="gl-w-half" - :project-path="projectPath" - @createWildcard="branch = $event" + <div> + <gl-form-group :label="$options.i18n.branch"> + <branch-dropdown + id="branches" + v-model="branch" + class="gl-w-half" + :project-path="projectPath" + @createWildcard="branch = $event" + /> + </gl-form-group> + + <protections + :protections="protections" + @change-allowed-to-push-members="protections.membersAllowedToPush = $event" + @change-allow-force-push="protections.allowForcePush = $event" + @change-allowed-to-merge-members="protections.membersAllowedToMerge = $event" + @change-require-code-owners-approval="protections.requireCodeOwnersApproval = $event" /> - </gl-form-group> - <!-- TODO - Add branch protections (https://gitlab.com/gitlab-org/gitlab/-/issues/362212) --> + </div> </template> diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index fcf81c9d1f7..2209172c06d 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -262,8 +262,8 @@ export default { const selectedUsers = this.preselectedItems .filter(({ type }) => type === LEVEL_TYPES.USER) - .map(({ user_id, name, username, avatar_url, type }) => ({ - id: user_id, + .map(({ user_id: id, name, username, avatar_url, type }) => ({ + id, name, username, avatar_url, diff --git a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue index fe968e74c6d..c13753da00b 100644 --- a/app/assets/javascripts/projects/settings/components/transfer_project_form.vue +++ b/app/assets/javascripts/projects/settings/components/transfer_project_form.vue @@ -1,7 +1,13 @@ <script> import { GlFormGroup } from '@gitlab/ui'; -import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import produce from 'immer'; import ConfirmDanger from '~/vue_shared/components/confirm_danger/confirm_danger.vue'; +import NamespaceSelect from '~/vue_shared/components/namespace_select/namespace_select.vue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import searchNamespacesWhereUserCanTransferProjects from '../graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql'; + +const GROUPS_PER_PAGE = 25; export default { name: 'TransferProjectForm', @@ -11,14 +17,6 @@ export default { ConfirmDanger, }, props: { - groupNamespaces: { - type: Array, - required: true, - }, - userNamespaces: { - type: Array, - required: true, - }, confirmationPhrase: { type: String, required: true, @@ -28,19 +26,88 @@ export default { required: true, }, }, + apollo: { + currentUser: { + query: searchNamespacesWhereUserCanTransferProjects, + debounce: DEBOUNCE_DELAY, + variables() { + return { + search: this.searchTerm, + after: null, + first: GROUPS_PER_PAGE, + }; + }, + result() { + this.isLoadingMoreGroups = false; + this.isSearchLoading = false; + }, + }, + }, data() { - return { selectedNamespace: null }; + return { + currentUser: {}, + selectedNamespace: null, + isLoadingMoreGroups: false, + isSearchLoading: false, + searchTerm: '', + }; }, computed: { hasSelectedNamespace() { return Boolean(this.selectedNamespace?.id); }, + groupNamespaces() { + return this.currentUser.groups?.nodes?.map(this.formatNamespace) || []; + }, + userNamespaces() { + const { namespace } = this.currentUser; + + return namespace ? [this.formatNamespace(namespace)] : []; + }, + hasNextPageOfGroups() { + return this.currentUser.groups?.pageInfo?.hasNextPage || false; + }, }, methods: { handleSelect(selectedNamespace) { this.selectedNamespace = selectedNamespace; this.$emit('selectNamespace', selectedNamespace.id); }, + handleLoadMoreGroups() { + this.isLoadingMoreGroups = true; + + this.$apollo.queries.currentUser.fetchMore({ + variables: { + after: this.currentUser.groups.pageInfo.endCursor, + first: GROUPS_PER_PAGE, + }, + updateQuery( + previousResult, + { + fetchMoreResult: { + currentUser: { groups: newGroups }, + }, + }, + ) { + const previousGroups = previousResult.currentUser.groups; + + return produce(previousResult, (draftData) => { + draftData.currentUser.groups.nodes = [...previousGroups.nodes, ...newGroups.nodes]; + draftData.currentUser.groups.pageInfo = newGroups.pageInfo; + }); + }, + }); + }, + handleSearch(searchTerm) { + this.isSearchLoading = true; + this.searchTerm = searchTerm; + }, + formatNamespace({ id, fullName }) { + return { + id: getIdFromGraphQLId(id), + humanName: fullName, + }; + }, }, }; </script> @@ -53,11 +120,16 @@ export default { :group-namespaces="groupNamespaces" :user-namespaces="userNamespaces" :selected-namespace="selectedNamespace" + :has-next-page-of-groups="hasNextPageOfGroups" + :is-loading-more-groups="isLoadingMoreGroups" + :is-search-loading="isSearchLoading" + :should-filter-namespaces="false" @select="handleSelect" + @load-more-groups="handleLoadMoreGroups" + @search="handleSearch" /> </gl-form-group> <confirm-danger - button-class="qa-transfer-button" :disabled="!hasSelectedNamespace" :phrase="confirmationPhrase" :button-text="confirmButtonText" diff --git a/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql new file mode 100644 index 00000000000..d4bcb8c869c --- /dev/null +++ b/app/assets/javascripts/projects/settings/graphql/queries/search_namespaces_where_user_can_transfer_projects.query.graphql @@ -0,0 +1,24 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query searchNamespacesWhereUserCanTransferProjects( + $search: String = "" + $after: String = "" + $first: Int = null +) { + currentUser { + id + groups(permissionScope: TRANSFER_PROJECTS, search: $search, after: $after, first: $first) { + nodes { + id + fullName + } + pageInfo { + ...PageInfo + } + } + namespace { + id + fullName + } + } +} diff --git a/app/assets/javascripts/projects/settings/init_transfer_project_form.js b/app/assets/javascripts/projects/settings/init_transfer_project_form.js index a5f720bffaa..bc1aff640d2 100644 --- a/app/assets/javascripts/projects/settings/init_transfer_project_form.js +++ b/app/assets/javascripts/projects/settings/init_transfer_project_form.js @@ -1,36 +1,29 @@ import Vue from 'vue'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; import TransferProjectForm from './components/transfer_project_form.vue'; -const prepareNamespaces = (rawNamespaces = '') => { - if (!rawNamespaces) { - return { groupNamespaces: [], userNamespaces: [] }; - } - - const data = JSON.parse(rawNamespaces); - return { - groupNamespaces: data?.group?.map(convertObjectPropsToCamelCase) || [], - userNamespaces: data?.user?.map(convertObjectPropsToCamelCase) || [], - }; -}; - export default () => { const el = document.querySelector('.js-transfer-project-form'); if (!el) { return false; } + Vue.use(VueApollo); + const { targetFormId = null, targetHiddenInputId = null, buttonText: confirmButtonText = '', phrase: confirmationPhrase = '', confirmDangerMessage = '', - namespaces = '', } = el.dataset; return new Vue({ el, + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), provide: { confirmDangerMessage, }, @@ -39,7 +32,6 @@ export default () => { props: { confirmButtonText, confirmationPhrase, - ...prepareNamespaces(namespaces), }, on: { selectNamespace: (id) => { |