fix(editor): Universal button snags (#11974)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions

Co-authored-by: Csaba Tuncsik <csaba.tuncsik@gmail.com>
This commit is contained in:
Raúl Gómez Morales 2024-12-06 08:52:07 +01:00 committed by GitHub
parent b1f8663265
commit 956b11a560
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 245 additions and 56 deletions

View file

@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => {
it('default slot should trigger first level', async () => {
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: { menu: [{ id: 'aaa', title: 'aaa', route: { name: 'projects' } }] },
props: { menu: [{ id: 'first', title: 'first', route: { name: 'projects' } }] },
global: {
plugins: [router],
},
@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => {
props: {
menu: [
{
id: 'aaa',
title: 'aaa',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }],
id: 'first',
title: 'first',
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }],
},
],
},
@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => {
props: {
menu: [
{
id: 'aaa',
title: 'aaa',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
id: 'first',
title: 'first',
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
},
],
},
@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => {
props: {
menu: [
{
id: 'aaa',
title: 'aaa',
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
id: 'first',
title: 'first',
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
},
],
},
@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => {
await userEvent.click(getByTestId('navigation-submenu-item'));
expect(emitted('itemClick')).toStrictEqual([
[{ active: true, index: 'bbb', indexPath: ['-1', 'aaa', 'bbb'] }],
[{ active: true, index: 'nested', indexPath: ['-1', 'first', 'nested'] }],
]);
expect(emitted('select')).toStrictEqual([['bbb']]);
expect(emitted('select')).toStrictEqual([['nested']]);
});
it('should open first level on click', async () => {
const { getByTestId, getByText } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: {
menu: [
{
id: 'first',
title: 'first',
},
],
},
});
expect(getByText('first')).not.toBeVisible();
await userEvent.click(getByTestId('test-trigger'));
expect(getByText('first')).toBeVisible();
});
it('should toggle nested level on mouseenter / mouseleave', async () => {
const { getByTestId, getByText } = render(NavigationDropdown, {
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
props: {
menu: [
{
id: 'first',
title: 'first',
submenu: [{ id: 'nested', title: 'nested' }],
},
],
},
});
expect(getByText('first')).not.toBeVisible();
await userEvent.click(getByTestId('test-trigger'));
expect(getByText('first')).toBeVisible();
expect(getByText('nested')).not.toBeVisible();
await userEvent.hover(getByTestId('navigation-submenu'));
await waitFor(() => expect(getByText('nested')).toBeVisible());
await userEvent.pointer([
{ target: getByTestId('navigation-submenu') },
{ target: getByTestId('test-trigger') },
]);
await waitFor(() => expect(getByText('nested')).not.toBeVisible());
});
});

View file

