mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Update the universal create button interaction (#12105)
This commit is contained in:
parent
c572c0648c
commit
5300e0ac45
|
@ -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'),
|
||||||
|
|
|
@ -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) =>
|
||||||
|
|
|
@ -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 ?? '' },
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 ?? '' },
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Reference in a new issue