fix(editor): Update the universal create button interaction (#12105)

This commit is contained in:
Raúl Gómez Morales 2024-12-11 15:23:55 +01:00 committed by GitHub
parent c572c0648c
commit 5300e0ac45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 235 additions and 270 deletions

View file

@ -6,48 +6,10 @@ export class CredentialsPage extends BasePage {
getters = { getters = {
emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'), emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'),
createCredentialButton: () => { createCredentialButton: () => {
cy.getByTestId('resource-add').should('be.visible').click(); cy.getByTestId('add-resource').should('be.visible').click();
cy.getByTestId('resource-add') cy.getByTestId('add-resource').getByTestId('action-credential').should('be.visible');
.find('.el-sub-menu__title') return cy.getByTestId('add-resource').getByTestId('action-credential');
.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'),

View file

@ -8,45 +8,8 @@ export class WorkflowsPage extends BasePage {
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: () => { createWorkflowButton: () => {
cy.getByTestId('resource-add').should('be.visible').click(); cy.getByTestId('add-resource-workflow').should('be.visible');
cy.getByTestId('resource-add') return cy.getByTestId('add-resource-workflow');
.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) =>

View file

@ -759,7 +759,10 @@ const createToastMessagingForNewCredentials = (
toastText = i18n.baseText('credentials.create.personal.toast.text'); toastText = i18n.baseText('credentials.create.personal.toast.text');
} }
if (projectsStore.currentProject) { if (
projectsStore.currentProject &&
projectsStore.currentProject.id !== projectsStore.personalProject?.id
) {
toastTitle = i18n.baseText('credentials.create.project.toast.title', { toastTitle = i18n.baseText('credentials.create.project.toast.title', {
interpolate: { projectName: project?.name ?? '' }, interpolate: { projectName: project?.name ?? '' },
}); });

View file

@ -605,7 +605,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
if (!id || ['new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(id)) { if (!id || ['new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(id)) {
let toastTitle = locale.baseText('workflows.create.personal.toast.title'); let toastTitle = locale.baseText('workflows.create.personal.toast.title');
let toastText = locale.baseText('workflows.create.personal.toast.text'); let toastText = locale.baseText('workflows.create.personal.toast.text');
if (projectsStore.currentProject) {
if (
projectsStore.currentProject &&
projectsStore.currentProject.id !== projectsStore.personalProject?.id
) {
toastTitle = locale.baseText('workflows.create.project.toast.title', { toastTitle = locale.baseText('workflows.create.project.toast.title', {
interpolate: { projectName: projectsStore.currentProject.name ?? '' }, interpolate: { projectName: projectsStore.currentProject.name ?? '' },
}); });

View file

@ -296,6 +296,7 @@ const {
handleSelect: handleMenuSelect, handleSelect: handleMenuSelect,
createProjectAppendSlotName, createProjectAppendSlotName,
projectsLimitReachedMessage, projectsLimitReachedMessage,
upgradeLabel,
} = useGlobalEntityCreation(); } = useGlobalEntityCreation();
onClickOutside(createBtn as Ref<VueInstance>, () => { onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close(); createBtn.value?.close();
@ -336,7 +337,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
type="tertiary" type="tertiary"
@click="handleMenuSelect(item.id)" @click="handleMenuSelect(item.id)"
> >
{{ i18n.baseText('generic.upgrade') }} {{ upgradeLabel }}
</N8nButton> </N8nButton>
</N8nTooltip> </N8nTooltip>
</template> </template>

View file

@ -0,0 +1,55 @@
<script lang="ts" setup>
import { N8nIconButton, N8nActionToggle } from 'n8n-design-system';
type Action = {
label: string;
value: string;
disabled: boolean;
};
defineProps<{
actions: Action[];
}>();
const emit = defineEmits<{
action: [id: string];
}>();
</script>
<template>
<div :class="[$style.buttonGroup]">
<slot></slot>
<N8nActionToggle
data-test-id="add-resource"
:actions="actions"
placement="bottom-end"
:teleported="false"
@action="emit('action', $event)"
>
<N8nIconButton :class="[$style.buttonGroupDropdown]" icon="angle-down" />
</N8nActionToggle>
</div>
</template>
<style lang="scss" module>
.buttonGroup {
display: inline-flex;
:global(> .button) {
border-right: 1px solid var(--button-font-color, var(--color-button-primary-font));
&:not(:first-child) {
border-radius: 0;
}
&:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}
.buttonGroupDropdown {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>

View file

@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
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';
@ -10,13 +9,19 @@ 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'; import { VIEWS } from '@/constants';
import userEvent from '@testing-library/user-event';
import { waitFor, within } from '@testing-library/vue';
const mockPush = vi.fn();
vi.mock('vue-router', async () => { vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router'); const actual = await vi.importActual('vue-router');
const params = {}; const params = {};
const location = {}; const location = {};
return { return {
...actual, ...actual,
useRouter: () => ({
push: mockPush,
}),
useRoute: () => ({ useRoute: () => ({
params, params,
location, location,
@ -32,7 +37,6 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
global: { global: {
stubs: { stubs: {
ProjectTabs: projectTabsSpy, ProjectTabs: projectTabsSpy,
N8nNavigationDropdown: true,
}, },
}, },
}); });
@ -143,23 +147,45 @@ describe('ProjectHeader', () => {
); );
}); });
test.each([ it('should create a workflow', async () => {
[null, 'Create'], const project = createTestProject({
[createTestProject({ type: ProjectTypes.Personal }), 'Create in personal'], scopes: ['workflow:create'],
[createTestProject({ type: ProjectTypes.Team }), 'Create in project'],
])('in project %s should render correct create button label %s', (project, label) => {
projectsStore.currentProject = project;
const { getByTestId } = renderComponent({
global: {
stubs: {
N8nNavigationDropdown: {
template: '<div><slot></slot></div>',
},
},
},
}); });
projectsStore.currentProject = project;
expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible(); const { getByTestId } = renderComponent();
await userEvent.click(getByTestId('add-resource-workflow'));
expect(mockPush).toHaveBeenCalledWith({
name: VIEWS.NEW_WORKFLOW,
query: { projectId: project.id },
});
});
describe('dropdown', () => {
it('should create a credential', async () => {
const project = createTestProject({
scopes: ['credential:create'],
});
projectsStore.currentProject = project;
const { getByTestId } = renderComponent();
await userEvent.click(within(getByTestId('add-resource')).getByRole('button'));
await waitFor(() => expect(getByTestId('action-credential')).toBeVisible());
await userEvent.click(getByTestId('action-credential'));
expect(mockPush).toHaveBeenCalledWith({
name: VIEWS.PROJECTS_CREDENTIALS,
params: {
projectId: project.id,
credentialId: 'create',
},
});
});
}); });
it('should not render creation button in setting page', async () => { it('should not render creation button in setting page', async () => {
@ -167,15 +193,7 @@ describe('ProjectHeader', () => {
vi.spyOn(router, 'useRoute').mockReturnValueOnce({ vi.spyOn(router, 'useRoute').mockReturnValueOnce({
name: VIEWS.PROJECT_SETTINGS, name: VIEWS.PROJECT_SETTINGS,
} as RouteLocationNormalizedLoadedGeneric); } as RouteLocationNormalizedLoadedGeneric);
const { queryByTestId } = renderComponent({ const { queryByTestId } = renderComponent();
global: { expect(queryByTestId('add-resource-buttons')).not.toBeInTheDocument();
stubs: {
N8nNavigationDropdown: {
template: '<div><slot></slot></div>',
},
},
},
});
expect(queryByTestId('resource-add')).not.toBeInTheDocument();
}); });
}); });

View file

@ -1,21 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, type Ref, ref } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { N8nNavigationDropdown, N8nButton, N8nIconButton, N8nTooltip } from 'n8n-design-system'; import { N8nButton } 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';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter();
const i18n = useI18n(); const i18n = useI18n();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const createBtn = ref<InstanceType<typeof N8nNavigationDropdown>>();
const headerIcon = computed(() => { const headerIcon = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) { if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
@ -48,22 +48,58 @@ const showSettings = computed(
projectsStore.currentProject?.type === ProjectTypes.Team, projectsStore.currentProject?.type === ProjectTypes.Team,
); );
const { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage } = const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
useGlobalEntityCreation(computed(() => !Boolean(projectsStore.currentProject)));
const createLabel = computed(() => { const ACTION_TYPES = {
if (!projectsStore.currentProject) { WORKFLOW: 'workflow',
return i18n.baseText('projects.create'); CREDENTIAL: 'credential',
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) { } as const;
return i18n.baseText('projects.create.personal'); type ActionTypes = (typeof ACTION_TYPES)[keyof typeof ACTION_TYPES];
} else {
return i18n.baseText('projects.create.team'); const createWorkflowButton = computed(() => ({
value: ACTION_TYPES.WORKFLOW,
label: 'Create Workflow',
disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes).workflow.create,
}));
const menu = computed(() => [
{
value: ACTION_TYPES.CREDENTIAL,
label: 'Create credential',
disabled:
sourceControlStore.preferences.branchReadOnly ||
!getResourcePermissions(homeProject.value?.scopes).credential.create,
},
]);
const actions: Record<ActionTypes, (projectId: string) => void> = {
[ACTION_TYPES.WORKFLOW]: (projectId: string) => {
void router.push({
name: VIEWS.NEW_WORKFLOW,
query: {
projectId,
},
});
},
[ACTION_TYPES.CREDENTIAL]: (projectId: string) => {
void router.push({
name: VIEWS.PROJECTS_CREDENTIALS,
params: {
projectId,
credentialId: 'create',
},
});
},
} as const;
const onSelect = (action: string) => {
const executableAction = actions[action as ActionTypes];
if (!homeProject.value) {
return;
} }
}); executableAction(homeProject.value.id);
};
onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close();
});
</script> </script>
<template> <template>
@ -83,30 +119,17 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
</N8nText> </N8nText>
</div> </div>
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]"> <div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
<N8nNavigationDropdown <ProjectCreateResource
ref="createBtn" data-test-id="add-resource-buttons"
data-test-id="resource-add" :actions="menu"
:menu="menu" @action="onSelect"
@select="handleSelect"
> >
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" /> <N8nButton
<template #[createProjectAppendSlotName]="{ item }"> data-test-id="add-resource-workflow"
<N8nTooltip v-bind="createWorkflowButton"
v-if="item.disabled" @click="onSelect(ACTION_TYPES.WORKFLOW)"
placement="right" />
:content="projectsLimitReachedMessage" </ProjectCreateResource>
>
<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>
<div :class="$style.actions"> <div :class="$style.actions">

View file

@ -1,7 +1,6 @@
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia'; import { setActivePinia } from 'pinia';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { mockedStore } from '@/__tests__/utils'; import { mockedStore } from '@/__tests__/utils';
import type router from 'vue-router'; import type router from 'vue-router';
import { flushPromises } from '@vue/test-utils'; import { flushPromises } from '@vue/test-utils';
@ -80,79 +79,6 @@ describe('useGlobalEntityCreation', () => {
); );
}); });
describe('single project', () => {
const currentProjectId = 'current-project';
it('should use currentProject', () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isTeamProjectFeatureEnabled = 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.isTeamProjectFeatureEnabled = 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.isTeamProjectFeatureEnabled = 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', () => { describe('global', () => {
it('should show personal + all team projects', () => { it('should show personal + all team projects', () => {
const projectsStore = mockedStore(useProjectsStore); const projectsStore = mockedStore(useProjectsStore);
@ -167,7 +93,7 @@ describe('useGlobalEntityCreation', () => {
{ id: '3', name: '3', type: 'team' }, { id: '3', name: '3', type: 'team' },
] as ProjectListItem[]; ] as ProjectListItem[];
const { menu } = useGlobalEntityCreation(true); const { menu } = useGlobalEntityCreation();
expect(menu.value[0].submenu?.length).toBe(4); expect(menu.value[0].submenu?.length).toBe(4);
expect(menu.value[1].submenu?.length).toBe(4); expect(menu.value[1].submenu?.length).toBe(4);
@ -178,7 +104,7 @@ describe('useGlobalEntityCreation', () => {
it('should only handle create-project', () => { it('should only handle create-project', () => {
const projectsStore = mockedStore(useProjectsStore); const projectsStore = mockedStore(useProjectsStore);
projectsStore.isTeamProjectFeatureEnabled = true; projectsStore.isTeamProjectFeatureEnabled = true;
const { handleSelect } = useGlobalEntityCreation(true); const { handleSelect } = useGlobalEntityCreation();
handleSelect('dummy'); handleSelect('dummy');
expect(projectsStore.createProject).not.toHaveBeenCalled(); expect(projectsStore.createProject).not.toHaveBeenCalled();
}); });
@ -190,7 +116,7 @@ describe('useGlobalEntityCreation', () => {
projectsStore.canCreateProjects = true; projectsStore.canCreateProjects = true;
projectsStore.createProject.mockResolvedValueOnce({ name: 'test', id: '1' } as Project); projectsStore.createProject.mockResolvedValueOnce({ name: 'test', id: '1' } as Project);
const { handleSelect } = useGlobalEntityCreation(true); const { handleSelect } = useGlobalEntityCreation();
handleSelect('create-project'); handleSelect('create-project');
await flushPromises(); await flushPromises();
@ -207,7 +133,7 @@ describe('useGlobalEntityCreation', () => {
projectsStore.canCreateProjects = true; projectsStore.canCreateProjects = true;
projectsStore.createProject.mockRejectedValueOnce(new Error('error')); projectsStore.createProject.mockRejectedValueOnce(new Error('error'));
const { handleSelect } = useGlobalEntityCreation(true); const { handleSelect } = useGlobalEntityCreation();
handleSelect('create-project'); handleSelect('create-project');
await flushPromises(); await flushPromises();
@ -220,7 +146,7 @@ describe('useGlobalEntityCreation', () => {
projectsStore.isTeamProjectFeatureEnabled = true; projectsStore.isTeamProjectFeatureEnabled = true;
const redirect = usePageRedirectionHelper(); const redirect = usePageRedirectionHelper();
const { handleSelect } = useGlobalEntityCreation(true); const { handleSelect } = useGlobalEntityCreation();
handleSelect('create-project'); handleSelect('create-project');
expect(redirect.goToUpgrade).toHaveBeenCalled(); expect(redirect.goToUpgrade).toHaveBeenCalled();
@ -237,14 +163,20 @@ describe('useGlobalEntityCreation', () => {
projectsStore.teamProjectsLimit = 10; projectsStore.teamProjectsLimit = 10;
settingsStore.isCloudDeployment = true; settingsStore.isCloudDeployment = true;
const { projectsLimitReachedMessage } = useGlobalEntityCreation(true); const { projectsLimitReachedMessage, upgradeLabel } = useGlobalEntityCreation();
expect(projectsLimitReachedMessage.value).toContain( expect(projectsLimitReachedMessage.value).toContain(
'You have reached the Pro plan limit of 10.', 'You have reached the Pro plan limit of 10.',
); );
expect(upgradeLabel.value).toBe('Upgrade');
settingsStore.isCloudDeployment = false; settingsStore.isCloudDeployment = false;
expect(projectsLimitReachedMessage.value).toContain('You have reached the plan limit of');
expect(upgradeLabel.value).toBe('Upgrade');
projectsStore.isTeamProjectFeatureEnabled = false;
expect(projectsLimitReachedMessage.value).toContain( expect(projectsLimitReachedMessage.value).toContain(
'Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows', 'Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows',
); );
expect(upgradeLabel.value).toBe('Enterprise');
}); });
}); });

View file

@ -1,4 +1,4 @@
import { computed, toValue, type ComputedRef, type Ref } from 'vue'; import { computed } from 'vue';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
@ -25,9 +25,7 @@ type Item = BaseItem & {
submenu?: BaseItem[]; submenu?: BaseItem[];
}; };
export const useGlobalEntityCreation = ( export const useGlobalEntityCreation = () => {
multipleProjects: Ref<boolean> | ComputedRef<boolean> | boolean = true,
) => {
const CREATE_PROJECT_ID = 'create-project'; const CREATE_PROJECT_ID = 'create-project';
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
@ -77,34 +75,10 @@ export const useGlobalEntityCreation = (
}, },
}, },
}, },
];
}
// single project
if (!toValue(multipleProjects)) {
return [
{ {
id: 'workflow', id: CREATE_PROJECT_ID,
title: 'Workflow', title: 'Project',
disabled: disabledWorkflow(projectsStore.currentProject?.scopes), disabled: true,
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',
},
},
}, },
]; ];
} }
@ -211,7 +185,7 @@ export const useGlobalEntityCreation = (
const projectsLimitReachedMessage = computed(() => { const projectsLimitReachedMessage = computed(() => {
if (settingsStore.isCloudDeployment) { if (settingsStore.isCloudDeployment) {
return i18n.baseText('projects.create.limitReached', { return i18n.baseText('projects.create.limitReached.cloud', {
adjustToNumber: projectsStore.teamProjectsLimit, adjustToNumber: projectsStore.teamProjectsLimit,
interpolate: { interpolate: {
planName: cloudPlanStore.currentPlanData?.displayName ?? '', planName: cloudPlanStore.currentPlanData?.displayName ?? '',
@ -220,10 +194,37 @@ export const useGlobalEntityCreation = (
}); });
} }
return i18n.baseText('projects.create.limitReached.self'); if (!projectsStore.isTeamProjectFeatureEnabled) {
return i18n.baseText('projects.create.limitReached.self');
}
return i18n.baseText('projects.create.limitReached', {
adjustToNumber: projectsStore.teamProjectsLimit,
interpolate: {
limit: projectsStore.teamProjectsLimit,
},
});
}); });
const createProjectAppendSlotName = computed(() => `item.append.${CREATE_PROJECT_ID}`); const createProjectAppendSlotName = computed(() => `item.append.${CREATE_PROJECT_ID}`);
return { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage }; const upgradeLabel = computed(() => {
if (settingsStore.isCloudDeployment) {
return i18n.baseText('generic.upgrade');
}
if (!projectsStore.isTeamProjectFeatureEnabled) {
return i18n.baseText('generic.enterprise');
}
return i18n.baseText('generic.upgrade');
});
return {
menu,
handleSelect,
createProjectAppendSlotName,
projectsLimitReachedMessage,
upgradeLabel,
};
}; };

View file

@ -46,6 +46,7 @@
"generic.copy": "Copy", "generic.copy": "Copy",
"generic.delete": "Delete", "generic.delete": "Delete",
"generic.dontShowAgain": "Don't show again", "generic.dontShowAgain": "Don't show again",
"generic.enterprise": "Enterprise",
"generic.executions": "Executions", "generic.executions": "Executions",
"generic.tests": "Tests", "generic.tests": "Tests",
"generic.or": "or", "generic.or": "or",
@ -77,6 +78,7 @@
"generic.ownedByMe": "(You)", "generic.ownedByMe": "(You)",
"generic.moreInfo": "More info", "generic.moreInfo": "More info",
"generic.next": "Next", "generic.next": "Next",
"generic.pro": "Pro",
"generic.viewDocs": "View docs", "generic.viewDocs": "View docs",
"about.aboutN8n": "About n8n", "about.aboutN8n": "About n8n",
"about.close": "Close", "about.close": "Close",
@ -630,7 +632,7 @@
"credentials.noResults.withSearch.switchToShared.preamble": "some credentials may be", "credentials.noResults.withSearch.switchToShared.preamble": "some credentials may be",
"credentials.noResults.withSearch.switchToShared.link": "hidden", "credentials.noResults.withSearch.switchToShared.link": "hidden",
"credentials.create.personal.toast.title": "Credential successfully created", "credentials.create.personal.toast.title": "Credential successfully created",
"credentials.create.personal.toast.text": "This credential is currently private to you.", "credentials.create.personal.toast.text": "This credential has been created inside your personal space.",
"credentials.create.project.toast.title": "Credential successfully created in {projectName}", "credentials.create.project.toast.title": "Credential successfully created in {projectName}",
"credentials.create.project.toast.text": "All members from {projectName} will have access to this credential.", "credentials.create.project.toast.text": "All members from {projectName} will have access to this credential.",
"dataDisplay.needHelp": "Need help?", "dataDisplay.needHelp": "Need help?",
@ -2329,7 +2331,7 @@
"workflows.concurrentChanges.confirmMessage.cancelButtonText": "Cancel", "workflows.concurrentChanges.confirmMessage.cancelButtonText": "Cancel",
"workflows.concurrentChanges.confirmMessage.confirmButtonText": "Overwrite and Save", "workflows.concurrentChanges.confirmMessage.confirmButtonText": "Overwrite and Save",
"workflows.create.personal.toast.title": "Workflow successfully created", "workflows.create.personal.toast.title": "Workflow successfully created",
"workflows.create.personal.toast.text": "This workflow is currently private to you.", "workflows.create.personal.toast.text": "This workflow has been created inside your personal space.",
"workflows.create.project.toast.title": "Workflow successfully created in {projectName}", "workflows.create.project.toast.title": "Workflow successfully created in {projectName}",
"workflows.create.project.toast.text": "All members from {projectName} will have access to this workflow.", "workflows.create.project.toast.text": "All members from {projectName} will have access to this workflow.",
"importCurlModal.title": "Import cURL command", "importCurlModal.title": "Import cURL command",
@ -2571,6 +2573,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.cloud": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects.",
"projects.create.limitReached.self": "Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows", "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",