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 = {
emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'),
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('add-resource').should('be.visible').click();
cy.getByTestId('add-resource').getByTestId('action-credential').should('be.visible');
return cy.getByTestId('add-resource').getByTestId('action-credential');
},
// cy.getByTestId('resources-list-add'),
searchInput: () => cy.getByTestId('resources-list-search'),
emptyList: () => cy.getByTestId('resources-list-empty'),
credentialCards: () => cy.getByTestId('resources-list-item'),

View file

@ -8,45 +8,8 @@ export class WorkflowsPage extends BasePage {
newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'),
searchBar: () => cy.getByTestId('resources-list-search'),
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');
cy.getByTestId('add-resource-workflow').should('be.visible');
return cy.getByTestId('add-resource-workflow');
},
workflowCards: () => cy.getByTestId('resources-list-item'),
workflowCard: (workflowName: string) =>

View file

@ -759,7 +759,10 @@ const createToastMessagingForNewCredentials = (
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', {
interpolate: { projectName: project?.name ?? '' },
});

View file

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

View file

@ -296,6 +296,7 @@ const {
handleSelect: handleMenuSelect,
createProjectAppendSlotName,
projectsLimitReachedMessage,
upgradeLabel,
} = useGlobalEntityCreation();
onClickOutside(createBtn as Ref<VueInstance>, () => {
createBtn.value?.close();
@ -336,7 +337,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
type="tertiary"
@click="handleMenuSelect(item.id)"
>
{{ i18n.baseText('generic.upgrade') }}
{{ upgradeLabel }}
</N8nButton>
</N8nTooltip>
</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 { within } from '@testing-library/dom';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createTestProject } from '@/__tests__/data/projects';
@ -10,13 +9,19 @@ import { useProjectsStore } from '@/stores/projects.store';
import type { Project } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
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 () => {
const actual = await vi.importActual('vue-router');
const params = {};
const location = {};
return {
...actual,
useRouter: () => ({
push: mockPush,
}),
useRoute: () => ({
params,
location,
@ -32,7 +37,6 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
global: {
stubs: {
ProjectTabs: projectTabsSpy,
N8nNavigationDropdown: true,
},
},
});
@ -143,23 +147,45 @@ describe('ProjectHeader', () => {
);
});
test.each([
[null, 'Create'],
[createTestProject({ type: ProjectTypes.Personal }), 'Create in personal'],
[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>',
},
},
},
it('should create a workflow', async () => {
const project = createTestProject({
scopes: ['workflow:create'],
});
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 () => {
@ -167,15 +193,7 @@ describe('ProjectHeader', () => {
vi.spyOn(router, 'useRoute').mockReturnValueOnce({
name: VIEWS.PROJECT_SETTINGS,
} as RouteLocationNormalizedLoadedGeneric);
const { queryByTestId } = renderComponent({
global: {
stubs: {
N8nNavigationDropdown: {
template: '<div><slot></slot></div>',
},
},
},
});
expect(queryByTestId('resource-add')).not.toBeInTheDocument();
const { queryByTestId } = renderComponent();
expect(queryByTestId('add-resource-buttons')).not.toBeInTheDocument();
});
});

View file

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

View file

@ -1,7 +1,6 @@
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';
@ -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', () => {
it('should show personal + all team projects', () => {
const projectsStore = mockedStore(useProjectsStore);
@ -167,7 +93,7 @@ describe('useGlobalEntityCreation', () => {
{ id: '3', name: '3', type: 'team' },
] as ProjectListItem[];
const { menu } = useGlobalEntityCreation(true);
const { menu } = useGlobalEntityCreation();
expect(menu.value[0].submenu?.length).toBe(4);
expect(menu.value[1].submenu?.length).toBe(4);
@ -178,7 +104,7 @@ describe('useGlobalEntityCreation', () => {
it('should only handle create-project', () => {
const projectsStore = mockedStore(useProjectsStore);
projectsStore.isTeamProjectFeatureEnabled = true;
const { handleSelect } = useGlobalEntityCreation(true);
const { handleSelect } = useGlobalEntityCreation();
handleSelect('dummy');
expect(projectsStore.createProject).not.toHaveBeenCalled();
});
@ -190,7 +116,7 @@ describe('useGlobalEntityCreation', () => {
projectsStore.canCreateProjects = true;
projectsStore.createProject.mockResolvedValueOnce({ name: 'test', id: '1' } as Project);
const { handleSelect } = useGlobalEntityCreation(true);
const { handleSelect } = useGlobalEntityCreation();
handleSelect('create-project');
await flushPromises();
@ -207,7 +133,7 @@ describe('useGlobalEntityCreation', () => {
projectsStore.canCreateProjects = true;
projectsStore.createProject.mockRejectedValueOnce(new Error('error'));
const { handleSelect } = useGlobalEntityCreation(true);
const { handleSelect } = useGlobalEntityCreation();
handleSelect('create-project');
await flushPromises();
@ -220,7 +146,7 @@ describe('useGlobalEntityCreation', () => {
projectsStore.isTeamProjectFeatureEnabled = true;
const redirect = usePageRedirectionHelper();
const { handleSelect } = useGlobalEntityCreation(true);
const { handleSelect } = useGlobalEntityCreation();
handleSelect('create-project');
expect(redirect.goToUpgrade).toHaveBeenCalled();
@ -237,14 +163,20 @@ describe('useGlobalEntityCreation', () => {
projectsStore.teamProjectsLimit = 10;
settingsStore.isCloudDeployment = true;
const { projectsLimitReachedMessage } = useGlobalEntityCreation(true);
const { projectsLimitReachedMessage, upgradeLabel } = useGlobalEntityCreation();
expect(projectsLimitReachedMessage.value).toContain(
'You have reached the Pro plan limit of 10.',
);
expect(upgradeLabel.value).toBe('Upgrade');
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(
'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 { useRouter } from 'vue-router';
import { useI18n } from '@/composables/useI18n';
@ -25,9 +25,7 @@ type Item = BaseItem & {
submenu?: BaseItem[];
};
export const useGlobalEntityCreation = (
multipleProjects: Ref<boolean> | ComputedRef<boolean> | boolean = true,
) => {
export const useGlobalEntityCreation = () => {
const CREATE_PROJECT_ID = 'create-project';
const settingsStore = useSettingsStore();
@ -77,34 +75,10 @@ export const useGlobalEntityCreation = (
},
},
},
];
}
// 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',
},
},
id: CREATE_PROJECT_ID,
title: 'Project',
disabled: true,
},
];
}
@ -211,7 +185,7 @@ export const useGlobalEntityCreation = (
const projectsLimitReachedMessage = computed(() => {
if (settingsStore.isCloudDeployment) {
return i18n.baseText('projects.create.limitReached', {
return i18n.baseText('projects.create.limitReached.cloud', {
adjustToNumber: projectsStore.teamProjectsLimit,
interpolate: {
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}`);
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.delete": "Delete",
"generic.dontShowAgain": "Don't show again",
"generic.enterprise": "Enterprise",
"generic.executions": "Executions",
"generic.tests": "Tests",
"generic.or": "or",
@ -77,6 +78,7 @@
"generic.ownedByMe": "(You)",
"generic.moreInfo": "More info",
"generic.next": "Next",
"generic.pro": "Pro",
"generic.viewDocs": "View docs",
"about.aboutN8n": "About n8n",
"about.close": "Close",
@ -630,7 +632,7 @@
"credentials.noResults.withSearch.switchToShared.preamble": "some credentials may be",
"credentials.noResults.withSearch.switchToShared.link": "hidden",
"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.text": "All members from {projectName} will have access to this credential.",
"dataDisplay.needHelp": "Need help?",
@ -2329,7 +2331,7 @@
"workflows.concurrentChanges.confirmMessage.cancelButtonText": "Cancel",
"workflows.concurrentChanges.confirmMessage.confirmButtonText": "Overwrite and Save",
"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.text": "All members from {projectName} will have access to this workflow.",
"importCurlModal.title": "Import cURL command",
@ -2571,6 +2573,7 @@
"projects.error.title": "Project error",
"projects.create.limit": "{num} project | {num} projects",
"projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
"projects.create.limitReached.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.link": "View plans",
"projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to",