@ -29,7 +29,7 @@ defineProps<{
}>();
const menuRef = ref<typeof ElMenu | null>(null);
const menuIndex = ref('-1');
const ROOT_MENU_INDEX = '-1';
const emit = defineEmits<{
itemClick: [item: MenuItemRegistered];
@ -37,7 +37,18 @@ const emit = defineEmits<{
}>();
const close = () => {
menuRef.value?.close(menuIndex.value);
menuRef.value?.close(ROOT_MENU_INDEX);
};
const menuTrigger = ref<'click' | 'hover'>('click');
const onOpen = (index: string) => {
if (index !== ROOT_MENU_INDEX) return;
menuTrigger.value = 'hover';
};
const onClose = (index: string) => {
if (index !== ROOT_MENU_INDEX) return;
menuTrigger.value = 'click';
};
defineExpose({
@ -50,14 +61,16 @@ defineExpose({
ref="menuRef"
mode="horizontal"
unique-opened
menu-trigger="click"
:menu-trigger="menuTrigger"
:ellipsis="false"
:class="$style.dropdown"
@select="emit('select', $event)"
@keyup.escape="close"
@open="onOpen"
@close="onClose"
>
<ElSubMenu
:index="menuIndex"
:index="ROOT_MENU_INDEX"
:class="$style.trigger"
:popper-offset="-10"
:popper-class="$style.submenu"
@ -70,10 +83,15 @@ defineExpose({
<template v-for="item in menu" :key="item.id">
<template v-if="item.submenu">
<ElSubMenu :index="item.id" :popper-offset="-10" data-test-id="navigation-submenu">
<ElSubMenu
:popper-class="$style.nestedSubmenu"
:index="item.id"
:popper-offset="-10"
data-test-id="navigation-submenu"
>
<template #title>{{ item.title }}</template>
<template v-for="subitem in item.submenu" :key="subitem.id">
<ConditionalRouterLink :to="!subitem.disabled && subitem.route">
<ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
<ElMenuItem
data-test-id="navigation-submenu-item"
:index="subitem.id"
@ -82,18 +100,20 @@ defineExpose({
>
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
{{ subitem.title }}
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
</ElMenuItem>
</ConditionalRouterLink>
</template>
</ElSubMenu>
</template>
<ConditionalRouterLink v-else :to="!item.disabled && item.route">
<ConditionalRouterLink v-else :to="(!item.disabled && item.route) || undefined">
<ElMenuItem
:index="item.id"
:disabled="item.disabled"
data-test-id="navigation-menu-item"
>
{{ item.title }}
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
</ElMenuItem>
</ConditionalRouterLink>
</template>
@ -125,17 +145,25 @@ defineExpose({
}
}
.nestedSubmenu {
:global(.el-menu) {
max-height: 450px;
overflow: auto;
}
}
.submenu {
padding: 5px 0 !important;
:global(.el-menu--horizontal .el-menu .el-menu-item),
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
color: var(--color-text-dark);
background-color: var(--color-menu-background);
}
:global(.el-menu--horizontal .el-menu .el-menu-item:not(.is-disabled):hover),
:global(.el-menu--horizontal .el-menu .el-sub-menu__title:not(.is-disabled):hover) {
background-color: var(--color-foreground-base);
background-color: var(--color-menu-hover-background);
}
:global(.el-popper) {

View file

@ -462,6 +462,10 @@
--color-configurable-node-name: var(--color-text-dark);
--color-secondary-link: var(--prim-color-secondary-tint-200);
--color-secondary-link-hover: var(--prim-color-secondary-tint-100);
--color-menu-background: var(--prim-gray-740);
--color-menu-hover-background: var(--prim-gray-670);
--color-menu-active-background: var(--prim-gray-670);
}
body[data-theme='dark'] {

View file

@ -533,6 +533,11 @@
--color-secondary-link: var(--color-secondary);
--color-secondary-link-hover: var(--color-secondary-shade-1);
// Menu
--color-menu-background: var(--prim-gray-0);
--color-menu-hover-background: var(--prim-gray-120);
--color-menu-active-background: var(--prim-gray-120);
// Generated Color Shades from 50 to 950
// Not yet used in design system
@each $color in ('neutral', 'success', 'warning', 'danger') {

View file

@ -291,7 +291,12 @@ const checkWidthAndAdjustSidebar = async (width: number) => {
}
};
const { menu, handleSelect: handleMenuSelect } = useGlobalEntityCreation();
const {
menu,
handleSelect: handleMenuSelect,
createProjectAppendSlotName,
projectsLimitReachedMessage,
} = useGlobalEntityCreation();
onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close();
});
@ -311,8 +316,8 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
:class="['clickable', $style.sideMenuCollapseButton]"
@click="toggleCollapse"
>
<n8n-icon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
<n8n-icon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
<N8nIcon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
<N8nIcon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
</div>
<div :class="$style.logo">
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
@ -323,9 +328,21 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
@select="handleMenuSelect"
>
<N8nIconButton icon="plus" type="secondary" outline />
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip v-if="item.disabled" placement="right" :content="projectsLimitReachedMessage">
<N8nButton
:size="'mini'"
style="margin-left: auto"
type="tertiary"
@click="handleMenuSelect(item.id)"
>
{{ i18n.baseText('generic.upgrade') }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown>
</div>
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
<N8nMenu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
<template #header>
<ProjectNavigation
:collapsed="isCollapsed"
@ -347,14 +364,14 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
<div :class="$style.giftContainer">
<GiftNotificationIcon />
</div>
<n8n-text
<N8nText
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
color="text-base"
>
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
nextVersions.length > 1 ? 's' : ''
}}
</n8n-text>
</N8nText>
</div>
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
</div>
@ -363,35 +380,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
<div ref="user" :class="$style.userArea">
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
<!-- This dropdown is only enabled when sidebar is collapsed -->
<el-dropdown placement="right-end" trigger="click" @command="onUserActionToggle">
<ElDropdown placement="right-end" trigger="click" @command="onUserActionToggle">
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
<n8n-avatar
<N8nAvatar
:first-name="usersStore.currentUser?.firstName"
:last-name="usersStore.currentUser?.lastName"
size="small"
/>
</div>
<template v-if="isCollapsed" #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="settings">
<ElDropdownMenu>
<ElDropdownItem command="settings">
{{ i18n.baseText('settings') }}
</el-dropdown-item>
<el-dropdown-item command="logout">
</ElDropdownItem>
<ElDropdownItem command="logout">
{{ i18n.baseText('auth.signout') }}
</el-dropdown-item>
</el-dropdown-menu>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</el-dropdown>
</ElDropdown>
</div>
<div
:class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }"
>
<n8n-text size="small" :bold="true" color="text-dark">{{
<N8nText size="small" :bold="true" color="text-dark">{{
usersStore.currentUser?.fullName
}}</n8n-text>
}}</N8nText>
</div>
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
<n8n-action-dropdown
<N8nActionDropdown
:items="userMenuItems"
placement="top-start"
data-test-id="user-menu"
@ -400,7 +417,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</div>
</div>
</template>
</n8n-menu>
</N8nMenu>
</div>
</template>

View file

@ -3,11 +3,13 @@ import { within } from '@testing-library/dom';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createTestProject } from '@/__tests__/data/projects';
import { useRoute } from 'vue-router';
import * as router from 'vue-router';
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { useProjectsStore } from '@/stores/projects.store';
import type { Project } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { VIEWS } from '@/constants';
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router');
@ -35,13 +37,13 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
},
});
let route: ReturnType<typeof useRoute>;
let route: ReturnType<typeof router.useRoute>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
describe('ProjectHeader', () => {
beforeEach(() => {
createTestingPinia();
route = useRoute();
route = router.useRoute();
projectsStore = mockedStore(useProjectsStore);
projectsStore.teamProjectsLimit = -1;
@ -159,4 +161,21 @@ describe('ProjectHeader', () => {
expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible();
});
it('should not render creation button in setting page', async () => {
projectsStore.currentProject = createTestProject({ type: ProjectTypes.Personal });
vi.spyOn(router, 'useRoute').mockReturnValueOnce({
name: VIEWS.PROJECT_SETTINGS,
} as RouteLocationNormalizedLoadedGeneric);
const { queryByTestId } = renderComponent({
global: {
stubs: {
N8nNavigationDropdown: {
template: '<div><slot></slot></div>',
},
},
},
});
expect(queryByTestId('resource-add')).not.toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, type Ref, ref } from 'vue';
import { useRoute } from 'vue-router';
import { N8nNavigationDropdown } from 'n8n-design-system';
import { N8nNavigationDropdown, N8nButton, N8nIconButton, N8nTooltip } from 'n8n-design-system';
import { onClickOutside, type VueInstance } from '@vueuse/core';
import { useI18n } from '@/composables/useI18n';
import { ProjectTypes } from '@/types/projects.types';
@ -9,6 +9,7 @@ import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { getResourcePermissions } from '@/permissions';
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
import { VIEWS } from '@/constants';
const route = useRoute();
const i18n = useI18n();
@ -47,9 +48,8 @@ const showSettings = computed(
projectsStore.currentProject?.type === ProjectTypes.Team,
);
const { menu, handleSelect } = useGlobalEntityCreation(
computed(() => !Boolean(projectsStore.currentProject)),
);
const { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage } =
useGlobalEntityCreation(computed(() => !Boolean(projectsStore.currentProject)));
const createLabel = computed(() => {
if (!projectsStore.currentProject) {
@ -82,17 +82,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</slot>
</N8nText>
</div>
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
<N8nNavigationDropdown
ref="createBtn"
data-test-id="resource-add"
:menu="menu"
@select="handleSelect"
>
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
<template #[createProjectAppendSlotName]="{ item }">
<N8nTooltip
v-if="item.disabled"
placement="right"
:content="projectsLimitReachedMessage"
>
<N8nButton
:size="'mini'"
style="margin-left: auto"
type="tertiary"
@click="handleSelect(item.id)"
>
{{ i18n.baseText('generic.upgrade') }}
</N8nButton>
</N8nTooltip>
</template>
</N8nNavigationDropdown>
</div>
</div>
<div :class="$style.actions">
<ProjectTabs :show-settings="showSettings" />
<N8nNavigationDropdown
ref="createBtn"
data-test-id="resource-add"
:menu="menu"
@select="handleSelect"
>
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
</N8nNavigationDropdown>
</div>
</div>
</template>
@ -106,6 +124,10 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
min-height: 64px;
}
.headerActions {
margin-left: auto;
}
.icon {
border: 1px solid var(--color-foreground-light);
padding: 6px;

View file

@ -7,6 +7,9 @@ import type router from 'vue-router';
import { flushPromises } from '@vue/test-utils';
import { useToast } from '@/composables/useToast';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import type { CloudPlanState } from '@/Interface';
import { VIEWS } from '@/constants';
import type { Project, ProjectListItem } from '@/types/projects.types';
@ -153,6 +156,7 @@ describe('useGlobalEntityCreation', () => {
describe('global', () => {
it('should show personal + all team projects', () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.teamProjectsLimit = -1;
const personalProjectId = 'personal-project';
projectsStore.isTeamProjectFeatureEnabled = true;
@ -222,4 +226,25 @@ describe('useGlobalEntityCreation', () => {
expect(redirect.goToUpgrade).toHaveBeenCalled();
});
});
it('should show plan and limit according to deployment type', () => {
const settingsStore = mockedStore(useSettingsStore);
const cloudPlanStore = mockedStore(useCloudPlanStore);
cloudPlanStore.currentPlanData = { displayName: 'Pro' } as CloudPlanState['data'];
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isTeamProjectFeatureEnabled = true;
projectsStore.teamProjectsLimit = 10;
settingsStore.isCloudDeployment = true;
const { projectsLimitReachedMessage } = useGlobalEntityCreation(true);
expect(projectsLimitReachedMessage.value).toContain(
'You have reached the Pro plan limit of 10.',
);
settingsStore.isCloudDeployment = false;
expect(projectsLimitReachedMessage.value).toContain(
'Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows',
);
});
});

View file

@ -5,6 +5,8 @@ import { useI18n } from '@/composables/useI18n';
import { sortByProperty } from '@/utils/sortUtils';
import { useToast } from '@/composables/useToast';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { getResourcePermissions } from '@/permissions';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
@ -28,6 +30,8 @@ export const useGlobalEntityCreation = (
) => {
const CREATE_PROJECT_ID = 'create-project';
const settingsStore = useSettingsStore();
const cloudPlanStore = useCloudPlanStore();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const router = useRouter();
@ -172,6 +176,7 @@ export const useGlobalEntityCreation = (
{
id: CREATE_PROJECT_ID,
title: 'Project',
disabled: !projectsStore.canCreateProjects,
},
];
});
@ -204,5 +209,21 @@ export const useGlobalEntityCreation = (
void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac');
};
return { menu, handleSelect };
const projectsLimitReachedMessage = computed(() => {
if (settingsStore.isCloudDeployment) {
return i18n.baseText('projects.create.limitReached', {
adjustToNumber: projectsStore.teamProjectsLimit,
interpolate: {
planName: cloudPlanStore.currentPlanData?.displayName ?? '',
limit: projectsStore.teamProjectsLimit,
},
});
}
return i18n.baseText('projects.create.limitReached.self');
});
const createProjectAppendSlotName = computed(() => `item.append.${CREATE_PROJECT_ID}`);
return { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage };
};

View file

@ -2563,6 +2563,7 @@
"projects.error.title": "Project error",
"projects.create.limit": "{num} project | {num} projects",
"projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
"projects.create.limitReached.self": "Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows",
"projects.create.limitReached.link": "View plans",
"projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to",
"projects.move.resource.modal.message": "\"{resourceName}\" is currently {inPersonalProject}{inTeamProject}",

View file

@ -35,7 +35,9 @@ const initialState = {
},
};
const renderComponent = createComponentRenderer(CredentialsView);
const renderComponent = createComponentRenderer(CredentialsView, {
global: { stubs: { ProjectHeader: true } },
});
let router: ReturnType<typeof useRouter>;
describe('CredentialsView', () => {