fix(editor): Move projects and rbac files (no-changelog) (#9651)

This commit is contained in:
Csaba Tuncsik
2024-06-06 15:30:17 +02:00
committed by GitHub
parent 58cfd2fde8
commit ed963011c9
94 changed files with 172 additions and 212 deletions

View File

@@ -0,0 +1,59 @@
import { createComponentRenderer } from '@/__tests__/render';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
const renderComponent = createComponentRenderer(ProjectCardBadge);
describe('ProjectCardBadge', () => {
it('should show "Owned by me" badge if there is no homeProject', () => {
const { getByText } = renderComponent({
props: {
resource: {},
personalProject: {},
},
});
expect(getByText('Owned by me')).toBeVisible();
});
it('should show "Owned by me" badge if homeProject ID equals personalProject ID', () => {
const { getByText } = renderComponent({
props: {
resource: {
homeProject: {
id: '1',
},
},
personalProject: {
id: '1',
},
},
});
expect(getByText('Owned by me')).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,
},
},
personalProject: {
id: '2',
},
},
});
expect(getByText(result)).toBeVisible();
});
});

View File

@@ -0,0 +1,52 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { splitName } from '@/utils/projects.utils';
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
import type { Project } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
type Props = {
resource: IWorkflowDb | ICredentialsResponse;
personalProject: Project | null;
};
const props = defineProps<Props>();
const locale = useI18n();
const badgeText = computed(() => {
if (
(props.resource.homeProject &&
props.personalProject &&
props.resource.homeProject.id === props.personalProject.id) ||
!props.resource.homeProject
) {
return locale.baseText('generic.ownedByMe');
} else {
const { firstName, lastName, email } = splitName(props.resource.homeProject?.name ?? '');
return !firstName ? email : `${firstName}${lastName ? ' ' + lastName : ''}`;
}
});
const badgeIcon = computed(() => {
if (
props.resource.sharedWithProjects?.length &&
props.resource.homeProject?.type !== ProjectTypes.Team
) {
return 'user-friends';
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
return 'archive';
} else {
return '';
}
});
</script>
<template>
<n8n-badge v-if="badgeText" class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
{{ badgeText }}
<n8n-icon v-if="badgeIcon" :icon="badgeIcon" size="small" class="ml-5xs" />
</n8n-badge>
</template>
<style lang="scss" module></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<{
(e: 'confirmDelete', value?: string): void;
}>();
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,207 @@
<script lang="ts" setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
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 { useToast } from '@/composables/useToast';
import { useUIStore } from '@/stores/ui.store';
type Props = {
collapsed: boolean;
planName?: string;
};
const props = defineProps<Props>();
const router = useRouter();
const locale = useI18n();
const toast = useToast();
const projectsStore = useProjectsStore();
const uiStore = useUIStore();
const isCreatingProject = ref(false);
const isComponentMounted = ref(false);
const home = computed<IMenuItem>(() => ({
id: 'home',
label: locale.baseText('projects.menu.home'),
icon: 'home',
route: {
to: { name: VIEWS.HOMEPAGE },
},
}));
const addProject = computed<IMenuItem>(() => ({
id: 'addProject',
label: locale.baseText('projects.menu.addProject'),
icon: 'plus',
disabled:
!isComponentMounted.value || isCreatingProject.value || !projectsStore.canCreateProjects,
isLoading: isCreatingProject.value,
}));
const getProjectMenuItem = (project: ProjectListItem) => ({
id: project.id,
label: project.name,
route: {
to: {
name: VIEWS.PROJECTS_WORKFLOWS,
params: { projectId: project.id },
},
},
});
const homeClicked = () => {};
const projectClicked = () => {};
const addProjectClicked = async () => {
isCreatingProject.value = true;
try {
const newProject = await projectsStore.createProject({
name: locale.baseText('projects.settings.newProjectName'),
});
await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } });
toast.showMessage({
title: locale.baseText('projects.settings.save.successful.title', {
interpolate: { projectName: newProject.name ?? '' },
}),
type: 'success',
});
} catch (error) {
toast.showError(error, locale.baseText('projects.error.title'));
} finally {
isCreatingProject.value = false;
}
};
const displayProjects = computed(() => {
return projectsStore.myProjects
.filter((p) => p.type === 'team')
.toSorted((a, b) => {
if (!a.name || !b.name) {
return 0;
}
if (a.name > b.name) {
return 1;
} else if (a.name < b.name) {
return -1;
}
return 0;
});
});
const goToUpgrade = async () => {
await uiStore.goToUpgrade('rbac', 'upgrade-rbac');
};
onMounted(async () => {
await nextTick();
isComponentMounted.value = true;
});
</script>
<template>
<div :class="$style.projects">
<ElMenu :collapse="props.collapsed" class="home">
<N8nMenuItem
:item="home"
:compact="props.collapsed"
:handle-select="homeClicked"
:active-tab="projectsStore.projectNavActiveId"
mode="tabs"
data-test-id="project-home-menu-item"
/>
</ElMenu>
<hr
v-if="
displayProjects.length ||
(projectsStore.hasPermissionToCreateProjects && projectsStore.teamProjectsAvailable)
"
class="mt-m mb-m"
/>
<ElMenu v-if="displayProjects.length" :collapse="props.collapsed" :class="$style.projectItems">
<N8nMenuItem
v-for="project in displayProjects"
:key="project.id"
:item="getProjectMenuItem(project)"
:compact="props.collapsed"
:handle-select="projectClicked"
:active-tab="projectsStore.projectNavActiveId"
mode="tabs"
data-test-id="project-menu-item"
/>
</ElMenu>
<N8nTooltip placement="right" :disabled="projectsStore.canCreateProjects">
<ElMenu
v-if="projectsStore.hasPermissionToCreateProjects && projectsStore.teamProjectsAvailable"
:collapse="props.collapsed"
class="pl-xs pr-xs"
>
<N8nMenuItem
:item="addProject"
:compact="props.collapsed"
:handle-select="addProjectClicked"
mode="tabs"
data-test-id="add-project-menu-item"
/>
</ElMenu>
<template #content>
<i18n-t keypath="projects.create.limitReached">
<template #planName>{{ props.planName }}</template>
<template #limit>
{{
locale.baseText('projects.create.limit', {
adjustToNumber: projectsStore.teamProjectsLimit,
interpolate: { num: String(projectsStore.teamProjectsLimit) },
})
}}
</template>
<template #link>
<a :class="$style.upgradeLink" href="#" @click="goToUpgrade">
{{ locale.baseText('projects.create.limitReached.link') }}
</a>
</template>
</i18n-t>
</template>
</N8nTooltip>
<hr
v-if="
displayProjects.length ||
(projectsStore.hasPermissionToCreateProjects && projectsStore.teamProjectsAvailable)
"
class="mt-m mb-m"
/>
</div>
</template>
<style lang="scss" module>
.projects {
display: grid;
grid-auto-rows: auto;
width: 100%;
overflow: hidden;
align-items: start;
}
.projectItems {
height: 100%;
padding: 0 var(--spacing-xs) var(--spacing-s);
overflow: auto;
}
.upgradeLink {
color: var(--color-primary);
cursor: pointer;
}
</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 { useUIStore } from '@/stores/ui.store';
type Props = {
limit: number;
planName?: string;
};
const props = defineProps<Props>();
const visible = defineModel<boolean>();
const uiStore = useUIStore();
const locale = useI18n();
const goToUpgrade = async () => {
await uiStore.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,128 @@
import { within } from '@testing-library/vue';
import { createPinia, setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems } from '@/__tests__/utils';
import { useRoute, useRouter } from 'vue-router';
import ProjectSettings from '@/components/Projects/ProjectSettings.vue';
import { useProjectsStore } from '@/stores/projects.store';
import { VIEWS } from '@/constants';
import { useUsersStore } from '@/stores/users.store';
import { createProjectListItem } from '@/__tests__/data/projects';
import { useSettingsStore } from '@/stores/settings.store';
import type { IN8nUISettings } from 'n8n-workflow';
vi.mock('vue-router', () => {
const params = {};
const push = vi.fn();
return {
useRoute: () => ({
params,
}),
useRouter: () => ({
push,
}),
RouterLink: vi.fn(),
};
});
const renderComponent = createComponentRenderer(ProjectSettings);
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
let router: ReturnType<typeof useRouter>;
let route: ReturnType<typeof useRoute>;
let projectsStore: ReturnType<typeof useProjectsStore>;
let usersStore: ReturnType<typeof useUsersStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
describe('ProjectSettings', () => {
beforeEach(() => {
const pinia = createPinia();
setActivePinia(pinia);
route = useRoute();
router = useRouter();
projectsStore = useProjectsStore();
usersStore = useUsersStore();
settingsStore = useSettingsStore();
vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve());
vi.spyOn(projectsStore, 'getAllProjects').mockImplementation(
async () => await Promise.resolve(),
);
vi.spyOn(projectsStore, 'teamProjects', 'get').mockReturnValue(teamProjects);
vi.spyOn(settingsStore, 'settings', 'get').mockReturnValue({
enterprise: {
projects: {
team: {
limit: -1,
},
},
},
} as IN8nUISettings);
projectsStore.setCurrentProject({
id: '123',
type: 'team',
name: 'Test Project',
relations: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
scopes: [],
});
});
it('should show confirmation modal before deleting project and delete with transfer', async () => {
const deleteProjectSpy = vi
.spyOn(projectsStore, 'deleteProject')
.mockImplementation(async () => {});
const { getByTestId, getByRole } = renderComponent();
const deleteButton = getByTestId('project-settings-delete-button');
await userEvent.click(deleteButton);
expect(deleteProjectSpy).not.toHaveBeenCalled();
const modal = getByRole('dialog');
expect(modal).toBeVisible();
const confirmButton = getByTestId('project-settings-delete-confirm-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(within(modal).getAllByRole('radio')[0]);
const projectSelect = getByTestId('project-sharing-select');
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
await userEvent.click(projectSelectDropdownItems[0]);
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(deleteProjectSpy).toHaveBeenCalledWith('123', expect.any(String));
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
it('should show confirmation modal before deleting project and deleting without transfer', async () => {
const deleteProjectSpy = vi
.spyOn(projectsStore, 'deleteProject')
.mockImplementation(async () => {});
const { getByTestId, getByRole } = renderComponent();
const deleteButton = getByTestId('project-settings-delete-button');
await userEvent.click(deleteButton);
expect(deleteProjectSpy).not.toHaveBeenCalled();
const modal = getByRole('dialog');
expect(modal).toBeVisible();
const confirmButton = getByTestId('project-settings-delete-confirm-button');
expect(confirmButton).toBeDisabled();
await userEvent.click(within(modal).getAllByRole('radio')[1]);
const input = within(modal).getByRole('textbox');
await userEvent.type(input, 'delete all ');
expect(confirmButton).toBeDisabled();
await userEvent.type(input, 'data');
expect(confirmButton).toBeEnabled();
await userEvent.click(confirmButton);
expect(deleteProjectSpy).toHaveBeenCalledWith('123', undefined);
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
});

View File

@@ -0,0 +1,419 @@
<script lang="ts" setup>
import { computed, ref, watch, onBeforeMount, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { deepCopy } from 'n8n-workflow';
import { useUsersStore } from '@/stores/users.store';
import type { IUser } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import type { Project, ProjectRelation } from '@/types/projects.types';
import { useToast } from '@/composables/useToast';
import { VIEWS } from '@/constants';
import ProjectDeleteDialog from '@/components/Projects/ProjectDeleteDialog.vue';
import ProjectRoleUpgradeDialog from '@/components/Projects/ProjectRoleUpgradeDialog.vue';
import { useRolesStore } from '@/stores/roles.store';
import type { ProjectRole } from '@/types/roles.types';
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
import { useTelemetry } from '@/composables/useTelemetry';
type FormDataDiff = {
name?: string;
role?: ProjectRelation[];
memberAdded?: ProjectRelation[];
memberRemoved?: ProjectRelation[];
};
const usersStore = useUsersStore();
const locale = useI18n();
const projectsStore = useProjectsStore();
const rolesStore = useRolesStore();
const cloudPlanStore = useCloudPlanStore();
const toast = useToast();
const router = useRouter();
const telemetry = useTelemetry();
const dialogVisible = ref(false);
const upgradeDialogVisible = ref(false);
const isDirty = ref(false);
const formData = ref<Pick<Project, 'name' | 'relations'>>({
name: '',
relations: [],
});
const projectRoleTranslations = ref<{ [key: string]: string }>({
'project:editor': locale.baseText('projects.settings.role.editor'),
'project:admin': locale.baseText('projects.settings.role.admin'),
});
const nameInput = ref<HTMLInputElement | null>(null);
const usersList = computed(() =>
usersStore.allUsers.filter((user: IUser) => {
const isAlreadySharedWithUser = (formData.value.relations || []).find(
(r: ProjectRelation) => r.id === user.id,
);
return !isAlreadySharedWithUser;
}),
);
const projects = computed(() =>
projectsStore.teamProjects.filter((project) => project.id !== projectsStore.currentProjectId),
);
const projectRoles = computed(() =>
rolesStore.processedProjectRoles.map((role) => ({
...role,
name: projectRoleTranslations.value[role.role],
})),
);
const firstLicensedRole = computed(() => projectRoles.value.find((role) => role.licensed)?.role);
const onAddMember = (userId: string) => {
isDirty.value = true;
const user = usersStore.getUserById(userId);
if (!user) return;
const { id, firstName, lastName, email } = user;
const relation = { id, firstName, lastName, email } as ProjectRelation;
if (firstLicensedRole.value) {
relation.role = firstLicensedRole.value;
}
formData.value.relations.push(relation);
};
const onRoleAction = (user: Partial<IUser>, role: string) => {
isDirty.value = true;
const index = formData.value.relations.findIndex((r: ProjectRelation) => r.id === user.id);
if (role === 'remove') {
formData.value.relations.splice(index, 1);
} else {
formData.value.relations[index].role = role as ProjectRole;
}
};
const onNameInput = () => {
isDirty.value = true;
};
const onCancel = () => {
formData.value.relations = projectsStore.currentProject?.relations
? deepCopy(projectsStore.currentProject.relations)
: [];
formData.value.name = projectsStore.currentProject?.name ?? '';
isDirty.value = false;
};
const makeFormDataDiff = (): FormDataDiff => {
const diff: FormDataDiff = {};
if (!projectsStore.currentProject) {
return diff;
}
if (formData.value.name !== projectsStore.currentProject.name) {
diff.name = formData.value.name ?? '';
}
if (formData.value.relations.length !== projectsStore.currentProject.relations.length) {
diff.memberAdded = formData.value.relations.filter(
(r: ProjectRelation) => !projectsStore.currentProject?.relations.find((cr) => cr.id === r.id),
);
diff.memberRemoved = projectsStore.currentProject.relations.filter(
(cr: ProjectRelation) => !formData.value.relations.find((r) => r.id === cr.id),
);
}
diff.role = formData.value.relations.filter((r: ProjectRelation) => {
const currentRelation = projectsStore.currentProject?.relations.find((cr) => cr.id === r.id);
return currentRelation?.role !== r.role && !diff.memberAdded?.find((ar) => ar.id === r.id);
});
return diff;
};
const sendTelemetry = (diff: FormDataDiff) => {
if (diff.name) {
telemetry.track('User changed project name', {
project_id: projectsStore.currentProject?.id,
name: diff.name,
});
}
if (diff.memberAdded) {
diff.memberAdded.forEach((r) => {
telemetry.track('User added member to project', {
project_id: projectsStore.currentProject?.id,
target_user_id: r.id,
role: r.role,
});
});
}
if (diff.memberRemoved) {
diff.memberRemoved.forEach((r) => {
telemetry.track('User removed member from project', {
project_id: projectsStore.currentProject?.id,
target_user_id: r.id,
});
});
}
if (diff.role) {
diff.role.forEach((r) => {
telemetry.track('User changed member role on project', {
project_id: projectsStore.currentProject?.id,
target_user_id: r.id,
role: r.role,
});
});
}
};
const onSubmit = async () => {
try {
if (isDirty.value && projectsStore.currentProject) {
const diff = makeFormDataDiff();
await projectsStore.updateProject({
id: projectsStore.currentProject.id,
name: formData.value.name,
relations: formData.value.relations.map((r: ProjectRelation) => ({
userId: r.id,
role: r.role,
})),
});
sendTelemetry(diff);
isDirty.value = false;
toast.showMessage({
title: locale.baseText('projects.settings.save.successful.title', {
interpolate: { projectName: formData.value.name ?? '' },
}),
type: 'success',
});
}
} catch (error) {
toast.showError(error, locale.baseText('projects.settings.save.error.title'));
}
};
const onDelete = async () => {
await projectsStore.getAllProjects();
dialogVisible.value = true;
};
const onConfirmDelete = async (transferId?: string) => {
try {
if (projectsStore.currentProject) {
const projectName = projectsStore.currentProject?.name ?? '';
await projectsStore.deleteProject(projectsStore.currentProject.id, transferId);
await router.push({ name: VIEWS.HOMEPAGE });
toast.showMessage({
title: locale.baseText('projects.settings.delete.successful.title', {
interpolate: { projectName },
}),
type: 'success',
});
dialogVisible.value = true;
}
} catch (error) {
toast.showError(error, locale.baseText('projects.settings.delete.error.title'));
}
};
const selectProjectNameIfMatchesDefault = () => {
if (
nameInput.value &&
formData.value.name === locale.baseText('projects.settings.newProjectName')
) {
nameInput.value.focus();
nameInput.value.select();
}
};
watch(
() => projectsStore.currentProject,
async () => {
formData.value.name = projectsStore.currentProject?.name ?? '';
formData.value.relations = projectsStore.currentProject?.relations
? deepCopy(projectsStore.currentProject.relations)
: [];
await nextTick();
selectProjectNameIfMatchesDefault();
},
{ immediate: true },
);
onBeforeMount(async () => {
await usersStore.fetchUsers();
});
</script>
<template>
<div :class="$style.projectSettings">
<div :class="$style.header">
<ProjectTabs />
</div>
<form @submit.prevent="onSubmit">
<fieldset>
<label for="name">{{ locale.baseText('projects.settings.name') }}</label>
<N8nInput
id="name"
ref="nameInput"
v-model="formData.name"
type="text"
name="name"
@input="onNameInput"
/>
</fieldset>
<fieldset>
<label for="projectMembers">{{
locale.baseText('projects.settings.projectMembers')
}}</label>
<N8nUserSelect
id="projectMembers"
class="mb-s"
size="large"
:users="usersList"
:current-user-id="usersStore.currentUser?.id"
:placeholder="$locale.baseText('workflows.shareModal.select.placeholder')"
data-test-id="project-members-select"
@update:model-value="onAddMember"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nUserSelect>
<N8nUsersList
:actions="[]"
:users="formData.relations"
:current-user-id="usersStore.currentUser?.id"
:delete-label="$locale.baseText('workflows.shareModal.list.delete')"
>
<template #actions="{ user }">
<div :class="$style.buttons">
<N8nSelect
class="mr-2xs"
:model-value="user?.role || projectRoles[0].role"
size="small"
@update:model-value="onRoleAction(user, $event)"
>
<N8nOption
v-for="role in projectRoles"
:key="role.role"
:value="role.role"
:label="role.name"
:disabled="!role.licensed"
>
{{ role.name
}}<span
v-if="!role.licensed"
:class="$style.upgrade"
@click="upgradeDialogVisible = true"
>
&nbsp;-&nbsp;{{ locale.baseText('generic.upgrade') }}
</span>
</N8nOption>
</N8nSelect>
<N8nButton
type="tertiary"
native-type="button"
square
icon="trash"
data-test-id="project-user-remove"
@click="onRoleAction(user, 'remove')"
/>
</div>
</template>
</N8nUsersList>
</fieldset>
<fieldset :class="$style.buttons">
<div>
<small v-if="isDirty" class="mr-2xs">{{
locale.baseText('projects.settings.message.unsavedChanges')
}}</small>
<N8nButton
:disabled="!isDirty"
type="secondary"
native-type="button"
class="mr-2xs"
data-test-id="project-settings-cancel-button"
@click.stop.prevent="onCancel"
>{{ locale.baseText('projects.settings.button.cancel') }}</N8nButton
>
</div>
<N8nButton
:disabled="!isDirty"
type="primary"
data-test-id="project-settings-save-button"
>{{ locale.baseText('projects.settings.button.save') }}</N8nButton
>
</fieldset>
<fieldset>
<hr class="mb-2xl" />
<h3 class="mb-xs">{{ locale.baseText('projects.settings.danger.title') }}</h3>
<small>{{ locale.baseText('projects.settings.danger.message') }}</small>
<br />
<N8nButton
type="tertiary"
native-type="button"
class="mt-s"
data-test-id="project-settings-delete-button"
@click.stop.prevent="onDelete"
>{{ locale.baseText('projects.settings.danger.deleteProject') }}</N8nButton
>
</fieldset>
</form>
<ProjectDeleteDialog
v-model="dialogVisible"
:current-project="projectsStore.currentProject"
:projects="projects"
@confirm-delete="onConfirmDelete"
/>
<ProjectRoleUpgradeDialog
v-model="upgradeDialogVisible"
:limit="projectsStore.teamProjectsLimit"
:plan-name="cloudPlanStore.currentPlanData?.displayName"
/>
</div>
</template>
<style lang="scss" module>
.projectSettings {
display: grid;
width: 100%;
justify-items: center;
grid-auto-rows: max-content;
form {
width: 100%;
max-width: 1280px;
padding: 0 var(--spacing-2xl);
fieldset {
padding-bottom: var(--spacing-2xl);
label {
display: block;
margin-bottom: var(--spacing-xs);
font-size: var(--font-size-xl);
}
}
}
}
.header {
width: 100%;
max-width: 1280px;
padding: var(--spacing-l) var(--spacing-2xl) 0;
}
.upgrade {
cursor: pointer;
}
.buttons {
display: flex;
justify-content: flex-end;
align-items: center;
}
</style>

View File

@@ -0,0 +1,163 @@
import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { getDropdownItems } 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');
const projectSelectInput = projectSelect.querySelector('input') as HTMLInputElement;
// 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);
expect(projectSelectDropdownItems[0].textContent).toContain(projectSelectInput.value);
expect(emitted()['update:modelValue']).toEqual([
[
expect.objectContaining({
name: projectSelectInput.value,
}),
],
]);
// Select another project from the dropdown list
await userEvent.click(projectSelectDropdownItems[1]);
projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(3);
expect(projectSelectDropdownItems[1].textContent).toContain(projectSelectInput.value);
expect(emitted()['update:modelValue']).toEqual([
expect.any(Array),
[
expect.objectContaining({
name: projectSelectInput.value,
}),
],
]);
});
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,192 @@
<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';
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<{
(event: 'projectAdded', value: ProjectSharingData): void;
(event: 'projectRemoved', value: ProjectSharingData): void;
}>();
const selectedProject = ref(Array.isArray(model.value) ? '' : model.value?.id ?? '');
const filter = ref('');
const selectPlaceholder = computed(
() =>
props.placeholder ??
(Array.isArray(model.value)
? locale.baseText('projects.sharing.placeholder')
: locale.baseText('projects.sharing.placeholder.single')),
);
const noDataText = computed(
() => props.emptyOptionsText ?? locale.baseText('projects.sharing.noMatchingUsers'),
);
const filteredProjects = computed(() =>
props.projects
.filter(
(project) =>
project.name?.toLowerCase().includes(filter.value.toLowerCase()) &&
(Array.isArray(model.value) ? !model.value?.find((p) => p.id === project.id) : true),
)
.sort((a, b) => (a.name && b.name ? a.name.localeCompare(b.name) : 0)),
);
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,62 @@
<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(() => splitName(props.project.name ?? ''));
</script>
<template>
<div :class="$style.projectInfo" data-test-id="project-sharing-info">
<div>
<N8nAvatar :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;
}
</style>

View File

@@ -0,0 +1,90 @@
import { createComponentRenderer } from '@/__tests__/render';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { useRoute, useRouter } from 'vue-router';
import { useProjectsStore } from '@/stores/projects.store';
vi.mock('vue-router', () => {
const params = {};
const push = vi.fn();
return {
useRoute: () => ({
params,
}),
useRouter: () => ({
push,
}),
RouterLink: vi.fn(),
};
});
vi.mock('@/stores/users.store', () => ({
useUsersStore: vi.fn().mockImplementation(() => ({
currentUser: {},
})),
}));
vi.mock('@/stores/projects.store', () => ({
useProjectsStore: vi.fn().mockReturnValue({}),
}));
vi.mock('@/utils/rbac/permissions', () => ({
hasPermission: vi.fn().mockReturnValue(false),
}));
const renderComponent = createComponentRenderer(ProjectTabs);
let router: ReturnType<typeof useRouter>;
let route: ReturnType<typeof useRoute>;
let projectsStore: ReturnType<typeof useProjectsStore>;
describe('ProjectTabs', () => {
beforeEach(() => {
route = useRoute();
router = useRouter();
projectsStore = useProjectsStore();
});
it('should render home tabs', async () => {
const { getByText, queryByText } = renderComponent();
expect(getByText('Workflows')).toBeInTheDocument();
expect(getByText('Credentials')).toBeInTheDocument();
expect(queryByText('Project settings')).not.toBeInTheDocument();
});
it('should render project tabs if use has permissions', () => {
route.params.projectId = '123';
projectsStore.currentProject = {
id: '123',
type: 'team',
name: 'Project',
relations: [],
scopes: ['project:update'],
createdAt: '',
updatedAt: '',
};
const { getByText } = renderComponent();
expect(getByText('Workflows')).toBeInTheDocument();
expect(getByText('Credentials')).toBeInTheDocument();
expect(getByText('Project settings')).toBeInTheDocument();
});
it('should render project tabs', () => {
route.params.projectId = '123';
projectsStore.currentProject = {
id: '123',
type: 'team',
name: 'Project',
relations: [],
scopes: ['project:read'],
createdAt: '',
updatedAt: '',
};
const { queryByText, getByText } = renderComponent();
expect(getByText('Workflows')).toBeInTheDocument();
expect(getByText('Credentials')).toBeInTheDocument();
expect(queryByText('Project settings')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,79 @@
<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';
import { useProjectsStore } from '@/stores/projects.store';
import { getProjectPermissions } from '@/permissions';
const locale = useI18n();
const route = useRoute();
const projectsStore = useProjectsStore();
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 },
},
}
: {
workflows: {
name: VIEWS.WORKFLOWS,
},
credentials: {
name: VIEWS.CREDENTIALS,
},
};
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,
},
];
if (projectId && getProjectPermissions(projectsStore.currentProject).update) {
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;
},
{ immediate: true },
);
</script>
<template>
<div :class="$style.projectTabs">
<N8nTabs v-model="selectedTab" :options="options" data-test-id="project-tabs" />
</div>
</template>
<style module lang="scss">
.projectTabs {
padding: var(--spacing-2xs) 0 var(--spacing-l);
}
</style>