refactor(editor): Move editor-ui and design-system to frontend dir (no-changelog) (#13564)

This commit is contained in:
Alex Grozav
2025-02-28 14:28:30 +02:00
committed by GitHub
parent 684353436d
commit f5743176e5
1635 changed files with 805 additions and 1079 deletions

View File

@@ -0,0 +1,83 @@
import { createComponentRenderer } from '@/__tests__/render';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { truncate } from '@n8n/utils/string/truncate';
const renderComponent = createComponentRenderer(ProjectCardBadge);
describe('ProjectCardBadge', () => {
it('should show "Personal" badge if there is no homeProject', () => {
const { getByText } = renderComponent({
props: {
resource: {},
personalProject: {},
},
});
expect(getByText('Personal')).toBeVisible();
});
it('should show "Personal" badge if homeProject ID equals personalProject ID', () => {
const { getByText } = renderComponent({
props: {
resource: {
homeProject: {
id: '1',
name: 'John',
},
},
resourceType: 'workflow',
personalProject: {
id: '1',
},
},
});
expect(getByText('Personal')).toBeVisible();
});
it('should show shared with count', () => {
const { getByText } = renderComponent({
props: {
resource: {
sharedWithProjects: [{}, {}, {}],
homeProject: {
id: '1',
name: 'John',
},
},
resourceType: 'workflow',
personalProject: {
id: '1',
},
},
});
expect(getByText('+ 3')).toBeVisible();
});
test.each([
['First Last <email@domain.com>', 'First Last'],
['First Last Third <email@domain.com>', 'First Last Third'],
['First Last Third Fourth <email@domain.com>', 'First Last Third Fourth'],
['<email@domain.com>', 'email@domain.com'],
[' <email@domain.com>', 'email@domain.com'],
['My project', 'My project'],
['MyProject', 'MyProject'],
])('should show the correct owner badge for project name: "%s"', (name, result) => {
const { getByText } = renderComponent({
props: {
resource: {
homeProject: {
id: '1',
name,
},
},
resourceType: 'workflow',
personalProject: {
id: '2',
},
},
});
expect(getByText(truncate(result, 20))).toBeVisible();
});
});

View File

@@ -0,0 +1,189 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { ResourceType } from '@/utils/projects.utils';
import { splitName } from '@/utils/projects.utils';
import type { Project, ProjectIcon as BadgeIcon } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import type { CredentialsResource, WorkflowResource } from '../layouts/ResourcesListLayout.vue';
type Props = {
resource: WorkflowResource | CredentialsResource;
resourceType: ResourceType;
resourceTypeLabel: string;
personalProject: Project | null;
};
const enum ProjectState {
SharedPersonal = 'shared-personal',
SharedOwned = 'shared-owned',
Owned = 'owned',
Personal = 'personal',
Team = 'team',
SharedTeam = 'shared-team',
Unknown = 'unknown',
}
const props = defineProps<Props>();
const i18n = useI18n();
const projectState = computed(() => {
if (
(props.resource.homeProject &&
props.personalProject &&
props.resource.homeProject.id === props.personalProject.id) ||
!props.resource.homeProject
) {
if (props.resource.sharedWithProjects?.length) {
return ProjectState.SharedOwned;
}
return ProjectState.Owned;
} else if (props.resource.homeProject?.type !== ProjectTypes.Team) {
if (props.resource.sharedWithProjects?.length) {
return ProjectState.SharedPersonal;
}
return ProjectState.Personal;
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
if (props.resource.sharedWithProjects?.length) {
return ProjectState.SharedTeam;
}
return ProjectState.Team;
}
return ProjectState.Unknown;
});
const numberOfMembersInHomeTeamProject = computed(
() => props.resource.sharedWithProjects?.length ?? 0,
);
const badgeText = computed(() => {
if (
projectState.value === ProjectState.Owned ||
projectState.value === ProjectState.SharedOwned
) {
return i18n.baseText('projects.menu.personal');
} else {
const { name, email } = splitName(props.resource.homeProject?.name ?? '');
return name ?? email ?? '';
}
});
const badgeIcon = computed<BadgeIcon>(() => {
switch (projectState.value) {
case ProjectState.Owned:
case ProjectState.SharedOwned:
return { type: 'icon', value: 'user' };
case ProjectState.Team:
case ProjectState.SharedTeam:
return props.resource.homeProject?.icon ?? { type: 'icon', value: 'layer-group' };
default:
return { type: 'icon', value: 'layer-group' };
}
});
const badgeTooltip = computed(() => {
switch (projectState.value) {
case ProjectState.SharedOwned:
return i18n.baseText('projects.badge.tooltip.sharedOwned', {
interpolate: {
resourceTypeLabel: props.resourceTypeLabel,
count: numberOfMembersInHomeTeamProject.value,
},
});
case ProjectState.SharedPersonal:
return i18n.baseText('projects.badge.tooltip.sharedPersonal', {
interpolate: {
resourceTypeLabel: props.resourceTypeLabel,
name: badgeText.value,
count: numberOfMembersInHomeTeamProject.value,
},
});
case ProjectState.Personal:
return i18n.baseText('projects.badge.tooltip.personal', {
interpolate: {
resourceTypeLabel: props.resourceTypeLabel,
name: badgeText.value,
},
});
case ProjectState.Team:
return i18n.baseText('projects.badge.tooltip.team', {
interpolate: {
resourceTypeLabel: props.resourceTypeLabel,
name: badgeText.value,
},
});
case ProjectState.SharedTeam:
return i18n.baseText('projects.badge.tooltip.sharedTeam', {
interpolate: {
resourceTypeLabel: props.resourceTypeLabel,
name: badgeText.value,
count: numberOfMembersInHomeTeamProject.value,
},
});
default:
return '';
}
});
</script>
<template>
<N8nTooltip :disabled="!badgeTooltip" placement="top">
<div :class="$style.wrapper" v-bind="$attrs">
<N8nBadge
v-if="badgeText"
:class="[$style.badge, $style.projectBadge]"
theme="tertiary"
bold
data-test-id="card-badge"
>
<ProjectIcon :icon="badgeIcon" :border-less="true" size="mini" />
<span v-n8n-truncate:20>{{ badgeText }}</span>
</N8nBadge>
<N8nBadge
v-if="numberOfMembersInHomeTeamProject"
:class="[$style.badge, $style.countBadge]"
theme="tertiary"
bold
>
+ {{ numberOfMembersInHomeTeamProject }}
</N8nBadge>
</div>
<template #content>
{{ badgeTooltip }}
</template>
</N8nTooltip>
</template>
<style lang="scss" module>
.wrapper {
margin-right: var(--spacing-xs);
}
.badge {
padding: var(--spacing-4xs) var(--spacing-2xs);
background-color: var(--color-background-xlight);
border-color: var(--color-foreground-base);
z-index: 1;
position: relative;
height: 23px;
:global(.n8n-text) {
color: var(--color-text-base);
}
}
.projectBadge {
& > span {
display: flex;
gap: var(--spacing-3xs);
}
}
.countBadge {
margin-left: -5px;
z-index: 0;
position: relative;
height: 23px;
:global(.n8n-text) {
color: var(--color-text-light);
}
}
</style>

View File

@@ -0,0 +1,71 @@
<script lang="ts" setup>
import type { ButtonType } from '@n8n/design-system';
import { N8nIconButton, N8nActionToggle } from '@n8n/design-system';
import { ref } from 'vue';
type Action = {
label: string;
value: string;
disabled: boolean;
};
defineProps<{
actions: Action[];
disabled?: boolean;
type?: ButtonType;
}>();
const emit = defineEmits<{
action: [id: string];
}>();
const actionToggleRef = ref<InstanceType<typeof N8nActionToggle> | null>(null);
defineExpose({
openActionToggle: (isOpen: boolean) => actionToggleRef.value?.openActionToggle(isOpen),
});
</script>
<template>
<div :class="[$style.buttonGroup]">
<slot></slot>
<N8nActionToggle
ref="actionToggleRef"
data-test-id="add-resource"
:actions="actions"
placement="bottom-end"
:teleported="false"
@action="emit('action', $event)"
>
<N8nIconButton
:disabled="disabled"
:class="[$style.buttonGroupDropdown]"
icon="angle-down"
:type="type ?? 'primary'"
/>
</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

@@ -0,0 +1,118 @@
<script lang="ts" setup>
import { ref, computed } from 'vue';
import type { Project, ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
import { useI18n } from '@/composables/useI18n';
type Props = {
currentProject: Project | null;
projects: ProjectListItem[];
};
const props = defineProps<Props>();
const visible = defineModel<boolean>();
const emit = defineEmits<{
confirmDelete: [value?: string];
}>();
const locale = useI18n();
const selectedProject = ref<ProjectSharingData | null>(null);
const operation = ref<'transfer' | 'wipe' | null>(null);
const wipeConfirmText = ref('');
const isValid = computed(() => {
if (operation.value === 'transfer') {
return !!selectedProject.value;
}
if (operation.value === 'wipe') {
return (
wipeConfirmText.value ===
locale.baseText('projects.settings.delete.question.wipe.placeholder')
);
}
return false;
});
const onDelete = () => {
if (!isValid.value) {
return;
}
if (operation.value === 'wipe') {
selectedProject.value = null;
}
emit('confirmDelete', selectedProject.value?.id);
};
</script>
<template>
<el-dialog
v-model="visible"
:title="
locale.baseText('projects.settings.delete.title', {
interpolate: { projectName: props.currentProject?.name ?? '' },
})
"
width="500"
>
<n8n-text color="text-base">{{ locale.baseText('projects.settings.delete.message') }}</n8n-text>
<div class="pt-l">
<el-radio
:model-value="operation"
label="transfer"
class="mb-s"
@update:model-value="operation = 'transfer'"
>
<n8n-text color="text-dark">{{
locale.baseText('projects.settings.delete.question.transfer.label')
}}</n8n-text>
</el-radio>
<div v-if="operation === 'transfer'" :class="$style.operation">
<n8n-text color="text-dark">{{
locale.baseText('projects.settings.delete.question.transfer.title')
}}</n8n-text>
<ProjectSharing
v-model="selectedProject"
class="pt-2xs"
:projects="props.projects"
:empty-options-text="locale.baseText('projects.sharing.noMatchingProjects')"
/>
</div>
<el-radio
:model-value="operation"
label="wipe"
class="mb-s"
@update:model-value="operation = 'wipe'"
>
<n8n-text color="text-dark">{{
locale.baseText('projects.settings.delete.question.wipe.label')
}}</n8n-text>
</el-radio>
<div v-if="operation === 'wipe'" :class="$style.operation">
<n8n-input-label :label="locale.baseText('projects.settings.delete.question.wipe.title')">
<n8n-input
v-model="wipeConfirmText"
:placeholder="locale.baseText('projects.settings.delete.question.wipe.placeholder')"
/>
</n8n-input-label>
</div>
</div>
<template #footer>
<N8nButton
type="danger"
native-type="button"
:disabled="!isValid"
data-test-id="project-settings-delete-confirm-button"
@click.stop.prevent="onDelete"
>{{ locale.baseText('projects.settings.danger.deleteProject') }}</N8nButton
>
</template>
</el-dialog>
</template>
<style lang="scss" module>
.operation {
padding: 0 0 var(--spacing-l) var(--spacing-l);
}
</style>

View File

@@ -0,0 +1,199 @@
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createTestProject } from '@/__tests__/data/projects';
import * as router from 'vue-router';
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
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,
}),
};
});
const projectTabsSpy = vi.fn().mockReturnValue({
render: vi.fn(),
});
const renderComponent = createComponentRenderer(ProjectHeader, {
global: {
stubs: {
ProjectTabs: projectTabsSpy,
},
},
});
let route: ReturnType<typeof router.useRoute>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
describe('ProjectHeader', () => {
beforeEach(() => {
createTestingPinia();
route = router.useRoute();
projectsStore = mockedStore(useProjectsStore);
projectsStore.teamProjectsLimit = -1;
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render the correct icon', async () => {
const { container, rerender } = renderComponent();
expect(container.querySelector('.fa-home')).toBeVisible();
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
await rerender({});
expect(container.querySelector('.fa-user')).toBeVisible();
const projectName = 'My Project';
projectsStore.currentProject = { name: projectName } as Project;
await rerender({});
expect(container.querySelector('.fa-layer-group')).toBeVisible();
});
it('should render the correct title and subtitle', async () => {
const { getByText, queryByText, rerender } = renderComponent();
const subtitle = 'All the workflows, credentials and executions you have access to';
expect(getByText('Overview')).toBeVisible();
expect(getByText(subtitle)).toBeVisible();
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
await rerender({});
expect(getByText('Personal')).toBeVisible();
expect(queryByText(subtitle)).not.toBeInTheDocument();
const projectName = 'My Project';
projectsStore.currentProject = { name: projectName } as Project;
await rerender({});
expect(getByText(projectName)).toBeVisible();
expect(queryByText(subtitle)).not.toBeInTheDocument();
});
it('should overwrite default subtitle with slot', () => {
const defaultSubtitle = 'All the workflows, credentials and executions you have access to';
const subtitle = 'Custom subtitle';
const { getByText, queryByText } = renderComponent({
slots: {
subtitle,
},
});
expect(getByText(subtitle)).toBeVisible();
expect(queryByText(defaultSubtitle)).not.toBeInTheDocument();
});
it('should render ProjectTabs Settings if project is team project and user has update scope', () => {
route.params.projectId = '123';
projectsStore.currentProject = createTestProject({ scopes: ['project:update'] });
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
{
'show-settings': true,
},
null,
);
});
it('should render ProjectTabs without Settings if no project update permission', () => {
route.params.projectId = '123';
projectsStore.currentProject = createTestProject({ scopes: ['project:read'] });
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
{
'show-settings': false,
},
null,
);
});
it('should render ProjectTabs without Settings if project is not team project', () => {
route.params.projectId = '123';
projectsStore.currentProject = createTestProject({
type: ProjectTypes.Personal,
scopes: ['project:update'],
});
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
{
'show-settings': false,
},
null,
);
});
it('should create a workflow', async () => {
const project = createTestProject({
scopes: ['workflow:create'],
});
projectsStore.currentProject = project;
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 () => {
projectsStore.currentProject = createTestProject({ type: ProjectTypes.Personal });
vi.spyOn(router, 'useRoute').mockReturnValueOnce({
name: VIEWS.PROJECT_SETTINGS,
} as RouteLocationNormalizedLoadedGeneric);
const { queryByTestId } = renderComponent();
expect(queryByTestId('add-resource-buttons')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { N8nButton, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@/composables/useI18n';
import { type ProjectIcon, ProjectTypes } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { getResourcePermissions } from '@/permissions';
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 sourceControlStore = useSourceControlStore();
const headerIcon = computed((): ProjectIcon => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (projectsStore.currentProject?.name) {
return projectsStore.currentProject.icon ?? { type: 'icon', value: 'layer-group' };
} else {
return { type: 'icon', value: 'home' };
}
});
const projectName = computed(() => {
if (!projectsStore.currentProject) {
return i18n.baseText('projects.menu.overview');
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
} else {
return projectsStore.currentProject.name;
}
});
const projectPermissions = computed(
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
);
const showSettings = computed(
() =>
!!route?.params?.projectId &&
!!projectPermissions.value.update &&
projectsStore.currentProject?.type === ProjectTypes.Team,
);
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
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',
icon: sourceControlStore.preferences.branchReadOnly ? 'lock' : undefined,
size: 'mini' as const,
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);
};
</script>
<template>
<div>
<div :class="$style.projectHeader">
<div :class="$style.projectDetails">
<ProjectIcon :icon="headerIcon" :border-less="true" size="medium" />
<div :class="$style.headerActions">
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
<N8nText color="text-light">
<slot name="subtitle">
<span v-if="!projectsStore.currentProject">{{
i18n.baseText('projects.header.subtitle')
}}</span>
</slot>
</N8nText>
</div>
</div>
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
<N8nTooltip
:disabled="!sourceControlStore.preferences.branchReadOnly"
:content="i18n.baseText('readOnlyEnv.cantAdd.any')"
>
<ProjectCreateResource
data-test-id="add-resource-buttons"
:actions="menu"
:disabled="sourceControlStore.preferences.branchReadOnly"
@action="onSelect"
>
<N8nButton
data-test-id="add-resource-workflow"
v-bind="createWorkflowButton"
@click="onSelect(ACTION_TYPES.WORKFLOW)"
/>
</ProjectCreateResource>
</N8nTooltip>
</div>
</div>
<div :class="$style.actions">
<ProjectTabs :show-settings="showSettings" />
</div>
</div>
</template>
<style lang="scss" module>
.projectHeader,
.projectDescription {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--spacing-m);
min-height: var(--spacing-3xl);
}
.projectDetails {
display: flex;
align-items: center;
}
.actions {
padding: var(--spacing-2xs) 0 var(--spacing-l);
}
@include mixins.breakpoint('xs-only') {
.projectHeader {
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
}
.headerActions {
margin-left: auto;
}
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import type { ProjectIcon } from '@/types/projects.types';
type Props = {
icon: ProjectIcon;
size?: 'mini' | 'small' | 'medium' | 'large';
round?: boolean;
borderLess?: boolean;
color?: 'text-light' | 'text-base' | 'text-dark';
};
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
round: false,
borderLess: false,
color: 'text-base',
});
</script>
<template>
<div
:class="[
$style.container,
$style[props.size],
{ [$style.round]: props.round, [$style.borderless]: props.borderLess },
]"
>
<N8nIcon
v-if="icon.type === 'icon'"
:icon="icon.value"
:class="$style.icon"
:color="color"
></N8nIcon>
<N8nText v-else-if="icon.type === 'emoji'" color="text-light" :class="$style.emoji">
{{ icon.value }}
</N8nText>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: center;
justify-content: center;
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-light);
border-radius: var(--border-radius-base);
&.round {
border-radius: 50%;
}
&.borderless {
border: none;
}
}
.mini {
width: var(--spacing-xs);
height: var(--spacing-xs);
.icon {
font-size: var(--font-size-2xs);
}
.emoji {
font-size: var(--font-size-3xs);
}
}
.small {
min-width: var(--spacing-l);
height: var(--spacing-l);
.emoji {
font-size: var(--font-size-2xs);
}
}
.medium {
min-width: var(--spacing-xl);
height: var(--spacing-xl);
.emoji {
font-size: var(--font-size-xs);
}
}
.large {
// Making this in line with user avatar size
min-width: 40px;
height: 40px;
.emoji {
font-size: var(--font-size-s);
}
}
</style>

