mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
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
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:
parent
b1f8663265
commit
956b11a560
|
@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => {
|
||||||
it('default slot should trigger first level', async () => {
|
it('default slot should trigger first level', async () => {
|
||||||
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
|
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
|
||||||
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
|
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: {
|
global: {
|
||||||
plugins: [router],
|
plugins: [router],
|
||||||
},
|
},
|
||||||
|
@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => {
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
id: 'aaa',
|
id: 'first',
|
||||||
title: 'aaa',
|
title: 'first',
|
||||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }],
|
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => {
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
id: 'aaa',
|
id: 'first',
|
||||||
title: 'aaa',
|
title: 'first',
|
||||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => {
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
id: 'aaa',
|
id: 'first',
|
||||||
title: 'aaa',
|
title: 'first',
|
||||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => {
|
||||||
await userEvent.click(getByTestId('navigation-submenu-item'));
|
await userEvent.click(getByTestId('navigation-submenu-item'));
|
||||||
|
|
||||||
expect(emitted('itemClick')).toStrictEqual([
|
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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -29,7 +29,7 @@ defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const menuRef = ref<typeof ElMenu | null>(null);
|
const menuRef = ref<typeof ElMenu | null>(null);
|
||||||
const menuIndex = ref('-1');
|
const ROOT_MENU_INDEX = '-1';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
itemClick: [item: MenuItemRegistered];
|
itemClick: [item: MenuItemRegistered];
|
||||||
|
@ -37,7 +37,18 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const close = () => {
|
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({
|
defineExpose({
|
||||||
|
@ -50,14 +61,16 @@ defineExpose({
|
||||||
ref="menuRef"
|
ref="menuRef"
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
unique-opened
|
unique-opened
|
||||||
menu-trigger="click"
|
:menu-trigger="menuTrigger"
|
||||||
:ellipsis="false"
|
:ellipsis="false"
|
||||||
:class="$style.dropdown"
|
:class="$style.dropdown"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@keyup.escape="close"
|
@keyup.escape="close"
|
||||||
|
@open="onOpen"
|
||||||
|
@close="onClose"
|
||||||
>
|
>
|
||||||
<ElSubMenu
|
<ElSubMenu
|
||||||
:index="menuIndex"
|
:index="ROOT_MENU_INDEX"
|
||||||
:class="$style.trigger"
|
:class="$style.trigger"
|
||||||
:popper-offset="-10"
|
:popper-offset="-10"
|
||||||
:popper-class="$style.submenu"
|
:popper-class="$style.submenu"
|
||||||
|
@ -70,10 +83,15 @@ defineExpose({
|
||||||
|
|
||||||
<template v-for="item in menu" :key="item.id">
|
<template v-for="item in menu" :key="item.id">
|
||||||
<template v-if="item.submenu">
|
<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 #title>{{ item.title }}</template>
|
||||||
<template v-for="subitem in item.submenu" :key="subitem.id">
|
<template v-for="subitem in item.submenu" :key="subitem.id">
|
||||||
<ConditionalRouterLink :to="!subitem.disabled && subitem.route">
|
<ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
data-test-id="navigation-submenu-item"
|
data-test-id="navigation-submenu-item"
|
||||||
:index="subitem.id"
|
:index="subitem.id"
|
||||||
|
@ -82,18 +100,20 @@ defineExpose({
|
||||||
>
|
>
|
||||||
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
|
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
|
||||||
{{ subitem.title }}
|
{{ subitem.title }}
|
||||||
|
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
|
||||||
</ElMenuItem>
|
</ElMenuItem>
|
||||||
</ConditionalRouterLink>
|
</ConditionalRouterLink>
|
||||||
</template>
|
</template>
|
||||||
</ElSubMenu>
|
</ElSubMenu>
|
||||||
</template>
|
</template>
|
||||||
<ConditionalRouterLink v-else :to="!item.disabled && item.route">
|
<ConditionalRouterLink v-else :to="(!item.disabled && item.route) || undefined">
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
:index="item.id"
|
:index="item.id"
|
||||||
:disabled="item.disabled"
|
:disabled="item.disabled"
|
||||||
data-test-id="navigation-menu-item"
|
data-test-id="navigation-menu-item"
|
||||||
>
|
>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
|
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
|
||||||
</ElMenuItem>
|
</ElMenuItem>
|
||||||
</ConditionalRouterLink>
|
</ConditionalRouterLink>
|
||||||
</template>
|
</template>
|
||||||
|
@ -125,17 +145,25 @@ defineExpose({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nestedSubmenu {
|
||||||
|
:global(.el-menu) {
|
||||||
|
max-height: 450px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.submenu {
|
.submenu {
|
||||||
padding: 5px 0 !important;
|
padding: 5px 0 !important;
|
||||||
|
|
||||||
:global(.el-menu--horizontal .el-menu .el-menu-item),
|
:global(.el-menu--horizontal .el-menu .el-menu-item),
|
||||||
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
|
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
|
||||||
color: var(--color-text-dark);
|
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-menu-item:not(.is-disabled):hover),
|
||||||
:global(.el-menu--horizontal .el-menu .el-sub-menu__title: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) {
|
:global(.el-popper) {
|
||||||
|
|
|
@ -462,6 +462,10 @@
|
||||||
--color-configurable-node-name: var(--color-text-dark);
|
--color-configurable-node-name: var(--color-text-dark);
|
||||||
--color-secondary-link: var(--prim-color-secondary-tint-200);
|
--color-secondary-link: var(--prim-color-secondary-tint-200);
|
||||||
--color-secondary-link-hover: var(--prim-color-secondary-tint-100);
|
--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'] {
|
body[data-theme='dark'] {
|
||||||
|
|
|
@ -533,6 +533,11 @@
|
||||||
--color-secondary-link: var(--color-secondary);
|
--color-secondary-link: var(--color-secondary);
|
||||||
--color-secondary-link-hover: var(--color-secondary-shade-1);
|
--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
|
// Generated Color Shades from 50 to 950
|
||||||
// Not yet used in design system
|
// Not yet used in design system
|
||||||
@each $color in ('neutral', 'success', 'warning', 'danger') {
|
@each $color in ('neutral', 'success', 'warning', 'danger') {
|
||||||
|
|
|
@ -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>, () => {
|
onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
createBtn.value?.close();
|
createBtn.value?.close();
|
||||||
});
|
});
|
||||||
|
@ -311,8 +316,8 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
:class="['clickable', $style.sideMenuCollapseButton]"
|
:class="['clickable', $style.sideMenuCollapseButton]"
|
||||||
@click="toggleCollapse"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<n8n-icon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
|
<N8nIcon 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-else icon="chevron-left" size="xsmall" class="mr-5xs" />
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.logo">
|
<div :class="$style.logo">
|
||||||
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
|
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
|
||||||
|
@ -323,9 +328,21 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
@select="handleMenuSelect"
|
@select="handleMenuSelect"
|
||||||
>
|
>
|
||||||
<N8nIconButton icon="plus" type="secondary" outline />
|
<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>
|
</N8nNavigationDropdown>
|
||||||
</div>
|
</div>
|
||||||
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
<N8nMenu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
||||||
<template #header>
|
<template #header>
|
||||||
<ProjectNavigation
|
<ProjectNavigation
|
||||||
:collapsed="isCollapsed"
|
:collapsed="isCollapsed"
|
||||||
|
@ -347,14 +364,14 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
<div :class="$style.giftContainer">
|
<div :class="$style.giftContainer">
|
||||||
<GiftNotificationIcon />
|
<GiftNotificationIcon />
|
||||||
</div>
|
</div>
|
||||||
<n8n-text
|
<N8nText
|
||||||
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
|
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
|
||||||
color="text-base"
|
color="text-base"
|
||||||
>
|
>
|
||||||
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
|
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
|
||||||
nextVersions.length > 1 ? 's' : ''
|
nextVersions.length > 1 ? 's' : ''
|
||||||
}}
|
}}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -363,35 +380,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
<div ref="user" :class="$style.userArea">
|
<div ref="user" :class="$style.userArea">
|
||||||
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
||||||
<!-- This dropdown is only enabled when sidebar is collapsed -->
|
<!-- 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 }">
|
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
|
||||||
<n8n-avatar
|
<N8nAvatar
|
||||||
:first-name="usersStore.currentUser?.firstName"
|
:first-name="usersStore.currentUser?.firstName"
|
||||||
:last-name="usersStore.currentUser?.lastName"
|
:last-name="usersStore.currentUser?.lastName"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="isCollapsed" #dropdown>
|
<template v-if="isCollapsed" #dropdown>
|
||||||
<el-dropdown-menu>
|
<ElDropdownMenu>
|
||||||
<el-dropdown-item command="settings">
|
<ElDropdownItem command="settings">
|
||||||
{{ i18n.baseText('settings') }}
|
{{ i18n.baseText('settings') }}
|
||||||
</el-dropdown-item>
|
</ElDropdownItem>
|
||||||
<el-dropdown-item command="logout">
|
<ElDropdownItem command="logout">
|
||||||
{{ i18n.baseText('auth.signout') }}
|
{{ i18n.baseText('auth.signout') }}
|
||||||
</el-dropdown-item>
|
</ElDropdownItem>
|
||||||
</el-dropdown-menu>
|
</ElDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</ElDropdown>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }"
|
: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
|
usersStore.currentUser?.fullName
|
||||||
}}</n8n-text>
|
}}</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
|
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
|
||||||
<n8n-action-dropdown
|
<N8nActionDropdown
|
||||||
:items="userMenuItems"
|
:items="userMenuItems"
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
data-test-id="user-menu"
|
data-test-id="user-menu"
|
||||||
|
@ -400,7 +417,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n8n-menu>
|
</N8nMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -3,11 +3,13 @@ import { within } from '@testing-library/dom';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { createTestProject } from '@/__tests__/data/projects';
|
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 ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { Project } from '@/types/projects.types';
|
import type { Project } from '@/types/projects.types';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
|
||||||
vi.mock('vue-router', async () => {
|
vi.mock('vue-router', async () => {
|
||||||
const actual = await vi.importActual('vue-router');
|
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>>;
|
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||||
|
|
||||||
describe('ProjectHeader', () => {
|
describe('ProjectHeader', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createTestingPinia();
|
createTestingPinia();
|
||||||
route = useRoute();
|
route = router.useRoute();
|
||||||
projectsStore = mockedStore(useProjectsStore);
|
projectsStore = mockedStore(useProjectsStore);
|
||||||
|
|
||||||
projectsStore.teamProjectsLimit = -1;
|
projectsStore.teamProjectsLimit = -1;
|
||||||
|
@ -159,4 +161,21 @@ describe('ProjectHeader', () => {
|
||||||
|
|
||||||
expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, type Ref, ref } from 'vue';
|
import { computed, type Ref, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
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 { onClickOutside, type VueInstance } from '@vueuse/core';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
@ -9,6 +9,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -47,9 +48,8 @@ const showSettings = computed(
|
||||||
projectsStore.currentProject?.type === ProjectTypes.Team,
|
projectsStore.currentProject?.type === ProjectTypes.Team,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { menu, handleSelect } = useGlobalEntityCreation(
|
const { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage } =
|
||||||
computed(() => !Boolean(projectsStore.currentProject)),
|
useGlobalEntityCreation(computed(() => !Boolean(projectsStore.currentProject)));
|
||||||
);
|
|
||||||
|
|
||||||
const createLabel = computed(() => {
|
const createLabel = computed(() => {
|
||||||
if (!projectsStore.currentProject) {
|
if (!projectsStore.currentProject) {
|
||||||
|
@ -82,17 +82,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
</slot>
|
</slot>
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</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>
|
||||||
<div :class="$style.actions">
|
<div :class="$style.actions">
|
||||||
<ProjectTabs :show-settings="showSettings" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -106,6 +124,10 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
border: 1px solid var(--color-foreground-light);
|
border: 1px solid var(--color-foreground-light);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|
|
@ -7,6 +7,9 @@ import type router from 'vue-router';
|
||||||
import { flushPromises } from '@vue/test-utils';
|
import { flushPromises } from '@vue/test-utils';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
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 { VIEWS } from '@/constants';
|
||||||
import type { Project, ProjectListItem } from '@/types/projects.types';
|
import type { Project, ProjectListItem } from '@/types/projects.types';
|
||||||
|
@ -153,6 +156,7 @@ describe('useGlobalEntityCreation', () => {
|
||||||
describe('global', () => {
|
describe('global', () => {
|
||||||
it('should show personal + all team projects', () => {
|
it('should show personal + all team projects', () => {
|
||||||
const projectsStore = mockedStore(useProjectsStore);
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.teamProjectsLimit = -1;
|
||||||
|
|
||||||
const personalProjectId = 'personal-project';
|
const personalProjectId = 'personal-project';
|
||||||
projectsStore.isTeamProjectFeatureEnabled = true;
|
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||||
|
@ -222,4 +226,25 @@ describe('useGlobalEntityCreation', () => {
|
||||||
expect(redirect.goToUpgrade).toHaveBeenCalled();
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { useI18n } from '@/composables/useI18n';
|
||||||
import { sortByProperty } from '@/utils/sortUtils';
|
import { sortByProperty } from '@/utils/sortUtils';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
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 { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
@ -28,6 +30,8 @@ export const useGlobalEntityCreation = (
|
||||||
) => {
|
) => {
|
||||||
const CREATE_PROJECT_ID = 'create-project';
|
const CREATE_PROJECT_ID = 'create-project';
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const cloudPlanStore = useCloudPlanStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -172,6 +176,7 @@ export const useGlobalEntityCreation = (
|
||||||
{
|
{
|
||||||
id: CREATE_PROJECT_ID,
|
id: CREATE_PROJECT_ID,
|
||||||
title: 'Project',
|
title: 'Project',
|
||||||
|
disabled: !projectsStore.canCreateProjects,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
@ -204,5 +209,21 @@ export const useGlobalEntityCreation = (
|
||||||
void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac');
|
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 };
|
||||||
};
|
};
|
||||||
|
|
|
@ -2563,6 +2563,7 @@
|
||||||
"projects.error.title": "Project error",
|
"projects.error.title": "Project error",
|
||||||
"projects.create.limit": "{num} project | {num} projects",
|
"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": "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.create.limitReached.link": "View plans",
|
||||||
"projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to",
|
"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}",
|
"projects.move.resource.modal.message": "\"{resourceName}\" is currently {inPersonalProject}{inTeamProject}",
|
||||||
|
|
|
@ -35,7 +35,9 @@ const initialState = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(CredentialsView);
|
const renderComponent = createComponentRenderer(CredentialsView, {
|
||||||
|
global: { stubs: { ProjectHeader: true } },
|
||||||
|
});
|
||||||
let router: ReturnType<typeof useRouter>;
|
let router: ReturnType<typeof useRouter>;
|
||||||
|
|
||||||
describe('CredentialsView', () => {
|
describe('CredentialsView', () => {
|
||||||
|
|
Loading…
Reference in a new issue