mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Add universal Create Resource Menu (#11564)
Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
This commit is contained in:
parent
1d8fd13d84
commit
b38ce14ec9
|
@ -6,8 +6,32 @@ const credentialsModal = new CredentialsModal();
|
||||||
|
|
||||||
export const getHomeButton = () => cy.getByTestId('project-home-menu-item');
|
export const getHomeButton = () => cy.getByTestId('project-home-menu-item');
|
||||||
export const getMenuItems = () => cy.getByTestId('project-menu-item');
|
export const getMenuItems = () => cy.getByTestId('project-menu-item');
|
||||||
export const getAddProjectButton = () =>
|
export const getAddProjectButton = () => {
|
||||||
cy.getByTestId('add-project-menu-item').should('contain', 'Add project').should('be.visible');
|
cy.getByTestId('universal-add').should('be.visible').click();
|
||||||
|
cy.getByTestId('universal-add')
|
||||||
|
.find('.el-sub-menu__title')
|
||||||
|
.as('menuitem')
|
||||||
|
.should('have.attr', 'aria-describedby');
|
||||||
|
|
||||||
|
cy.get('@menuitem')
|
||||||
|
.invoke('attr', 'aria-describedby')
|
||||||
|
.then((el) => cy.get(`[id="${el}"]`))
|
||||||
|
.as('submenu');
|
||||||
|
|
||||||
|
cy.get('@submenu').within((submenu) =>
|
||||||
|
cy
|
||||||
|
.wrap(submenu)
|
||||||
|
.getByTestId('navigation-menu-item')
|
||||||
|
.should('be.visible')
|
||||||
|
.filter(':contains("Project")')
|
||||||
|
.as('button'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return cy.get('@button');
|
||||||
|
};
|
||||||
|
|
||||||
|
// export const getAddProjectButton = () =>
|
||||||
|
// cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible');
|
||||||
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
|
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
|
||||||
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
|
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
|
||||||
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
|
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
|
||||||
|
|
|
@ -5,7 +5,49 @@ export class CredentialsPage extends BasePage {
|
||||||
|
|
||||||
getters = {
|
getters = {
|
||||||
emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'),
|
emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'),
|
||||||
createCredentialButton: () => cy.getByTestId('resources-list-add'),
|
createCredentialButton: () => {
|
||||||
|
cy.getByTestId('resource-add').should('be.visible').click();
|
||||||
|
cy.getByTestId('resource-add')
|
||||||
|
.find('.el-sub-menu__title')
|
||||||
|
.as('menuitem')
|
||||||
|
.should('have.attr', 'aria-describedby');
|
||||||
|
|
||||||
|
cy.get('@menuitem')
|
||||||
|
.should('be.visible')
|
||||||
|
.invoke('attr', 'aria-describedby')
|
||||||
|
.then((el) => cy.get(`[id="${el}"]`))
|
||||||
|
.as('submenu');
|
||||||
|
|
||||||
|
cy.get('@submenu')
|
||||||
|
.should('be.visible')
|
||||||
|
.within((submenu) => {
|
||||||
|
// If submenu has another submenu
|
||||||
|
if (submenu.find('[data-test-id="navigation-submenu"]').length) {
|
||||||
|
cy.wrap(submenu)
|
||||||
|
.find('[data-test-id="navigation-submenu"]')
|
||||||
|
.should('be.visible')
|
||||||
|
.filter(':contains("Credential")')
|
||||||
|
.as('child')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('@child')
|
||||||
|
.should('be.visible')
|
||||||
|
.find('[data-test-id="navigation-submenu-item"]')
|
||||||
|
.should('be.visible')
|
||||||
|
.filter(':contains("Personal")')
|
||||||
|
.as('button');
|
||||||
|
} else {
|
||||||
|
cy.wrap(submenu)
|
||||||
|
.find('[data-test-id="navigation-menu-item"]')
|
||||||
|
.filter(':contains("Credential")')
|
||||||
|
.as('button');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cy.get('@button').should('be.visible');
|
||||||
|
},
|
||||||
|
|
||||||
|
// cy.getByTestId('resources-list-add'),
|
||||||
searchInput: () => cy.getByTestId('resources-list-search'),
|
searchInput: () => cy.getByTestId('resources-list-search'),
|
||||||
emptyList: () => cy.getByTestId('resources-list-empty'),
|
emptyList: () => cy.getByTestId('resources-list-empty'),
|
||||||
credentialCards: () => cy.getByTestId('resources-list-item'),
|
credentialCards: () => cy.getByTestId('resources-list-item'),
|
||||||
|
|
|
@ -7,7 +7,47 @@ export class WorkflowsPage extends BasePage {
|
||||||
newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'),
|
newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'),
|
||||||
newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'),
|
newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'),
|
||||||
searchBar: () => cy.getByTestId('resources-list-search'),
|
searchBar: () => cy.getByTestId('resources-list-search'),
|
||||||
createWorkflowButton: () => cy.getByTestId('resources-list-add'),
|
createWorkflowButton: () => {
|
||||||
|
cy.getByTestId('resource-add').should('be.visible').click();
|
||||||
|
cy.getByTestId('resource-add')
|
||||||
|
.find('.el-sub-menu__title')
|
||||||
|
.as('menuitem')
|
||||||
|
.should('have.attr', 'aria-describedby');
|
||||||
|
|
||||||
|
cy.get('@menuitem')
|
||||||
|
.should('be.visible')
|
||||||
|
.invoke('attr', 'aria-describedby')
|
||||||
|
.then((el) => cy.get(`[id="${el}"]`))
|
||||||
|
.as('submenu');
|
||||||
|
|
||||||
|
cy.get('@submenu')
|
||||||
|
.should('be.visible')
|
||||||
|
.within((submenu) => {
|
||||||
|
// If submenu has another submenu
|
||||||
|
if (submenu.find('[data-test-id="navigation-submenu"]').length) {
|
||||||
|
cy.wrap(submenu)
|
||||||
|
.find('[data-test-id="navigation-submenu"]')
|
||||||
|
.should('be.visible')
|
||||||
|
.filter(':contains("Workflow")')
|
||||||
|
.as('child')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
cy.get('@child')
|
||||||
|
.should('be.visible')
|
||||||
|
.find('[data-test-id="navigation-submenu-item"]')
|
||||||
|
.should('be.visible')
|
||||||
|
.filter(':contains("Personal")')
|
||||||
|
.as('button');
|
||||||
|
} else {
|
||||||
|
cy.wrap(submenu)
|
||||||
|
.find('[data-test-id="navigation-menu-item"]')
|
||||||
|
.filter(':contains("Workflow")')
|
||||||
|
.as('button');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cy.get('@button').should('be.visible');
|
||||||
|
},
|
||||||
workflowCards: () => cy.getByTestId('resources-list-item'),
|
workflowCards: () => cy.getByTestId('resources-list-item'),
|
||||||
workflowCard: (workflowName: string) =>
|
workflowCard: (workflowName: string) =>
|
||||||
this.getters
|
this.getters
|
||||||
|
|
|
@ -537,13 +537,11 @@ code[class^='language-'] {
|
||||||
}
|
}
|
||||||
|
|
||||||
.inputWrapper {
|
.inputWrapper {
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
bottom: 0;
|
|
||||||
background-color: var(--color-foreground-xlight);
|
background-color: var(--color-foreground-xlight);
|
||||||
border: var(--border-base);
|
border: var(--border-base);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-top: 1px;
|
border-top: 0;
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
border: none;
|
border: none;
|
||||||
|
|
|
@ -132,6 +132,7 @@ const onSelect = (item: IMenuItem): void => {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: var(--menu-background, var(--color-background-xlight));
|
background-color: var(--menu-background, var(--color-background-xlight));
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menuHeader {
|
.menuHeader {
|
||||||
|
|
|
@ -29,9 +29,10 @@ describe('N8nNavigationDropdown', () => {
|
||||||
|
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
});
|
});
|
||||||
|
|
||||||
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: 'aaa', title: 'aaa', route: { name: 'projects' } }] },
|
||||||
global: {
|
global: {
|
||||||
plugins: [router],
|
plugins: [router],
|
||||||
|
@ -40,13 +41,13 @@ describe('N8nNavigationDropdown', () => {
|
||||||
expect(getByTestId('test-trigger')).toBeVisible();
|
expect(getByTestId('test-trigger')).toBeVisible();
|
||||||
expect(queryByTestId('navigation-menu-item')).not.toBeVisible();
|
expect(queryByTestId('navigation-menu-item')).not.toBeVisible();
|
||||||
|
|
||||||
await userEvent.hover(getByTestId('test-trigger'));
|
await userEvent.click(getByTestId('test-trigger'));
|
||||||
await waitFor(() => expect(queryByTestId('navigation-menu-item')).toBeVisible());
|
await waitFor(() => expect(queryByTestId('navigation-menu-item')).toBeVisible());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirect to route', async () => {
|
it('redirect to route', 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: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
|
@ -64,7 +65,7 @@ describe('N8nNavigationDropdown', () => {
|
||||||
expect(getByTestId('test-trigger')).toBeVisible();
|
expect(getByTestId('test-trigger')).toBeVisible();
|
||||||
expect(queryByTestId('navigation-submenu')).not.toBeVisible();
|
expect(queryByTestId('navigation-submenu')).not.toBeVisible();
|
||||||
|
|
||||||
await userEvent.hover(getByTestId('test-trigger'));
|
await userEvent.click(getByTestId('test-trigger'));
|
||||||
|
|
||||||
await waitFor(() => expect(getByTestId('navigation-submenu')).toBeVisible());
|
await waitFor(() => expect(getByTestId('navigation-submenu')).toBeVisible());
|
||||||
|
|
||||||
|
@ -75,7 +76,7 @@ describe('N8nNavigationDropdown', () => {
|
||||||
|
|
||||||
it('should render icons in submenu when provided', () => {
|
it('should render icons in submenu when provided', () => {
|
||||||
const { getByTestId } = render(NavigationDropdown, {
|
const { getByTestId } = render(NavigationDropdown, {
|
||||||
slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) },
|
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
|
@ -95,7 +96,7 @@ describe('N8nNavigationDropdown', () => {
|
||||||
|
|
||||||
it('should propagate events', async () => {
|
it('should propagate events', async () => {
|
||||||
const { getByTestId, emitted } = render(NavigationDropdown, {
|
const { getByTestId, emitted } = render(NavigationDropdown, {
|
||||||
slots: { default: h('button', { ['data-test-id']: 'test-trigger' }) },
|
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMenu, ElSubMenu, ElMenuItem, type MenuItemRegistered } from 'element-plus';
|
import { ElMenu, ElSubMenu, ElMenuItem, type MenuItemRegistered } from 'element-plus';
|
||||||
|
import { ref, defineProps, defineEmits, defineOptions } from 'vue';
|
||||||
import type { RouteLocationRaw } from 'vue-router';
|
import type { RouteLocationRaw } from 'vue-router';
|
||||||
|
|
||||||
import ConditionalRouterLink from '../ConditionalRouterLink';
|
import ConditionalRouterLink from '../ConditionalRouterLink';
|
||||||
|
@ -17,28 +18,51 @@ type Item = BaseItem & {
|
||||||
submenu?: BaseItem[];
|
submenu?: BaseItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'N8nNavigationDropdown',
|
||||||
|
});
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
menu: Item[];
|
menu: Item[];
|
||||||
|
disabled?: boolean;
|
||||||
|
teleport?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const menuRef = ref<typeof ElMenu | null>(null);
|
||||||
|
const menuIndex = ref('-1');
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
itemClick: [item: MenuItemRegistered];
|
itemClick: [item: MenuItemRegistered];
|
||||||
select: [id: Item['id']];
|
select: [id: Item['id']];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
menuRef.value?.close(menuIndex.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
close,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ElMenu
|
<ElMenu
|
||||||
|
ref="menuRef"
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
|
unique-opened
|
||||||
|
menu-trigger="click"
|
||||||
:ellipsis="false"
|
:ellipsis="false"
|
||||||
:class="$style.dropdown"
|
:class="$style.dropdown"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
|
@keyup.escape="close"
|
||||||
>
|
>
|
||||||
<ElSubMenu
|
<ElSubMenu
|
||||||
index="-1"
|
:index="menuIndex"
|
||||||
:class="$style.trigger"
|
:class="$style.trigger"
|
||||||
:popper-offset="-10"
|
:popper-offset="-10"
|
||||||
:popper-class="$style.submenu"
|
:popper-class="$style.submenu"
|
||||||
|
:disabled
|
||||||
|
:teleported="teleport"
|
||||||
>
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -49,7 +73,7 @@ const emit = defineEmits<{
|
||||||
<ElSubMenu :index="item.id" :popper-offset="-10" data-test-id="navigation-submenu">
|
<ElSubMenu :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.route">
|
<ConditionalRouterLink :to="!subitem.disabled && subitem.route">
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
data-test-id="navigation-submenu-item"
|
data-test-id="navigation-submenu-item"
|
||||||
:index="subitem.id"
|
:index="subitem.id"
|
||||||
|
@ -63,7 +87,7 @@ const emit = defineEmits<{
|
||||||
</template>
|
</template>
|
||||||
</ElSubMenu>
|
</ElSubMenu>
|
||||||
</template>
|
</template>
|
||||||
<ConditionalRouterLink v-else :to="item.route">
|
<ConditionalRouterLink v-else :to="!item.disabled && item.route">
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
:index="item.id"
|
:index="item.id"
|
||||||
:disabled="item.disabled"
|
:disabled="item.disabled"
|
||||||
|
@ -81,24 +105,29 @@ const emit = defineEmits<{
|
||||||
:global(.el-menu).dropdown {
|
:global(.el-menu).dropdown {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
|
||||||
|
|
||||||
:global(.el-sub-menu).trigger {
|
> :global(.el-sub-menu) {
|
||||||
:global(.el-sub-menu__title) {
|
> :global(.el-sub-menu__title) {
|
||||||
height: auto;
|
height: auto;
|
||||||
line-height: initial;
|
line-height: initial;
|
||||||
border-bottom: 0 !important;
|
border-bottom: 0 !important;
|
||||||
|
padding: 0;
|
||||||
:global(.el-sub-menu__icon-arrow) {
|
:global(.el-sub-menu__icon-arrow) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.el-sub-menu__title:hover) {
|
&:global(.is-active) {
|
||||||
background-color: transparent;
|
:global(.el-sub-menu__title) {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenu {
|
.submenu {
|
||||||
|
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);
|
||||||
|
@ -109,6 +138,15 @@ const emit = defineEmits<{
|
||||||
background-color: var(--color-foreground-base);
|
background-color: var(--color-foreground-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.el-popper) {
|
||||||
|
padding: 0 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.el-menu--popup) {
|
||||||
|
border: 1px solid var(--color-foreground-base);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
:global(.el-menu--horizontal .el-menu .el-menu-item.is-disabled) {
|
:global(.el-menu--horizontal .el-menu .el-menu-item.is-disabled) {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
|
@ -190,7 +190,6 @@ watch(defaultLocale, (newLocale) => {
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
grid-area: sidebar;
|
grid-area: sidebar;
|
||||||
height: 100%;
|
|
||||||
z-index: var(--z-index-app-sidebar);
|
z-index: var(--z-index-app-sidebar);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -75,7 +75,6 @@ function onClose() {
|
||||||
v-show="assistantStore.isAssistantOpen"
|
v-show="assistantStore.isAssistantOpen"
|
||||||
:supported-directions="['left']"
|
:supported-directions="['left']"
|
||||||
:width="assistantStore.chatWidth"
|
:width="assistantStore.chatWidth"
|
||||||
:class="$style.container"
|
|
||||||
data-test-id="ask-assistant-sidebar"
|
data-test-id="ask-assistant-sidebar"
|
||||||
@resize="onResizeDebounced"
|
@resize="onResizeDebounced"
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, nextTick } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref, nextTick, type Ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { useBecomeTemplateCreatorStore } from '@/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore';
|
import { useBecomeTemplateCreatorStore } from '@/components/BecomeTemplateCreatorCta/becomeTemplateCreatorStore';
|
||||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
@ -23,6 +22,10 @@ import { ABOUT_MODAL_KEY, VERSIONS_MODAL_KEY, VIEWS } from '@/constants';
|
||||||
import { useBugReporting } from '@/composables/useBugReporting';
|
import { useBugReporting } from '@/composables/useBugReporting';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
|
||||||
|
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||||
|
import { N8nNavigationDropdown } from 'n8n-design-system';
|
||||||
|
import { onClickOutside, type VueInstance } from '@vueuse/core';
|
||||||
|
|
||||||
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
|
const becomeTemplateCreatorStore = useBecomeTemplateCreatorStore();
|
||||||
const cloudPlanStore = useCloudPlanStore();
|
const cloudPlanStore = useCloudPlanStore();
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
@ -158,6 +161,7 @@ const mainMenuItems = computed(() => [
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
const createBtn = ref<InstanceType<typeof N8nNavigationDropdown>>();
|
||||||
|
|
||||||
const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed);
|
const isCollapsed = computed(() => uiStore.sidebarMenuCollapsed);
|
||||||
|
|
||||||
|
@ -286,6 +290,11 @@ const checkWidthAndAdjustSidebar = async (width: number) => {
|
||||||
fullyExpanded.value = !isCollapsed.value;
|
fullyExpanded.value = !isCollapsed.value;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { menu, handleSelect: handleMenuSelect } = useGlobalEntityCreation();
|
||||||
|
onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
|
createBtn.value?.close();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -305,11 +314,19 @@ const checkWidthAndAdjustSidebar = async (width: number) => {
|
||||||
<n8n-icon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
|
<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" />
|
<n8n-icon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
|
||||||
</div>
|
</div>
|
||||||
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
|
||||||
<template #header>
|
|
||||||
<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" />
|
||||||
|
<N8nNavigationDropdown
|
||||||
|
ref="createBtn"
|
||||||
|
data-test-id="universal-add"
|
||||||
|
:menu="menu"
|
||||||
|
@select="handleMenuSelect"
|
||||||
|
>
|
||||||
|
<N8nIconButton icon="plus" type="secondary" outline />
|
||||||
|
</N8nNavigationDropdown>
|
||||||
</div>
|
</div>
|
||||||
|
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
||||||
|
<template #header>
|
||||||
<ProjectNavigation
|
<ProjectNavigation
|
||||||
:collapsed="isCollapsed"
|
:collapsed="isCollapsed"
|
||||||
:plan-name="cloudPlanStore.currentPlanData?.displayName"
|
:plan-name="cloudPlanStore.currentPlanData?.displayName"
|
||||||
|
@ -394,11 +411,18 @@ const checkWidthAndAdjustSidebar = async (width: number) => {
|
||||||
border-right: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
border-right: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
|
||||||
transition: width 150ms ease-in-out;
|
transition: width 150ms ease-in-out;
|
||||||
width: $sidebar-expanded-width;
|
width: $sidebar-expanded-width;
|
||||||
|
padding-top: 54px;
|
||||||
|
background-color: var(--menu-background, var(--color-background-xlight));
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
height: $header-height;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--spacing-xs);
|
padding: var(--spacing-xs);
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -409,6 +433,12 @@ const checkWidthAndAdjustSidebar = async (width: number) => {
|
||||||
|
|
||||||
&.sideMenuCollapsed {
|
&.sideMenuCollapsed {
|
||||||
width: $sidebar-width;
|
width: $sidebar-width;
|
||||||
|
padding-top: 90px;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.logo img {
|
.logo img {
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
|
@ -29,6 +29,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
ProjectTabs: projectTabsSpy,
|
ProjectTabs: projectTabsSpy,
|
||||||
|
N8nNavigationDropdown: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -41,6 +42,8 @@ describe('ProjectHeader', () => {
|
||||||
createTestingPinia();
|
createTestingPinia();
|
||||||
route = useRoute();
|
route = useRoute();
|
||||||
projectsStore = mockedStore(useProjectsStore);
|
projectsStore = mockedStore(useProjectsStore);
|
||||||
|
|
||||||
|
projectsStore.teamProjectsLimit = -1;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
@ -1,16 +1,21 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } 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 { 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';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
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';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
|
const createBtn = ref<InstanceType<typeof N8nNavigationDropdown>>();
|
||||||
|
|
||||||
const headerIcon = computed(() => {
|
const headerIcon = computed(() => {
|
||||||
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
||||||
return 'user';
|
return 'user';
|
||||||
|
@ -41,6 +46,24 @@ const showSettings = computed(
|
||||||
!!projectPermissions.value.update &&
|
!!projectPermissions.value.update &&
|
||||||
projectsStore.currentProject?.type === ProjectTypes.Team,
|
projectsStore.currentProject?.type === ProjectTypes.Team,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { menu, handleSelect } = useGlobalEntityCreation(
|
||||||
|
computed(() => !Boolean(projectsStore.currentProject)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createLabel = computed(() => {
|
||||||
|
if (!projectsStore.currentProject) {
|
||||||
|
return 'Create';
|
||||||
|
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
|
||||||
|
return 'Create personal';
|
||||||
|
} else {
|
||||||
|
return 'Create in project';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
|
createBtn.value?.close();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -55,11 +78,18 @@ const showSettings = computed(
|
||||||
<slot name="subtitle" />
|
<slot name="subtitle" />
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$slots.actions" :class="[$style.actions]">
|
|
||||||
<slot name="actions"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -79,6 +109,9 @@ const showSettings = computed(
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
margin-left: auto;
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: var(--spacing-2xs) 0 var(--spacing-l);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,15 +1,10 @@
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import userEvent from '@testing-library/user-event';
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||||
import { createRouter, createMemoryHistory, useRouter } from 'vue-router';
|
|
||||||
import { createProjectListItem } from '@/__tests__/data/projects';
|
import { createProjectListItem } from '@/__tests__/data/projects';
|
||||||
import ProjectsNavigation from '@/components/Projects//ProjectNavigation.vue';
|
import ProjectsNavigation from '@/components/Projects//ProjectNavigation.vue';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import type { Project } from '@/types/projects.types';
|
|
||||||
import { VIEWS } from '@/constants';
|
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
|
||||||
|
|
||||||
vi.mock('vue-router', async () => {
|
vi.mock('vue-router', async () => {
|
||||||
const actual = await vi.importActual('vue-router');
|
const actual = await vi.importActual('vue-router');
|
||||||
|
@ -62,10 +57,7 @@ const renderComponent = createComponentRenderer(ProjectsNavigation, {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let router: ReturnType<typeof useRouter>;
|
|
||||||
let toast: ReturnType<typeof useToast>;
|
|
||||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||||
let pageRedirectionHelper: ReturnType<typeof usePageRedirectionHelper>;
|
|
||||||
|
|
||||||
const personalProjects = Array.from({ length: 3 }, createProjectListItem);
|
const personalProjects = Array.from({ length: 3 }, createProjectListItem);
|
||||||
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
|
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
|
||||||
|
@ -74,10 +66,6 @@ describe('ProjectsNavigation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createTestingPinia();
|
createTestingPinia();
|
||||||
|
|
||||||
router = useRouter();
|
|
||||||
toast = useToast();
|
|
||||||
pageRedirectionHelper = usePageRedirectionHelper();
|
|
||||||
|
|
||||||
projectsStore = mockedStore(useProjectsStore);
|
projectsStore = mockedStore(useProjectsStore);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -92,70 +80,6 @@ describe('ProjectsNavigation', () => {
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show "Add project" button when conditions are not met', async () => {
|
|
||||||
projectsStore.teamProjectsLimit = 0;
|
|
||||||
projectsStore.hasPermissionToCreateProjects = false;
|
|
||||||
|
|
||||||
const { queryByText } = renderComponent({
|
|
||||||
props: {
|
|
||||||
collapsed: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(queryByText('Add project')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show "Add project" button when conditions met', async () => {
|
|
||||||
projectsStore.teamProjectsLimit = -1;
|
|
||||||
projectsStore.hasPermissionToCreateProjects = true;
|
|
||||||
projectsStore.createProject.mockResolvedValue({
|
|
||||||
id: '1',
|
|
||||||
name: 'My project 1',
|
|
||||||
} as Project);
|
|
||||||
|
|
||||||
const { getByText } = renderComponent({
|
|
||||||
props: {
|
|
||||||
collapsed: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getByText('Add project')).toBeVisible();
|
|
||||||
await userEvent.click(getByText('Add project'));
|
|
||||||
|
|
||||||
expect(projectsStore.createProject).toHaveBeenCalledWith({
|
|
||||||
name: 'My project',
|
|
||||||
});
|
|
||||||
expect(router.push).toHaveBeenCalledWith({
|
|
||||||
name: VIEWS.PROJECT_SETTINGS,
|
|
||||||
params: { projectId: '1' },
|
|
||||||
});
|
|
||||||
expect(toast.showMessage).toHaveBeenCalledWith({
|
|
||||||
title: 'Project My project 1 saved successfully',
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show "Add project" button tooltip when project creation limit reached', async () => {
|
|
||||||
projectsStore.teamProjectsLimit = 3;
|
|
||||||
projectsStore.hasPermissionToCreateProjects = true;
|
|
||||||
projectsStore.canCreateProjects = false;
|
|
||||||
|
|
||||||
const { getByText } = renderComponent({
|
|
||||||
props: {
|
|
||||||
collapsed: false,
|
|
||||||
planName: 'Free',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(getByText('Add project')).toBeVisible();
|
|
||||||
await userEvent.hover(getByText('Add project'));
|
|
||||||
|
|
||||||
expect(getByText(/You have reached the Free plan limit of 3/)).toBeVisible();
|
|
||||||
await userEvent.click(getByText('View plans'));
|
|
||||||
|
|
||||||
expect(pageRedirectionHelper.goToUpgrade).toHaveBeenCalledWith('rbac', 'upgrade-rbac');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show "Projects" title and Personal project when the feature is enabled', async () => {
|
it('should show "Projects" title and Personal project when the feature is enabled', async () => {
|
||||||
projectsStore.isTeamProjectFeatureEnabled = true;
|
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||||
projectsStore.myProjects = [...personalProjects, ...teamProjects];
|
projectsStore.myProjects = [...personalProjects, ...teamProjects];
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import type { IMenuItem } from 'n8n-design-system/types';
|
import type { IMenuItem } from 'n8n-design-system/types';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { ProjectListItem } from '@/types/projects.types';
|
import type { ProjectListItem } from '@/types/projects.types';
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
import { sortByProperty } from '@/utils/sortUtils';
|
import { sortByProperty } from '@/utils/sortUtils';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
@ -17,14 +14,9 @@ type Props = {
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const toast = useToast();
|
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
|
||||||
|
|
||||||
const isCreatingProject = ref(false);
|
|
||||||
const isComponentMounted = ref(false);
|
|
||||||
const home = computed<IMenuItem>(() => ({
|
const home = computed<IMenuItem>(() => ({
|
||||||
id: 'home',
|
id: 'home',
|
||||||
label: locale.baseText('projects.menu.overview'),
|
label: locale.baseText('projects.menu.overview'),
|
||||||
|
@ -33,14 +25,6 @@ const home = computed<IMenuItem>(() => ({
|
||||||
to: { name: VIEWS.HOMEPAGE },
|
to: { name: VIEWS.HOMEPAGE },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const addProject = computed<IMenuItem>(() => ({
|
|
||||||
id: 'addProject',
|
|
||||||
label: locale.baseText('projects.menu.addProject'),
|
|
||||||
icon: 'plus',
|
|
||||||
disabled:
|
|
||||||
!isComponentMounted.value || isCreatingProject.value || !projectsStore.canCreateProjects,
|
|
||||||
isLoading: isCreatingProject.value,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getProjectMenuItem = (project: ProjectListItem) => ({
|
const getProjectMenuItem = (project: ProjectListItem) => ({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
|
@ -66,46 +50,12 @@ const personalProject = computed<IMenuItem>(() => ({
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const addProjectClicked = async () => {
|
|
||||||
isCreatingProject.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newProject = await projectsStore.createProject({
|
|
||||||
name: locale.baseText('projects.settings.newProjectName'),
|
|
||||||
});
|
|
||||||
await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } });
|
|
||||||
toast.showMessage({
|
|
||||||
title: locale.baseText('projects.settings.save.successful.title', {
|
|
||||||
interpolate: { projectName: newProject.name ?? '' },
|
|
||||||
}),
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast.showError(error, locale.baseText('projects.error.title'));
|
|
||||||
} finally {
|
|
||||||
isCreatingProject.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayProjects = computed(() =>
|
const displayProjects = computed(() =>
|
||||||
sortByProperty(
|
sortByProperty(
|
||||||
'name',
|
'name',
|
||||||
projectsStore.myProjects.filter((p) => p.type === 'team'),
|
projectsStore.myProjects.filter((p) => p.type === 'team'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const canCreateProjects = computed(
|
|
||||||
() => projectsStore.hasPermissionToCreateProjects && projectsStore.isTeamProjectFeatureEnabled,
|
|
||||||
);
|
|
||||||
|
|
||||||
const goToUpgrade = async () => {
|
|
||||||
await pageRedirectionHelper.goToUpgrade('rbac', 'upgrade-rbac');
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick();
|
|
||||||
isComponentMounted.value = true;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -153,39 +103,6 @@ onMounted(async () => {
|
||||||
data-test-id="project-menu-item"
|
data-test-id="project-menu-item"
|
||||||
/>
|
/>
|
||||||
</ElMenu>
|
</ElMenu>
|
||||||
<N8nTooltip
|
|
||||||
v-if="canCreateProjects"
|
|
||||||
placement="right"
|
|
||||||
:disabled="projectsStore.canCreateProjects"
|
|
||||||
>
|
|
||||||
<ElMenu :collapse="props.collapsed" class="pl-xs pr-xs mb-m">
|
|
||||||
<N8nMenuItem
|
|
||||||
:item="addProject"
|
|
||||||
:compact="props.collapsed"
|
|
||||||
:handle-select="addProjectClicked"
|
|
||||||
mode="tabs"
|
|
||||||
data-test-id="add-project-menu-item"
|
|
||||||
/>
|
|
||||||
</ElMenu>
|
|
||||||
<template #content>
|
|
||||||
<i18n-t keypath="projects.create.limitReached">
|
|
||||||
<template #planName>{{ props.planName }}</template>
|
|
||||||
<template #limit>
|
|
||||||
{{
|
|
||||||
locale.baseText('projects.create.limit', {
|
|
||||||
adjustToNumber: projectsStore.teamProjectsLimit,
|
|
||||||
interpolate: { num: String(projectsStore.teamProjectsLimit) },
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</template>
|
|
||||||
<template #link>
|
|
||||||
<a :class="$style.upgradeLink" href="#" @click="goToUpgrade">
|
|
||||||
{{ locale.baseText('projects.create.limitReached.link') }}
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</template>
|
|
||||||
</N8nTooltip>
|
|
||||||
<hr v-if="projectsStore.isTeamProjectFeatureEnabled" class="mb-m" />
|
<hr v-if="projectsStore.isTeamProjectFeatureEnabled" class="mb-m" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -79,13 +79,5 @@ watch(
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.projectTabs">
|
|
||||||
<N8nTabs v-model="selectedTab" :options="options" data-test-id="project-tabs" />
|
<N8nTabs v-model="selectedTab" :options="options" data-test-id="project-tabs" />
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
|
||||||
.projectTabs {
|
|
||||||
padding: var(--spacing-2xs) 0 var(--spacing-l);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -4,6 +4,12 @@ import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import type router from 'vue-router';
|
import type router from 'vue-router';
|
||||||
|
|
||||||
|
vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
||||||
|
useGlobalEntityCreation: () => ({
|
||||||
|
menu: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('vue-router', async (importOriginal) => {
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
const { RouterLink } = await importOriginal<typeof router>();
|
const { RouterLink } = await importOriginal<typeof router>();
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -83,7 +83,7 @@ defineSlots<{
|
||||||
empty(): unknown;
|
empty(): unknown;
|
||||||
preamble(): unknown;
|
preamble(): unknown;
|
||||||
postamble(): unknown;
|
postamble(): unknown;
|
||||||
'add-button'(props: { disabled: boolean }): unknown;
|
'add-button'(): unknown;
|
||||||
callout(): unknown;
|
callout(): unknown;
|
||||||
filters(props: {
|
filters(props: {
|
||||||
filters: Record<string, boolean | string | string[]>;
|
filters: Record<string, boolean | string | string[]>;
|
||||||
|
@ -407,16 +407,7 @@ onMounted(async () => {
|
||||||
</n8n-select>
|
</n8n-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="add-button" :disabled="disabled">
|
<slot name="add-button"></slot>
|
||||||
<n8n-button
|
|
||||||
size="large"
|
|
||||||
:disabled="disabled"
|
|
||||||
data-test-id="resources-list-add"
|
|
||||||
@click="onAddButtonClick"
|
|
||||||
>
|
|
||||||
{{ i18n.baseText(`${resourceKey}.add` as BaseTextKey) }}
|
|
||||||
</n8n-button>
|
|
||||||
</slot>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<slot name="callout"></slot>
|
<slot name="callout"></slot>
|
||||||
|
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import type router from 'vue-router';
|
||||||
|
import { flushPromises } from '@vue/test-utils';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import type { Project, ProjectListItem } from '@/types/projects.types';
|
||||||
|
|
||||||
|
import { useGlobalEntityCreation } from './useGlobalEntityCreation';
|
||||||
|
|
||||||
|
vi.mock('@/composables/usePageRedirectionHelper', () => {
|
||||||
|
const goToUpgrade = vi.fn();
|
||||||
|
return {
|
||||||
|
usePageRedirectionHelper: () => ({
|
||||||
|
goToUpgrade,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => {
|
||||||
|
const showMessage = vi.fn();
|
||||||
|
const showError = vi.fn();
|
||||||
|
return {
|
||||||
|
useToast: () => {
|
||||||
|
return {
|
||||||
|
showMessage,
|
||||||
|
showError,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const routerPushMock = vi.fn();
|
||||||
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const { RouterLink, useRoute } = await importOriginal<typeof router>();
|
||||||
|
return {
|
||||||
|
RouterLink,
|
||||||
|
useRoute,
|
||||||
|
useRouter: () => ({
|
||||||
|
push: routerPushMock,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia());
|
||||||
|
routerPushMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useGlobalEntityCreation', () => {
|
||||||
|
it('should not contain projects for community', () => {
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
|
||||||
|
const personalProjectId = 'personal-project';
|
||||||
|
projectsStore.canCreateProjects = false;
|
||||||
|
projectsStore.personalProject = { id: personalProjectId } as Project;
|
||||||
|
const { menu } = useGlobalEntityCreation();
|
||||||
|
|
||||||
|
expect(menu.value[0]).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
route: { name: VIEWS.NEW_WORKFLOW, query: { projectId: personalProjectId } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(menu.value[1]).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
route: {
|
||||||
|
name: VIEWS.CREDENTIALS,
|
||||||
|
params: { projectId: personalProjectId, credentialId: 'create' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('single project', () => {
|
||||||
|
const currentProjectId = 'current-project';
|
||||||
|
|
||||||
|
it('should use currentProject', () => {
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
|
||||||
|
projectsStore.canCreateProjects = true;
|
||||||
|
projectsStore.currentProject = { id: currentProjectId } as Project;
|
||||||
|
|
||||||
|
const { menu } = useGlobalEntityCreation(false);
|
||||||
|
|
||||||
|
expect(menu.value[0]).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
route: { name: VIEWS.NEW_WORKFLOW, query: { projectId: currentProjectId } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(menu.value[1]).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
route: {
|
||||||
|
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||||
|
params: { projectId: currentProjectId, credentialId: 'create' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be disabled in readOnly', () => {
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
|
||||||
|
projectsStore.canCreateProjects = true;
|
||||||
|
projectsStore.currentProject = { id: currentProjectId } as Project;
|
||||||
|
|
||||||
|
const sourceControl = mockedStore(useSourceControlStore);
|
||||||
|
sourceControl.preferences.branchReadOnly = true;
|
||||||
|
|
||||||
|
const { menu } = useGlobalEntityCreation(false);
|
||||||
|
|
||||||
|
expect(menu.value[0]).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
disabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(menu.value[1]).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
disabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be disabled based in scopes', () => {
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
|
||||||
|
projectsStore.canCreateProjects = true;
|
||||||
|
projectsStore.currentProject = { id: currentProjectId, scopes: [] } as unknown as Project;
|
||||||
|
|
||||||
|
const { menu } = useGlobalEntityCreation(false);
|
||||||
|
|
||||||
|
expect(menu.value[0]).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
disabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(menu.value[1]).toStrictEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
disabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('global', () => {
|
||||||
|
it('should show personal + all team projects', () => {
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
|
||||||
|
const personalProjectId = 'personal-project';
|
||||||
|
projectsStore.canCreateProjects = true;
|
||||||
|
projectsStore.personalProject = { id: personalProjectId } as Project;
|
||||||
|
projectsStore.myProjects = [
|
||||||
|
{ id: '1', name: '1', type: 'team' },
|
||||||
|
{ id: '2', name: '2', type: 'public' },
|
||||||
|
{ id: '3', name: '3', type: 'team' },
|
||||||
|
] as ProjectListItem[];
|
||||||
|
|
||||||
|
const { menu } = useGlobalEntityCreation(true);
|
||||||
|
|
||||||
|
expect(menu.value[0].submenu?.length).toBe(4);
|
||||||
|
expect(menu.value[1].submenu?.length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSelect()', () => {
|
||||||
|
it('should only handle create-project', () => {
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.canCreateProjects = true;
|
||||||
|
const { handleSelect } = useGlobalEntityCreation(true);
|
||||||
|
handleSelect('dummy');
|
||||||
|
expect(projectsStore.createProject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new project', async () => {
|
||||||
|
const toast = useToast();
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.canCreateProjects = true;
|
||||||
|
projectsStore.createProject.mockResolvedValueOnce({ name: 'test', id: '1' } as Project);
|
||||||
|
|
||||||
|
const { handleSelect } = useGlobalEntityCreation(true);
|
||||||
|
|
||||||
|
handleSelect('create-project');
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(projectsStore.createProject).toHaveBeenCalled();
|
||||||
|
expect(routerPushMock).toHaveBeenCalled();
|
||||||
|
expect(toast.showMessage).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles create project error', async () => {
|
||||||
|
const toast = useToast();
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.canCreateProjects = true;
|
||||||
|
projectsStore.createProject.mockRejectedValueOnce(new Error('error'));
|
||||||
|
|
||||||
|
const { handleSelect } = useGlobalEntityCreation(true);
|
||||||
|
|
||||||
|
handleSelect('create-project');
|
||||||
|
await flushPromises();
|
||||||
|
expect(toast.showError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects when project limit has been reached', () => {
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.canCreateProjects = false;
|
||||||
|
const redirect = usePageRedirectionHelper();
|
||||||
|
|
||||||
|
const { handleSelect } = useGlobalEntityCreation(true);
|
||||||
|
|
||||||
|
handleSelect('create-project');
|
||||||
|
expect(redirect.goToUpgrade).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
208
packages/editor-ui/src/composables/useGlobalEntityCreation.ts
Normal file
208
packages/editor-ui/src/composables/useGlobalEntityCreation.ts
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
import { computed, toValue, type ComputedRef, type Ref } from 'vue';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { sortByProperty } from '@/utils/sortUtils';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
import type { Scope } from '@n8n/permissions';
|
||||||
|
import type { RouteLocationRaw } from 'vue-router';
|
||||||
|
|
||||||
|
type BaseItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: string;
|
||||||
|
route?: RouteLocationRaw;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Item = BaseItem & {
|
||||||
|
submenu?: BaseItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGlobalEntityCreation = (
|
||||||
|
multipleProjects: Ref<boolean> | ComputedRef<boolean> | boolean = true,
|
||||||
|
) => {
|
||||||
|
const CREATE_PROJECT_ID = 'create-project';
|
||||||
|
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
|
const sourceControlStore = useSourceControlStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
const displayProjects = computed(() =>
|
||||||
|
sortByProperty(
|
||||||
|
'name',
|
||||||
|
projectsStore.myProjects.filter((p) => p.type === 'team'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const disabledWorkflow = (scopes: Scope[] = []): boolean =>
|
||||||
|
sourceControlStore.preferences.branchReadOnly ||
|
||||||
|
!getResourcePermissions(scopes).workflow.create;
|
||||||
|
|
||||||
|
const disabledCredential = (scopes: Scope[] = []): boolean =>
|
||||||
|
sourceControlStore.preferences.branchReadOnly ||
|
||||||
|
!getResourcePermissions(scopes).credential.create;
|
||||||
|
|
||||||
|
const menu = computed<Item[]>(() => {
|
||||||
|
// Community
|
||||||
|
if (!projectsStore.canCreateProjects) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'workflow',
|
||||||
|
title: 'Workflow',
|
||||||
|
route: {
|
||||||
|
name: VIEWS.NEW_WORKFLOW,
|
||||||
|
query: {
|
||||||
|
projectId: projectsStore.personalProject?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'credential',
|
||||||
|
title: 'Credential',
|
||||||
|
route: {
|
||||||
|
name: VIEWS.CREDENTIALS,
|
||||||
|
params: {
|
||||||
|
projectId: projectsStore.personalProject?.id,
|
||||||
|
credentialId: 'create',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// single project
|
||||||
|
if (!toValue(multipleProjects)) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'workflow',
|
||||||
|
title: 'Workflow',
|
||||||
|
disabled: disabledWorkflow(projectsStore.currentProject?.scopes),
|
||||||
|
route: {
|
||||||
|
name: VIEWS.NEW_WORKFLOW,
|
||||||
|
query: {
|
||||||
|
projectId: projectsStore.currentProject?.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'credential',
|
||||||
|
title: 'Credential',
|
||||||
|
disabled: disabledCredential(projectsStore.currentProject?.scopes),
|
||||||
|
route: {
|
||||||
|
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||||
|
params: {
|
||||||
|
projectId: projectsStore.currentProject?.id,
|
||||||
|
credentialId: 'create',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// global
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'workflow',
|
||||||
|
title: 'Workflow',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
id: 'workflow-title',
|
||||||
|
title: 'Create in',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'workflow-personal',
|
||||||
|
title: i18n.baseText('projects.menu.personal'),
|
||||||
|
icon: 'user',
|
||||||
|
disabled: disabledWorkflow(projectsStore.personalProject?.scopes),
|
||||||
|
route: {
|
||||||
|
name: VIEWS.NEW_WORKFLOW,
|
||||||
|
query: { projectId: projectsStore.personalProject?.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...displayProjects.value.map((project) => ({
|
||||||
|
id: `workflow-${project.id}`,
|
||||||
|
title: project.name as string,
|
||||||
|
icon: 'layer-group',
|
||||||
|
disabled: disabledWorkflow(project.scopes),
|
||||||
|
route: {
|
||||||
|
name: VIEWS.NEW_WORKFLOW,
|
||||||
|
query: { projectId: project.id },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'credential',
|
||||||
|
title: 'Credential',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
id: 'credential-title',
|
||||||
|
title: 'Create in',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'credential-personal',
|
||||||
|
title: i18n.baseText('projects.menu.personal'),
|
||||||
|
icon: 'user',
|
||||||
|
disabled: disabledCredential(projectsStore.personalProject?.scopes),
|
||||||
|
route: {
|
||||||
|
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||||
|
params: { projectId: projectsStore.personalProject?.id, credentialId: 'create' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...displayProjects.value.map((project) => ({
|
||||||
|
id: `credential-${project.id}`,
|
||||||
|
title: project.name as string,
|
||||||
|
icon: 'layer-group',
|
||||||
|
disabled: disabledCredential(project.scopes),
|
||||||
|
route: {
|
||||||
|
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||||
|
params: { projectId: project.id, credentialId: 'create' },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: CREATE_PROJECT_ID,
|
||||||
|
title: 'Project',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const createProject = async () => {
|
||||||
|
try {
|
||||||
|
const newProject = await projectsStore.createProject({
|
||||||
|
name: i18n.baseText('projects.settings.newProjectName'),
|
||||||
|
});
|
||||||
|
await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } });
|
||||||
|
toast.showMessage({
|
||||||
|
title: i18n.baseText('projects.settings.save.successful.title', {
|
||||||
|
interpolate: { projectName: newProject.name as string },
|
||||||
|
}),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('projects.error.title'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (id: string) => {
|
||||||
|
if (id !== CREATE_PROJECT_ID) return;
|
||||||
|
|
||||||
|
if (projectsStore.canCreateProjects) {
|
||||||
|
void createProject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { menu, handleSelect };
|
||||||
|
};
|
|
@ -6,10 +6,14 @@ import { useUIStore } from '@/stores/ui.store';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { waitFor, within, fireEvent } from '@testing-library/vue';
|
import { waitFor, within, fireEvent } from '@testing-library/vue';
|
||||||
import { CREDENTIAL_SELECT_MODAL_KEY, STORES } from '@/constants';
|
import { CREDENTIAL_SELECT_MODAL_KEY, STORES } from '@/constants';
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
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 { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
||||||
|
useGlobalEntityCreation: () => ({
|
||||||
|
menu: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('vue-router', async () => {
|
vi.mock('vue-router', async () => {
|
||||||
const actual = await vi.importActual('vue-router');
|
const actual = await vi.importActual('vue-router');
|
||||||
|
@ -96,28 +100,6 @@ describe('CredentialsView', () => {
|
||||||
renderComponent({ props: { credentialId: 'create' } });
|
renderComponent({ props: { credentialId: 'create' } });
|
||||||
expect(uiStore.openModal).toHaveBeenCalledWith(CREDENTIAL_SELECT_MODAL_KEY);
|
expect(uiStore.openModal).toHaveBeenCalledWith(CREDENTIAL_SELECT_MODAL_KEY);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update credentialId route param to create', async () => {
|
|
||||||
const projectsStore = mockedStore(useProjectsStore);
|
|
||||||
projectsStore.isProjectHome = false;
|
|
||||||
projectsStore.currentProject = { scopes: ['credential:create'] } as Project;
|
|
||||||
const credentialsStore = mockedStore(useCredentialsStore);
|
|
||||||
credentialsStore.allCredentials = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'test',
|
|
||||||
type: 'test',
|
|
||||||
createdAt: '2021-05-05T00:00:00Z',
|
|
||||||
updatedAt: '2021-05-05T00:00:00Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const { getByTestId } = renderComponent();
|
|
||||||
|
|
||||||
await userEvent.click(getByTestId('resources-list-add'));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(router.replace).toHaveBeenCalledWith({ params: { credentialId: 'create' } }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('open existing credential', () => {
|
describe('open existing credential', () => {
|
||||||
|
|
|
@ -25,7 +25,6 @@ import { getResourcePermissions } from '@/permissions';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { N8nButton, N8nInputLabel, N8nSelect, N8nOption } from 'n8n-design-system';
|
|
||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
@ -74,12 +73,6 @@ const credentialTypesById = computed<ICredentialTypeMap>(
|
||||||
() => credentialsStore.credentialTypesById,
|
() => credentialsStore.credentialTypesById,
|
||||||
);
|
);
|
||||||
|
|
||||||
const addCredentialButtonText = computed(() =>
|
|
||||||
projectsStore.currentProject
|
|
||||||
? i18n.baseText('credentials.project.add')
|
|
||||||
: i18n.baseText('credentials.add'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
|
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
|
||||||
|
|
||||||
const projectPermissions = computed(() =>
|
const projectPermissions = computed(() =>
|
||||||
|
@ -192,19 +185,6 @@ onMounted(() => {
|
||||||
<template #header>
|
<template #header>
|
||||||
<ProjectHeader />
|
<ProjectHeader />
|
||||||
</template>
|
</template>
|
||||||
<template #add-button="{ disabled }">
|
|
||||||
<div>
|
|
||||||
<N8nButton
|
|
||||||
size="large"
|
|
||||||
block
|
|
||||||
:disabled="disabled"
|
|
||||||
data-test-id="resources-list-add"
|
|
||||||
@click="addCredential"
|
|
||||||
>
|
|
||||||
{{ addCredentialButtonText }}
|
|
||||||
</N8nButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #default="{ data }">
|
<template #default="{ data }">
|
||||||
<CredentialCard
|
<CredentialCard
|
||||||
data-test-id="resources-list-item"
|
data-test-id="resources-list-item"
|
||||||
|
|
|
@ -19,6 +19,11 @@ import * as usersApi from '@/api/users';
|
||||||
vi.mock('@/api/projects.api');
|
vi.mock('@/api/projects.api');
|
||||||
vi.mock('@/api/users');
|
vi.mock('@/api/users');
|
||||||
vi.mock('@/api/sourceControl');
|
vi.mock('@/api/sourceControl');
|
||||||
|
vi.mock('@/composables/useGlobalEntityCreation', () => ({
|
||||||
|
useGlobalEntityCreation: () => ({
|
||||||
|
menu: [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
|
@ -146,55 +151,6 @@ describe('WorkflowsView', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('workflow creation button', () => {
|
|
||||||
it('should create global workflow', async () => {
|
|
||||||
const pushSpy = vi.spyOn(router, 'push');
|
|
||||||
const pinia = createTestingPinia({ initialState });
|
|
||||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
|
||||||
workflowsStore.allWorkflows = [{ id: '1' } as IWorkflowDb];
|
|
||||||
|
|
||||||
const projectsStore = mockedStore(useProjectsStore);
|
|
||||||
projectsStore.fetchProject.mockResolvedValue({} as Project);
|
|
||||||
projectsStore.personalProject = { scopes: ['workflow:create'] } as Project;
|
|
||||||
|
|
||||||
const { getByTestId } = renderComponent({ pinia });
|
|
||||||
expect(getByTestId('resources-list-add')).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(getByTestId('resources-list-add').textContent).toBe('Add workflow');
|
|
||||||
|
|
||||||
await userEvent.click(getByTestId('resources-list-add'));
|
|
||||||
|
|
||||||
expect(pushSpy).toHaveBeenCalledWith({ name: VIEWS.NEW_WORKFLOW, query: { projectId: '' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a project specific workflow', async () => {
|
|
||||||
await router.replace({ path: '/project-id' });
|
|
||||||
const pushSpy = vi.spyOn(router, 'push');
|
|
||||||
|
|
||||||
const pinia = createTestingPinia({ initialState });
|
|
||||||
const workflowsStore = mockedStore(useWorkflowsStore);
|
|
||||||
workflowsStore.allWorkflows = [{ id: '1' } as IWorkflowDb];
|
|
||||||
|
|
||||||
const projectsStore = mockedStore(useProjectsStore);
|
|
||||||
|
|
||||||
projectsStore.currentProject = { scopes: ['workflow:create'] } as Project;
|
|
||||||
|
|
||||||
const { getByTestId } = renderComponent({ pinia });
|
|
||||||
expect(router.currentRoute.value.params.projectId).toBe('project-id');
|
|
||||||
|
|
||||||
expect(getByTestId('resources-list-add')).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(getByTestId('resources-list-add').textContent).toBe('Add workflow to project');
|
|
||||||
|
|
||||||
await userEvent.click(getByTestId('resources-list-add'));
|
|
||||||
|
|
||||||
expect(pushSpy).toHaveBeenCalledWith({
|
|
||||||
name: VIEWS.NEW_WORKFLOW,
|
|
||||||
query: { projectId: 'project-id' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('filters', () => {
|
describe('filters', () => {
|
||||||
it('should set tag filter based on query parameters', async () => {
|
it('should set tag filter based on query parameters', async () => {
|
||||||
await router.replace({ query: { tags: 'test-tag' } });
|
await router.replace({ query: { tags: 'test-tag' } });
|
||||||
|
|
|
@ -23,7 +23,6 @@ import { useI18n } from '@/composables/useI18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import {
|
import {
|
||||||
N8nButton,
|
|
||||||
N8nCard,
|
N8nCard,
|
||||||
N8nHeading,
|
N8nHeading,
|
||||||
N8nIcon,
|
N8nIcon,
|
||||||
|
@ -31,7 +30,6 @@ import {
|
||||||
N8nOption,
|
N8nOption,
|
||||||
N8nSelect,
|
N8nSelect,
|
||||||
N8nText,
|
N8nText,
|
||||||
N8nTooltip,
|
|
||||||
} from 'n8n-design-system';
|
} from 'n8n-design-system';
|
||||||
import { pickBy } from 'lodash-es';
|
import { pickBy } from 'lodash-es';
|
||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
|
@ -116,12 +114,6 @@ const isSalesUser = computed(() => {
|
||||||
return ['Sales', 'sales-and-marketing'].includes(userRole.value || '');
|
return ['Sales', 'sales-and-marketing'].includes(userRole.value || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
const addWorkflowButtonText = computed(() => {
|
|
||||||
return projectsStore.currentProject
|
|
||||||
? i18n.baseText('workflows.project.add')
|
|
||||||
: i18n.baseText('workflows.add');
|
|
||||||
});
|
|
||||||
|
|
||||||
const projectPermissions = computed(() => {
|
const projectPermissions = computed(() => {
|
||||||
return getResourcePermissions(
|
return getResourcePermissions(
|
||||||
projectsStore.currentProject?.scopes ?? projectsStore.personalProject?.scopes,
|
projectsStore.currentProject?.scopes ?? projectsStore.personalProject?.scopes,
|
||||||
|
@ -313,30 +305,6 @@ onMounted(async () => {
|
||||||
<template #header>
|
<template #header>
|
||||||
<ProjectHeader />
|
<ProjectHeader />
|
||||||
</template>
|
</template>
|
||||||
<template #add-button="{ disabled }">
|
|
||||||
<N8nTooltip :disabled="!readOnlyEnv">
|
|
||||||
<div>
|
|
||||||
<N8nButton
|
|
||||||
size="large"
|
|
||||||
block
|
|
||||||
:disabled="disabled"
|
|
||||||
data-test-id="resources-list-add"
|
|
||||||
@click="addWorkflow"
|
|
||||||
>
|
|
||||||
{{ addWorkflowButtonText }}
|
|
||||||
</N8nButton>
|
|
||||||
</div>
|
|
||||||
<template #content>
|
|
||||||
<i18n-t tag="span" keypath="mainSidebar.workflows.readOnlyEnv.tooltip">
|
|
||||||
<template #link>
|
|
||||||
<a target="_blank" href="https://docs.n8n.io/source-control-environments/">
|
|
||||||
{{ i18n.baseText('mainSidebar.workflows.readOnlyEnv.tooltip.link') }}
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</template>
|
|
||||||
</N8nTooltip>
|
|
||||||
</template>
|
|
||||||
<template #default="{ data, updateItemSize }">
|
<template #default="{ data, updateItemSize }">
|
||||||
<WorkflowCard
|
<WorkflowCard
|
||||||
data-test-id="resources-list-item"
|
data-test-id="resources-list-item"
|
||||||
|
|
Loading…
Reference in a new issue