View File

@@ -0,0 +1,241 @@
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestWorkflow } from '@/__tests__/mocks';
import { createProjectListItem } from '@/__tests__/data/projects';
import { getDropdownItems, mockedStore } from '@/__tests__/utils';
import type { MockedStore } from '@/__tests__/utils';
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useProjectsStore } from '@/stores/projects.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCredentialsStore } from '@/stores/credentials.store';
const renderComponent = createComponentRenderer(ProjectMoveResourceModal, {
pinia: createTestingPinia(),
global: {
stubs: {
Modal: {
template:
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
},
},
},
});
let telemetry: ReturnType<typeof useTelemetry>;
let projectsStore: MockedStore<typeof useProjectsStore>;
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let credentialsStore: MockedStore<typeof useCredentialsStore>;
describe('ProjectMoveResourceModal', () => {
beforeEach(() => {
vi.clearAllMocks();
telemetry = useTelemetry();
projectsStore = mockedStore(useProjectsStore);
workflowsStore = mockedStore(useWorkflowsStore);
credentialsStore = mockedStore(useCredentialsStore);
});
it('should send telemetry when mounted', async () => {
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
projectsStore.availableProjects = [createProjectListItem()];
workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow());
const props = {
modalName: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resourceType: 'workflow',
resourceTypeLabel: 'workflow',
resource: {
id: '1',
name: 'My Workflow',
homeProject: {
id: '2',
name: 'My Project',
},
},
},
};
renderComponent({ props });
expect(telemetryTrackSpy).toHaveBeenCalledWith(
'User clicked to move a workflow',
expect.objectContaining({ workflow_id: '1' }),
);
});
it('should show no available projects message', async () => {
projectsStore.availableProjects = [];
workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow());
const props = {
modalName: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resourceType: 'workflow',
resourceTypeLabel: 'workflow',
resource: {
id: '1',
name: 'My Workflow',
homeProject: {
id: '2',
name: 'My Project',
},
},
},
};
const { getByText } = renderComponent({ props });
expect(getByText(/Currently there are not any projects or users available/)).toBeVisible();
});
it('should not hide project select if filter has no result', async () => {
const projects = Array.from({ length: 5 }, createProjectListItem);
projectsStore.availableProjects = projects;
const props = {
modalName: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resourceType: 'workflow',
resourceTypeLabel: 'Workflow',
resource: {
id: '1',
name: 'My Workflow',
homeProject: {
id: projects[0].id,
name: projects[0].name,
},
},
},
};
const { getByTestId, getByRole } = renderComponent({ props });
const projectSelect = getByTestId('project-move-resource-modal-select');
const projectSelectInput: HTMLInputElement = getByRole('combobox');
expect(projectSelectInput).toBeVisible();
expect(projectSelect).toBeVisible();
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(projects.length - 1);
await userEvent.click(projectSelectInput);
await userEvent.type(projectSelectInput, 'non-existing project');
expect(projectSelect).toBeVisible();
});
it('should not load workflow if the resource is a credential', async () => {
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
projectsStore.availableProjects = [createProjectListItem()];
const props = {
modalName: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resourceType: 'credential',
resourceTypeLabel: 'credential',
resource: {
id: '1',
name: 'My credential',
homeProject: {
id: '2',
name: 'My Project',
},
},
},
};
const { getByText } = renderComponent({ props });
expect(telemetryTrackSpy).toHaveBeenCalledWith(
'User clicked to move a credential',
expect.objectContaining({ credential_id: '1' }),
);
expect(workflowsStore.fetchWorkflow).not.toHaveBeenCalled();
expect(getByText(/Moving will remove any existing sharing for this credential/)).toBeVisible();
});
it('should send credential IDs when workflow moved with used credentials and checkbox checked', async () => {
const destinationProject = createProjectListItem();
const currentProjectId = '123';
const movedWorkflow = {
...createTestWorkflow(),
usedCredentials: [
{
id: '1',
name: 'PG Credential',
credentialType: 'postgres',
currentUserHasAccess: true,
},
{
id: '2',
name: 'Notion Credential',
credentialType: 'notion',
currentUserHasAccess: true,
},
],
};
projectsStore.currentProjectId = currentProjectId;
projectsStore.availableProjects = [destinationProject];
workflowsStore.fetchWorkflow.mockResolvedValueOnce(movedWorkflow);
credentialsStore.fetchAllCredentials.mockResolvedValueOnce([
{
id: '1',
name: 'PG Credential',
createdAt: '2021-01-01T00:00:00Z',
updatedAt: '2021-01-01T00:00:00Z',
type: 'postgres',
scopes: ['credential:share'],
isManaged: false,
},
{
id: '2',
name: 'Notion Credential',
createdAt: '2021-01-01T00:00:00Z',
updatedAt: '2021-01-01T00:00:00Z',
type: 'notion',
scopes: ['credential:share'],
isManaged: false,
},
{
id: '3',
name: 'Another Credential',
createdAt: '2021-01-01T00:00:00Z',
updatedAt: '2021-01-01T00:00:00Z',
type: 'another',
scopes: ['credential:share'],
isManaged: false,
},
]);
const props = {
modalName: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resourceType: 'workflow',
resourceTypeLabel: 'workflow',
resource: movedWorkflow,
},
};
const { getByTestId, getByText } = renderComponent({ props });
expect(getByTestId('project-move-resource-modal-button')).toBeDisabled();
expect(getByText(/Moving will remove any existing sharing for this workflow/)).toBeVisible();
const projectSelect = getByTestId('project-move-resource-modal-select');
expect(projectSelect).toBeVisible();
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
await userEvent.click(projectSelectDropdownItems[0]);
expect(getByTestId('project-move-resource-modal-button')).toBeEnabled();
await userEvent.click(getByTestId('project-move-resource-modal-checkbox-all'));
await userEvent.click(getByTestId('project-move-resource-modal-button'));
expect(projectsStore.moveResourceToProject).toHaveBeenCalledWith(
'workflow',
movedWorkflow.id,
destinationProject.id,
['1', '2'],
);
});
});

View File

@@ -0,0 +1,349 @@
<script lang="ts" setup>
import { ref, computed, onMounted, h } from 'vue';
import { truncate } from '@n8n/utils/string/truncate';
import type { ICredentialsResponse, IUsedCredential, IWorkflowDb } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
import { useProjectsStore } from '@/stores/projects.store';
import Modal from '@/components/Modal.vue';
import { VIEWS } from '@/constants';
import { ResourceType, splitName } from '@/utils/projects.utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { ProjectTypes } from '@/types/projects.types';
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
import ProjectMoveResourceModalCredentialsList from '@/components/Projects/ProjectMoveResourceModalCredentialsList.vue';
import { useToast } from '@/composables/useToast';
import { getResourcePermissions } from '@/permissions';
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
import type { EventBus } from '@n8n/utils/event-bus';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCredentialsStore } from '@/stores/credentials.store';
const props = defineProps<{
modalName: string;
data: {
resource: IWorkflowDb | ICredentialsResponse;
resourceType: ResourceType;
resourceTypeLabel: string;
eventBus?: EventBus;
};
}>();
const i18n = useI18n();
const uiStore = useUIStore();
const toast = useToast();
const projectsStore = useProjectsStore();
const workflowsStore = useWorkflowsStore();
const credentialsStore = useCredentialsStore();
const telemetry = useTelemetry();
const filter = ref('');
const projectId = ref<string | null>(null);
const shareUsedCredentials = ref(false);
const usedCredentials = ref<IUsedCredential[]>([]);
const allCredentials = ref<ICredentialsResponse[]>([]);
const shareableCredentials = computed(() =>
allCredentials.value.filter(
(credential) =>
getResourcePermissions(credential.scopes).credential.share &&
usedCredentials.value.find((uc) => uc.id === credential.id),
),
);
const unShareableCredentials = computed(() =>
usedCredentials.value.reduce(
(acc, uc) => {
const credential = credentialsStore.getCredentialById(uc.id);
const credentialPermissions = getResourcePermissions(credential?.scopes).credential;
if (!credentialPermissions.share) {
if (credentialPermissions.read) {
acc.push(credential);
} else {
acc.push(uc);
}
}
return acc;
},
[] as Array<IUsedCredential | ICredentialsResponse>,
),
);
const homeProjectName = computed(
() => processProjectName(props.data.resource.homeProject?.name ?? '') ?? '',
);
const availableProjects = computed(() =>
sortByProperty(
'name',
projectsStore.availableProjects.filter(
(p) =>
p.id !== props.data.resource.homeProject?.id &&
(!p.scopes || getResourcePermissions(p.scopes)[props.data.resourceType].create),
),
),
);
const filteredProjects = computed(() =>
availableProjects.value.filter((p) => p.name?.toLowerCase().includes(filter.value.toLowerCase())),
);
const selectedProject = computed(() =>
availableProjects.value.find((p) => p.id === projectId.value),
);
const isResourceInTeamProject = computed(() => isHomeProjectTeam(props.data.resource));
const isResourceWorkflow = computed(() => props.data.resourceType === ResourceType.Workflow);
const targetProjectName = computed(() => {
const { name, email } = splitName(selectedProject.value?.name ?? '');
return truncate(name ?? email ?? '', 25);
});
const resourceName = computed(() => truncate(props.data.resource.name, 25));
const isHomeProjectTeam = (resource: IWorkflowDb | ICredentialsResponse) =>
resource.homeProject?.type === ProjectTypes.Team;
const processProjectName = (projectName: string) => {
const { name, email } = splitName(projectName);
return name ?? email;
};
const updateProject = (value: string) => {
projectId.value = value;
};
const closeModal = () => {
uiStore.closeModal(props.modalName);
};
const setFilter = (query: string) => {
filter.value = query;
};
const moveResource = async () => {
if (!selectedProject.value) return;
try {
await projectsStore.moveResourceToProject(
props.data.resourceType,
props.data.resource.id,
selectedProject.value.id,
shareUsedCredentials.value ? shareableCredentials.value.map((c) => c.id) : undefined,
);
closeModal();
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
[`${props.data.resourceType}_id`]: props.data.resource.id,
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
});
toast.showToast({
title: i18n.baseText('projects.move.resource.success.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
resourceName: resourceName.value,
targetProjectName: targetProjectName.value,
},
}),
message: h(ProjectMoveSuccessToastMessage, {
routeName: isResourceWorkflow.value ? VIEWS.PROJECTS_WORKFLOWS : VIEWS.PROJECTS_CREDENTIALS,
resourceType: props.data.resourceType,
targetProject: selectedProject.value,
isShareCredentialsChecked: shareUsedCredentials.value,
areAllUsedCredentialsShareable:
shareableCredentials.value.length === usedCredentials.value.length,
}),
type: 'success',
duration: 8000,
});
if (props.data.eventBus) {
props.data.eventBus.emit('resource-moved', {
resourceId: props.data.resource.id,
resourceType: props.data.resourceType,
targetProjectId: selectedProject.value.id,
});
}
} catch (error) {
toast.showError(
error.message,
i18n.baseText('projects.move.resource.error.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
resourceName: resourceName.value,
},
}),
);
}
};
onMounted(async () => {
telemetry.track(`User clicked to move a ${props.data.resourceType}`, {
[`${props.data.resourceType}_id`]: props.data.resource.id,
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
});
if (isResourceWorkflow.value) {
const [workflow, credentials] = await Promise.all([
workflowsStore.fetchWorkflow(props.data.resource.id),
credentialsStore.fetchAllCredentials(),
]);
usedCredentials.value = workflow?.usedCredentials ?? [];
allCredentials.value = credentials ?? [];
}
});
</script>
<template>
<Modal width="500px" :name="props.modalName" data-test-id="project-move-resource-modal">
<template #header>
<N8nHeading tag="h2" size="xlarge" class="mb-m pr-s">
{{
i18n.baseText('projects.move.resource.modal.title', {
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
})
}}
</N8nHeading>
<N8nText>
<i18n-t keypath="projects.move.resource.modal.message">
<template #resourceName
><strong>{{ resourceName }}</strong></template
>
<template v-if="isResourceInTeamProject" #inTeamProject>
<i18n-t keypath="projects.move.resource.modal.message.team">
<template #resourceHomeProjectName
><strong>{{ homeProjectName }}</strong></template
>
</i18n-t>
</template>
<template v-else #inPersonalProject>
<i18n-t keypath="projects.move.resource.modal.message.personal">
<template #resourceHomeProjectName
><strong>{{ homeProjectName }}</strong></template
>
</i18n-t>
</template>
</i18n-t>
</N8nText>
</template>
<template #content>
<div v-if="availableProjects.length">
<N8nSelect
class="mr-2xs mb-xs"
:model-value="projectId"
:filterable="true"
:filter-method="setFilter"
:placeholder="i18n.baseText('projects.move.resource.modal.selectPlaceholder')"
data-test-id="project-move-resource-modal-select"
@update:model-value="updateProject"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
<N8nOption
v-for="p in filteredProjects"
:key="p.id"
:value="p.id"
:label="p.name"
></N8nOption>
</N8nSelect>
<N8nText>
<i18n-t keypath="projects.move.resource.modal.message.sharingNote">
<template #note
><strong>{{
i18n.baseText('projects.move.resource.modal.message.note')
}}</strong></template
>
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
</i18n-t>
<span
v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0"
:class="$style.textBlock"
>
{{
i18n.baseText('projects.move.resource.modal.message.sharingInfo', {
adjustToNumber: props.data.resource.sharedWithProjects?.length,
interpolate: {
numberOfProjects: props.data.resource.sharedWithProjects?.length ?? 0,
},
})
}}</span
>
<N8nCheckbox
v-if="shareableCredentials.length"
v-model="shareUsedCredentials"
:class="$style.textBlock"
data-test-id="project-move-resource-modal-checkbox-all"
>
<i18n-t keypath="projects.move.resource.modal.message.usedCredentials">
<template #usedCredentials>
<N8nTooltip placement="top">
<span :class="$style.tooltipText">
{{
i18n.baseText('projects.move.resource.modal.message.usedCredentials.number', {
adjustToNumber: shareableCredentials.length,
interpolate: { number: shareableCredentials.length },
})
}}
</span>
<template #content>
<ProjectMoveResourceModalCredentialsList
:current-project-id="projectsStore.currentProjectId"
:credentials="shareableCredentials"
/>
</template>
</N8nTooltip>
</template>
</i18n-t>
</N8nCheckbox>
<span v-if="unShareableCredentials.length" :class="$style.textBlock">
<i18n-t keypath="projects.move.resource.modal.message.unAccessibleCredentials.note">
<template #credentials>
<N8nTooltip placement="top">
<span :class="$style.tooltipText">{{
i18n.baseText('projects.move.resource.modal.message.unAccessibleCredentials')
}}</span>
<template #content>
<ProjectMoveResourceModalCredentialsList
:current-project-id="projectsStore.currentProjectId"
:credentials="unShareableCredentials"
/>
</template>
</N8nTooltip>
</template>
</i18n-t>
</span>
</N8nText>
</div>
<N8nText v-else>{{
i18n.baseText('projects.move.resource.modal.message.noProjects', {
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
})
}}</N8nText>
</template>
<template #footer>
<div :class="$style.buttons">
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton
:disabled="!projectId"
type="primary"
data-test-id="project-move-resource-modal-button"
@click="moveResource"
>
{{
i18n.baseText('projects.move.resource.modal.button', {
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
})
}}
</N8nButton>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.buttons {
display: flex;
justify-content: flex-end;
}
.textBlock {
display: block;
margin-top: var(--spacing-s);
}
.tooltipText {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { RouteLocationNamedRaw } from 'vue-router';
import type { ICredentialsResponse, IUsedCredential } from '@/Interface';
import { getResourcePermissions } from '@/permissions';
import { VIEWS } from '@/constants';
const props = withDefaults(
defineProps<{
credentials?: Array<ICredentialsResponse | IUsedCredential>;
currentProjectId?: string;
}>(),
{
credentials: () => [],
currentProjectId: '',
},
);
const isCredentialReadable = (credential: ICredentialsResponse | IUsedCredential) =>
'scopes' in credential ? getResourcePermissions(credential.scopes).credential.read : false;
const getCredentialRouterLocation = (
credential: ICredentialsResponse | IUsedCredential,
): RouteLocationNamedRaw => {
const isSharedWithCurrentProject = credential.sharedWithProjects?.find(
(p) => p.id === props.currentProjectId,
);
const params: {
projectId?: string;
credentialId: string;
} = { credentialId: credential.id };
if (isSharedWithCurrentProject ?? credential.homeProject?.id) {
params.projectId = isSharedWithCurrentProject
? props.currentProjectId
: credential.homeProject?.id;
}
return {
name: isSharedWithCurrentProject ? VIEWS.PROJECTS_CREDENTIALS : VIEWS.CREDENTIALS,
params,
};
};
</script>
<template>
<ul :class="$style.credentialsList">
<li v-for="credential in props.credentials" :key="credential.id">
<router-link
v-if="isCredentialReadable(credential)"
target="_blank"
:to="getCredentialRouterLocation(credential)"
>
{{ credential.name }}
</router-link>
<span v-else>{{ credential.name }}</span>
</li>
</ul>
</template>
<style module lang="scss">
.credentialsList {
list-style-type: none;
padding: 0;
margin: 0;
li {
padding: 0 0 var(--spacing-3xs);
&:last-child {
padding-bottom: 0;
}
}
}
</style>

View File

@@ -0,0 +1,95 @@
import { createComponentRenderer } from '@/__tests__/render';
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
import { ResourceType } from '@/utils/projects.utils';
import { VIEWS } from '@/constants';
import { ProjectTypes } from '@/types/projects.types';
const renderComponent = createComponentRenderer(ProjectMoveSuccessToastMessage, {
global: {
stubs: {
'router-link': {
template: '<a href="#"><slot /></a>',
},
},
},
});
describe('ProjectMoveSuccessToastMessage', () => {
it('should show credentials message if the resource is a workflow', async () => {
const props = {
routeName: VIEWS.PROJECTS_WORKFLOWS,
resourceType: ResourceType.Workflow,
targetProject: {
id: '2',
name: 'My Project',
},
isShareCredentialsChecked: false,
areAllUsedCredentialsShareable: false,
};
const { getByText } = renderComponent({ props });
expect(getByText(/The workflow's credentials were not shared/)).toBeInTheDocument();
});
it('should show all credentials shared message if the resource is a workflow', async () => {
const props = {
routeName: VIEWS.PROJECTS_WORKFLOWS,
resourceType: ResourceType.Workflow,
targetProject: {
id: '2',
name: 'My Project',
},
isShareCredentialsChecked: true,
areAllUsedCredentialsShareable: true,
};
const { getByText } = renderComponent({ props });
expect(getByText(/The workflow's credentials were shared/)).toBeInTheDocument();
});
it('should show not all credentials shared message if the resource is a workflow', async () => {
const props = {
routeName: VIEWS.PROJECTS_WORKFLOWS,
resourceType: ResourceType.Workflow,
targetProject: {
id: '2',
name: 'My Project',
},
isShareCredentialsChecked: true,
areAllUsedCredentialsShareable: false,
};
const { getByText } = renderComponent({ props });
expect(getByText(/Due to missing permissions/)).toBeInTheDocument();
});
it('should show link if the target project type is team project', async () => {
const props = {
routeName: VIEWS.PROJECTS_WORKFLOWS,
resourceType: ResourceType.Workflow,
targetProject: {
id: '2',
name: 'Team Project',
type: ProjectTypes.Team,
},
isShareCredentialsChecked: false,
areAllUsedCredentialsShareable: false,
};
const { getByRole } = renderComponent({ props });
expect(getByRole('link')).toBeInTheDocument();
});
it('should show only general if the resource is credential and moved to a personal project', async () => {
const props = {
routeName: VIEWS.PROJECTS_WORKFLOWS,
resourceType: ResourceType.Credential,
targetProject: {
id: '2',
name: 'Personal Project',
type: ProjectTypes.Personal,
},
isShareCredentialsChecked: false,
areAllUsedCredentialsShareable: false,
};
const { queryByText, queryByRole } = renderComponent({ props });
expect(queryByText(/The workflow's credentials were not shared/)).not.toBeInTheDocument();
expect(queryByRole('link')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { truncate } from '@n8n/utils/string/truncate';
import { ResourceType, splitName } from '@/utils/projects.utils';
import type { ProjectListItem } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{
routeName: string;
resourceType: ResourceType;
targetProject: ProjectListItem;
isShareCredentialsChecked: boolean;
areAllUsedCredentialsShareable: boolean;
}>();
const i18n = useI18n();
const isWorkflow = computed(() => props.resourceType === ResourceType.Workflow);
const isTargetProjectTeam = computed(() => props.targetProject.type === ProjectTypes.Team);
const targetProjectName = computed(() => {
const { name, email } = splitName(props.targetProject?.name ?? '');
return truncate(name ?? email ?? '', 25);
});
</script>
<template>
<div>
<N8nText v-if="isWorkflow" tag="p" class="pt-xs">
<span v-if="props.isShareCredentialsChecked && props.areAllUsedCredentialsShareable">{{
i18n.baseText('projects.move.resource.success.message.workflow.withAllCredentials')
}}</span>
<span v-else-if="props.isShareCredentialsChecked">{{
i18n.baseText('projects.move.resource.success.message.workflow.withSomeCredentials')
}}</span>
<span v-else>{{ i18n.baseText('projects.move.resource.success.message.workflow') }}</span>
</N8nText>
<p v-if="isTargetProjectTeam" class="pt-s">
<router-link
:to="{
name: props.routeName,
params: { projectId: props.targetProject.id },
}"
>
{{
i18n.baseText('projects.move.resource.success.link', {
interpolate: { targetProjectName },
})
}}
</router-link>
</p>
</div>
</template>

View File

@@ -0,0 +1,178 @@
import { createRouter, createMemoryHistory } from 'vue-router';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createProjectListItem } from '@/__tests__/data/projects';
import ProjectsNavigation from '@/components/Projects/ProjectNavigation.vue';
import { useProjectsStore } from '@/stores/projects.store';
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router');
const push = vi.fn();
return {
...actual,
useRouter: () => ({
push,
}),
RouterLink: {
template: '<a><slot /></a>',
},
};
});
vi.mock('@/composables/useToast', () => {
const showMessage = vi.fn();
const showError = vi.fn();
return {
useToast: () => ({
showMessage,
showError,
}),
};
});
vi.mock('@/composables/usePageRedirectionHelper', () => {
const goToUpgrade = vi.fn();
return {
usePageRedirectionHelper: () => ({
goToUpgrade,
}),
};
});
vi.mock('is-emoji-supported', () => ({
isEmojiSupported: () => true,
}));
const renderComponent = createComponentRenderer(ProjectsNavigation, {
global: {
plugins: [
createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/',
name: 'home',
component: { template: '<div>Home</div>' },
},
],
}),
],
},
});
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
const personalProjects = Array.from({ length: 3 }, createProjectListItem);
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
describe('ProjectsNavigation', () => {
beforeEach(() => {
createTestingPinia();
projectsStore = mockedStore(useProjectsStore);
});
it('should not throw an error', () => {
projectsStore.teamProjectsLimit = -1;
expect(() => {
renderComponent({
props: {
collapsed: false,
},
});
}).not.toThrow();
});
it('should show "Projects" title and Personal project when the feature is enabled', async () => {
projectsStore.teamProjectsLimit = -1;
projectsStore.myProjects = [...personalProjects, ...teamProjects];
const { getByRole, getAllByTestId, getByTestId } = renderComponent({
props: {
collapsed: false,
},
});
expect(getByRole('heading', { level: 3, name: 'Projects' })).toBeVisible();
expect(getByTestId('project-personal-menu-item')).toBeVisible();
expect(getByTestId('project-personal-menu-item').querySelector('svg')).toBeVisible();
expect(getAllByTestId('project-menu-item')).toHaveLength(teamProjects.length);
});
it('should not show "Projects" title when the menu is collapsed', async () => {
projectsStore.teamProjectsLimit = -1;
const { queryByRole } = renderComponent({
props: {
collapsed: true,
},
});
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
});
it('should not show "Projects" title when the feature is not enabled', async () => {
projectsStore.teamProjectsLimit = 0;
const { queryByRole } = renderComponent({
props: {
collapsed: false,
},
});
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
});
it('should show project icons when the menu is collapsed', async () => {
projectsStore.teamProjectsLimit = -1;
const { getByTestId } = renderComponent({
props: {
collapsed: true,
},
});
expect(getByTestId('project-personal-menu-item')).toBeVisible();
expect(getByTestId('project-personal-menu-item').querySelector('svg')).toBeInTheDocument();
});
it('should not show add first project button if there are projects already', async () => {
projectsStore.teamProjectsLimit = -1;
projectsStore.myProjects = [...teamProjects];
const { queryByTestId } = renderComponent({
props: {
collapsed: false,
},
});
expect(queryByTestId('add-first-project-button')).not.toBeInTheDocument();
});
it('should not show project plus button and add first project button if user cannot create projects', async () => {
projectsStore.teamProjectsLimit = 0;
const { queryByTestId } = renderComponent({
props: {
collapsed: false,
},
});
expect(queryByTestId('project-plus-button')).not.toBeInTheDocument();
expect(queryByTestId('add-first-project-button')).not.toBeInTheDocument();
});
it('should show project plus button and add first project button if user can create projects', async () => {
projectsStore.teamProjectsLimit = -1;
const { getByTestId } = renderComponent({
props: {
collapsed: false,
},
});
expect(getByTestId('project-plus-button')).toBeVisible();
expect(getByTestId('add-first-project-button')).toBeVisible();
});
});

View File

@@ -0,0 +1,210 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { IMenuItem } from '@n8n/design-system/types';
import { useI18n } from '@/composables/useI18n';
import { VIEWS } from '@/constants';
import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectListItem } from '@/types/projects.types';
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
type Props = {
collapsed: boolean;
planName?: string;
};
const props = defineProps<Props>();
const locale = useI18n();
const projectsStore = useProjectsStore();
const globalEntityCreation = useGlobalEntityCreation();
const isCreatingProject = computed(() => globalEntityCreation.isCreatingProject.value);
const displayProjects = computed(() => globalEntityCreation.displayProjects.value);
const home = computed<IMenuItem>(() => ({
id: 'home',
label: locale.baseText('projects.menu.overview'),
icon: 'home',
route: {
to: { name: VIEWS.HOMEPAGE },
},
}));
const getProjectMenuItem = (project: ProjectListItem) => ({
id: project.id,
label: project.name,
icon: project.icon,
route: {
to: {
name: VIEWS.PROJECTS_WORKFLOWS,
params: { projectId: project.id },
},
},
});
const personalProject = computed<IMenuItem>(() => ({
id: projectsStore.personalProject?.id ?? '',
label: locale.baseText('projects.menu.personal'),
icon: 'user',
route: {
to: {
name: VIEWS.PROJECTS_WORKFLOWS,
params: { projectId: projectsStore.personalProject?.id },
},
},
}));
const showAddFirstProject = computed(
() => projectsStore.isTeamProjectFeatureEnabled && !displayProjects.value.length,
);
</script>
<template>
<div :class="$style.projects">
<ElMenu :collapse="props.collapsed" class="home">
<N8nMenuItem
:item="home"
:compact="props.collapsed"
:active-tab="projectsStore.projectNavActiveId"
mode="tabs"
data-test-id="project-home-menu-item"
/>
</ElMenu>
<hr v-if="projectsStore.isTeamProjectFeatureEnabled" class="mt-m mb-m" />
<N8nText
v-if="!props.collapsed && projectsStore.isTeamProjectFeatureEnabled"
:class="[$style.projectsLabel]"
tag="h3"
bold
>
<span>{{ locale.baseText('projects.menu.title') }}</span>
<N8nButton
v-if="projectsStore.canCreateProjects"
icon="plus"
text
data-test-id="project-plus-button"
:disabled="isCreatingProject"
:class="$style.plusBtn"
@click="globalEntityCreation.createProject"
/>
</N8nText>
<ElMenu
v-if="projectsStore.isTeamProjectFeatureEnabled"
:collapse="props.collapsed"
:class="$style.projectItems"
>
<N8nMenuItem
:item="personalProject"
:compact="props.collapsed"
:active-tab="projectsStore.projectNavActiveId"
mode="tabs"
data-test-id="project-personal-menu-item"
/>
<N8nMenuItem
v-for="project in displayProjects"
:key="project.id"
:class="{
[$style.collapsed]: props.collapsed,
}"
:item="getProjectMenuItem(project)"
:compact="props.collapsed"
:active-tab="projectsStore.projectNavActiveId"
mode="tabs"
data-test-id="project-menu-item"
/>
</ElMenu>
<N8nButton
v-if="showAddFirstProject"
:class="[
$style.addFirstProjectBtn,
{
[$style.collapsed]: props.collapsed,
},
]"
:disabled="isCreatingProject"
type="secondary"
icon="plus"
data-test-id="add-first-project-button"
@click="globalEntityCreation.createProject"
>
{{ locale.baseText('projects.menu.addFirstProject') }}
</N8nButton>
<hr v-if="projectsStore.isTeamProjectFeatureEnabled" class="mb-m" />
</div>
</template>
<style lang="scss" module>
.projects {
display: grid;
grid-auto-rows: auto;
width: 100%;
overflow: hidden;
align-items: start;
&:hover {
.plusBtn {
display: block;
}
}
}
.projectItems {
height: 100%;
padding: 0 var(--spacing-xs) var(--spacing-s);
overflow: auto;
}
.upgradeLink {
color: var(--color-primary);
cursor: pointer;
}
.collapsed {
text-transform: uppercase;
}
.projectsLabel {
display: flex;
justify-content: space-between;
margin: 0 0 var(--spacing-s) var(--spacing-xs);
padding: 0 var(--spacing-s);
text-overflow: ellipsis;
overflow: hidden;
box-sizing: border-box;
color: var(--color-text-base);
&.collapsed {
padding: 0;
margin-left: 0;
justify-content: center;
}
}
.plusBtn {
margin: 0;
padding: 0;
color: var(--color-text-light);
display: none;
}
.addFirstProjectBtn {
font-size: var(--font-size-xs);
padding: var(--spacing-3xs);
margin: 0 var(--spacing-m) var(--spacing-m);
&.collapsed {
> span:last-child {
display: none;
}
}
}
</style>
<style lang="scss" scoped>
.home {
padding: 0 var(--spacing-xs);
:deep(.el-menu-item) {
padding: var(--spacing-m) var(--spacing-xs) !important;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
type Props = {
limit: number;
planName?: string;
};
const props = defineProps<Props>();
const visible = defineModel<boolean>();
const pageRedirectionHelper = usePageRedirectionHelper();
const locale = useI18n();
const goToUpgrade = async () => {
await pageRedirectionHelper.goToUpgrade('rbac', 'upgrade-rbac');
visible.value = false;
};
</script>
<template>
<el-dialog
v-model="visible"
:title="locale.baseText('projects.settings.role.upgrade.title')"
width="500"
>
<div class="pt-l">
<i18n-t keypath="projects.settings.role.upgrade.message">
<template #planName>{{ props.planName }}</template>
<template #limit>
{{
locale.baseText('projects.create.limit', {
adjustToNumber: props.limit,
interpolate: { num: String(props.limit) },
})
}}
</template>
</i18n-t>
</div>
<template #footer>
<N8nButton type="secondary" native-type="button" @click="visible = false">{{
locale.baseText('generic.cancel')
}}</N8nButton>
<N8nButton type="primary" native-type="button" @click="goToUpgrade">{{
locale.baseText('projects.create.limitReached.link')
}}</N8nButton>
</template>
</el-dialog>
</template>

View File

@@ -0,0 +1,165 @@
import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems, getSelectedDropdownValue } from '@/__tests__/utils';
import { createProjectListItem, createProjectSharingData } from '@/__tests__/data/projects';
import ProjectSharing from '@/components/Projects/ProjectSharing.vue';
const renderComponent = createComponentRenderer(ProjectSharing);
const personalProjects = Array.from({ length: 3 }, createProjectListItem);
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
const homeProject = createProjectSharingData();
describe('ProjectSharing', () => {
it('should render empty select when projects is empty and no selected project existing', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
projects: [],
modelValue: [],
},
});
expect(getByTestId('project-sharing-select')).toBeInTheDocument();
expect(queryByTestId('project-sharing-list-item')).not.toBeInTheDocument();
expect(queryByTestId('project-sharing-owner')).not.toBeInTheDocument();
});
it('should filter, add and remove projects', async () => {
const { getByTestId, getAllByTestId, queryByTestId, queryAllByTestId, emitted } =
renderComponent({
props: {
projects: personalProjects,
modelValue: [personalProjects[0]],
roles: [
{
role: 'project:admin',
name: 'Admin',
},
{
role: 'project:editor',
name: 'Editor',
},
],
},
});
expect(queryByTestId('project-sharing-owner')).not.toBeInTheDocument();
// Check the initial state (one selected project comes from the modelValue prop)
expect(getAllByTestId('project-sharing-list-item')).toHaveLength(1);
const projectSelect = getByTestId('project-sharing-select');
const projectSelectInput = projectSelect.querySelector('input') as HTMLInputElement;
// Get the dropdown items
let projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(2);
// Add a project (first from the dropdown list)
await userEvent.click(projectSelectDropdownItems[0]);
expect(emitted()['update:modelValue']).toEqual([[[expect.any(Object), expect.any(Object)]]]);
expect(getAllByTestId('project-sharing-list-item')).toHaveLength(2);
expect(projectSelectInput.value).toBe('');
projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(1);
let actionDropDownItems = await getDropdownItems(
getAllByTestId('project-sharing-list-item')[0],
);
expect(actionDropDownItems).toHaveLength(2);
// Click on the remove button
await userEvent.click(
within(getAllByTestId('project-sharing-list-item')[0]).getByTestId('project-sharing-remove'),
);
expect(emitted()['update:modelValue']).toEqual([
[[expect.any(Object), expect.any(Object)]],
[[expect.any(Object)]],
]);
// Check the state
expect(getAllByTestId('project-sharing-list-item')).toHaveLength(1);
projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(2);
actionDropDownItems = await getDropdownItems(getAllByTestId('project-sharing-list-item')[0]);
expect(actionDropDownItems).toHaveLength(2);
// Remove the last selected project
await userEvent.click(
within(getAllByTestId('project-sharing-list-item')[0]).getByTestId('project-sharing-remove'),
);
expect(emitted()['update:modelValue']).toEqual([
[[expect.any(Object), expect.any(Object)]],
[[expect.any(Object)]],
[[]],
]);
// Check the final state
expect(queryAllByTestId('project-sharing-list-item')).toHaveLength(0);
projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(3);
});
it('should work as a simple select when model is not an array', async () => {
const { getByTestId, queryByTestId, emitted } = renderComponent({
props: {
projects: teamProjects,
modelValue: null,
},
});
expect(queryByTestId('project-sharing-owner')).not.toBeInTheDocument();
const projectSelect = getByTestId('project-sharing-select');
// Get the dropdown items
let projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(3);
// Select the first project from the dropdown list
await userEvent.click(projectSelectDropdownItems[0]);
expect(queryByTestId('project-sharing-list-item')).not.toBeInTheDocument();
projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(3);
const selectedValue = await getSelectedDropdownValue(projectSelectDropdownItems);
expect(selectedValue).toBeTruthy();
expect(emitted()['update:modelValue']).toEqual([
[
expect.objectContaining({
name: selectedValue,
}),
],
]);
// Select another project from the dropdown list
await userEvent.click(projectSelectDropdownItems[1]);
projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(3);
const newSelectedValue = await getSelectedDropdownValue(projectSelectDropdownItems);
expect(newSelectedValue).toBeTruthy();
expect(emitted()['update:modelValue']).toEqual([
expect.any(Array),
[
expect.objectContaining({
name: newSelectedValue,
}),
],
]);
});
it('should render home project as owner when defined', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
projects: personalProjects,
modelValue: [],
homeProject,
},
});
expect(getByTestId('project-sharing-select')).toBeInTheDocument();
expect(queryByTestId('project-sharing-list-item')).not.toBeInTheDocument();
expect(getByTestId('project-sharing-owner')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,190 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue';
import type { RoleMap } from '@/types/roles.types';
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
const locale = useI18n();
type Props = {
projects: ProjectListItem[];
homeProject?: ProjectSharingData;
roles?: RoleMap['workflow' | 'credential' | 'project'];
readonly?: boolean;
static?: boolean;
placeholder?: string;
emptyOptionsText?: string;
};
const props = defineProps<Props>();
const model = defineModel<(ProjectSharingData | null) | ProjectSharingData[]>({
required: true,
});
const emit = defineEmits<{
projectAdded: [value: ProjectSharingData];
projectRemoved: [value: ProjectSharingData];
}>();
const selectedProject = ref(Array.isArray(model.value) ? '' : (model.value?.id ?? ''));
const filter = ref('');
const selectPlaceholder = computed(
() => props.placeholder ?? locale.baseText('projects.sharing.select.placeholder'),
);
const noDataText = computed(
() => props.emptyOptionsText ?? locale.baseText('projects.sharing.noMatchingUsers'),
);
const filteredProjects = computed(() =>
sortByProperty(
'name',
props.projects.filter(
(project) =>
project.name?.toLowerCase().includes(filter.value.toLowerCase()) &&
(Array.isArray(model.value) ? !model.value?.find((p) => p.id === project.id) : true),
),
),
);
const setFilter = (query: string) => {
filter.value = query;
};
const onProjectSelected = (projectId: string) => {
const project = props.projects.find((p) => p.id === projectId);
if (!project) {
return;
}
if (Array.isArray(model.value)) {
model.value = [...model.value, project];
} else {
model.value = project;
}
emit('projectAdded', project);
};
const onRoleAction = (project: ProjectSharingData, role: string) => {
if (!Array.isArray(model.value) || props.readonly) {
return;
}
const index = model.value?.findIndex((p) => p.id === project.id) ?? -1;
if (index === -1) {
return;
}
if (role === 'remove') {
model.value = model.value.filter((p) => p.id !== project.id);
emit('projectRemoved', project);
}
};
watch(
() => model.value,
() => {
if (model.value === null || Array.isArray(model.value)) {
selectedProject.value = '';
} else {
selectedProject.value = model.value.id;
}
},
{ immediate: true },
);
</script>
<template>
<div>
<N8nSelect
v-if="!props.static"
:model-value="selectedProject"
data-test-id="project-sharing-select"
:filterable="true"
:filter-method="setFilter"
:placeholder="selectPlaceholder"
:default-first-option="true"
:no-data-text="noDataText"
size="large"
:disabled="props.readonly"
@update:model-value="onProjectSelected"
>
<template #prefix>
<n8n-icon icon="search" />
</template>
<N8nOption
v-for="project in filteredProjects"
:key="project.id"
:value="project.id"
:label="project.name"
>
<ProjectSharingInfo :project="project" />
</N8nOption>
</N8nSelect>
<ul v-if="Array.isArray(model)" :class="$style.selectedProjects">
<li v-if="props.homeProject" :class="$style.project" data-test-id="project-sharing-owner">
<ProjectSharingInfo :project="props.homeProject">
<N8nBadge theme="tertiary" bold>
{{ locale.baseText('auth.roles.owner') }}
</N8nBadge></ProjectSharingInfo
>
</li>
<li
v-for="project in model"
:key="project.id"
:class="$style.project"
data-test-id="project-sharing-list-item"
>
<ProjectSharingInfo :project="project" />
<N8nSelect
v-if="props.roles?.length && !props.static"
:class="$style.projectRoleSelect"
:model-value="props.roles[0]"
:disabled="props.readonly"
size="small"
@update:model-value="onRoleAction(project, $event)"
>
<N8nOption v-for="role in roles" :key="role.role" :value="role.role" :label="role.name" />
</N8nSelect>
<N8nButton
v-if="!props.static"
type="tertiary"
native-type="button"
square
icon="trash"
:disabled="props.readonly"
data-test-id="project-sharing-remove"
@click="onRoleAction(project, 'remove')"
/>
</li>
</ul>
</div>
</template>
<style lang="scss" module>
.project {
display: flex;
width: 100%;
align-items: center;
padding: var(--spacing-2xs) 0;
gap: var(--spacing-2xs);
}
.selectedProjects {
li {
padding: 0;
border-bottom: var(--border-base);
&:first-child {
padding-top: var(--spacing-m);
}
&:last-child {
border-bottom: none;
}
}
}
.projectRoleSelect {
width: auto;
}
</style>

View File

@@ -0,0 +1,83 @@
<script lang="ts" setup>
import { computed } from 'vue';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import { splitName } from '@/utils/projects.utils';
type Props = {
project: ProjectListItem | ProjectSharingData;
};
const props = defineProps<Props>();
const processedName = computed(() => {
const { name, email } = splitName(props.project.name ?? '');
const nameArray = name?.split(' ');
const lastName = nameArray?.pop() ?? '';
return {
firstName: nameArray?.join(' ') ?? '',
lastName,
email,
};
});
const projectIcon = computed(() => {
if (props.project.icon) {
return props.project.icon;
}
return null;
});
</script>
<template>
<div :class="$style.projectInfo" data-test-id="project-sharing-info">
<div>
<ProjectIcon v-if="projectIcon" :icon="projectIcon" size="large" :round="true" />
<N8nAvatar v-else :first-name="processedName.firstName" :last-name="processedName.lastName" />
<div :class="$style.text">
<p v-if="processedName.firstName || processedName.lastName">
{{ processedName.firstName }} {{ processedName.lastName }}
</p>
<small>{{ processedName.email }}</small>
</div>
</div>
<slot></slot>
</div>
</template>
<style lang="scss" module>
.projectInfo {
display: flex;
align-items: center;
width: 100%;
padding: var(--spacing-2xs) 0;
gap: 8px;
justify-content: space-between;
> div {
display: flex;
align-items: center;
gap: 8px;
}
p {
font-size: var(--font-size-s);
color: var(--color-text-dark);
font-weight: var(--font-weight-bold);
line-height: var(--font-line-height-loose);
}
small {
font-size: var(--font-size-xs);
color: var(--color-text-light);
line-height: var(--font-line-height-loose);
}
}
.text {
display: flex;
flex-direction: column;
p {
margin: 0;
}
}
</style>

View File

@@ -0,0 +1,42 @@
import { createComponentRenderer } from '@/__tests__/render';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
vi.mock('vue-router', async () => {
const actual = await vi.importActual('vue-router');
const params = {};
return {
...actual,
useRoute: () => ({
params,
}),
};
});
const renderComponent = createComponentRenderer(ProjectTabs, {
global: {
stubs: {
'router-link': {
template: '<div><slot /></div>',
},
},
},
});
describe('ProjectTabs', () => {
it('should render home tabs', async () => {
const { getByText, queryByText } = renderComponent();
expect(getByText('Workflows')).toBeInTheDocument();
expect(getByText('Credentials')).toBeInTheDocument();
expect(getByText('Executions')).toBeInTheDocument();
expect(queryByText('Project settings')).not.toBeInTheDocument();
});
it('should render project tab Settings', () => {
const { getByText } = renderComponent({ props: { showSettings: true } });
expect(getByText('Workflows')).toBeInTheDocument();
expect(getByText('Credentials')).toBeInTheDocument();
expect(getByText('Executions')).toBeInTheDocument();
expect(getByText('Project settings')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import type { RouteRecordName } from 'vue-router';
import { useRoute } from 'vue-router';
import { VIEWS } from '@/constants';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{
showSettings?: boolean;
}>();
const locale = useI18n();
const route = useRoute();
const selectedTab = ref<RouteRecordName | null | undefined>('');
const options = computed(() => {
const projectId = route?.params?.projectId;
const to = projectId
? {
workflows: {
name: VIEWS.PROJECTS_WORKFLOWS,
params: { projectId },
},
credentials: {
name: VIEWS.PROJECTS_CREDENTIALS,
params: { projectId },
},
executions: {
name: VIEWS.PROJECTS_EXECUTIONS,
params: { projectId },
},
}
: {
workflows: {
name: VIEWS.WORKFLOWS,
},
credentials: {
name: VIEWS.CREDENTIALS,
},
executions: {
name: VIEWS.EXECUTIONS,
},
};
const tabs = [
{
label: locale.baseText('mainSidebar.workflows'),
value: to.workflows.name,
to: to.workflows,
},
{
label: locale.baseText('mainSidebar.credentials'),
value: to.credentials.name,
to: to.credentials,
},
{
label: locale.baseText('mainSidebar.executions'),
value: to.executions.name,
to: to.executions,
},
];
if (props.showSettings) {
tabs.push({
label: locale.baseText('projects.settings'),
value: VIEWS.PROJECT_SETTINGS,
to: { name: VIEWS.PROJECT_SETTINGS, params: { projectId } },
});
}
return tabs;
});
watch(
() => route?.name,
() => {
selectedTab.value = route?.name;
// Select workflows tab if folders tab is selected
selectedTab.value =
route.name === VIEWS.PROJECTS_FOLDERS ? VIEWS.PROJECTS_WORKFLOWS : route.name;
},
{ immediate: true },
);
</script>
<template>
<N8nTabs v-model="selectedTab" :options="options" data-test-id="project-tabs" />
</template>