add project settings

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-12-03 20:58:24 +01:00
parent 1a915239c6
commit 57f1a3fc59
No known key found for this signature in database
32 changed files with 191 additions and 125 deletions

View file

@ -13,9 +13,11 @@ describe('CreateProjectDto', () => {
name: 'with name and emoji icon',
request: {
name: 'My Awesome Project',
icon: {
type: 'emoji',
value: '🚀',
settings: {
icon: {
type: 'emoji',
value: '🚀',
},
},
},
},
@ -23,9 +25,11 @@ describe('CreateProjectDto', () => {
name: 'with name and regular icon',
request: {
name: 'My Awesome Project',
icon: {
type: 'icon',
value: 'blah',
settings: {
icon: {
type: 'icon',
value: 'blah',
},
},
},
},
@ -39,28 +43,31 @@ describe('CreateProjectDto', () => {
test.each([
{
name: 'missing name',
request: { icon: { type: 'emoji', value: '🚀' } },
request: { settings: { icon: { type: 'emoji', value: '🚀' } } },
expectedErrorPath: ['name'],
},
{
name: 'empty name',
request: { name: '', icon: { type: 'emoji', value: '🚀' } },
request: { name: '', settings: { icon: { type: 'emoji', value: '🚀' } } },
expectedErrorPath: ['name'],
},
{
name: 'name too long',
request: { name: 'a'.repeat(256), icon: { type: 'emoji', value: '🚀' } },
request: { name: 'a'.repeat(256), settings: { icon: { type: 'emoji', value: '🚀' } } },
expectedErrorPath: ['name'],
},
{
name: 'invalid icon type',
request: { name: 'My Awesome Project', icon: { type: 'invalid', value: '🚀' } },
expectedErrorPath: ['icon', 'type'],
request: {
name: 'My Awesome Project',
settings: { icon: { type: 'invalid', value: '🚀' } },
},
expectedErrorPath: ['settings', 'icon', 'type'],
},
{
name: 'invalid icon value',
request: { name: 'My Awesome Project', icon: { type: 'emoji', value: '' } },
expectedErrorPath: ['icon', 'value'],
request: { name: 'My Awesome Project', settings: { icon: { type: 'emoji', value: '' } } },
expectedErrorPath: ['settings', 'icon', 'value'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = CreateProjectDto.safeParse(request);

View file

@ -13,9 +13,11 @@ describe('UpdateProjectDto', () => {
name: 'with name and emoji icon',
request: {
name: 'My Updated Project',
icon: {
type: 'emoji',
value: '🚀',
settings: {
icon: {
type: 'emoji',
value: '🚀',
},
},
},
},
@ -23,9 +25,11 @@ describe('UpdateProjectDto', () => {
name: 'with name and regular icon',
request: {
name: 'My Updated Project',
icon: {
type: 'icon',
value: 'blah',
settings: {
icon: {
type: 'icon',
value: 'blah',
},
},
},
},
@ -44,9 +48,11 @@ describe('UpdateProjectDto', () => {
name: 'with all fields',
request: {
name: 'My Updated Project',
icon: {
type: 'emoji',
value: '🚀',
settings: {
icon: {
type: 'emoji',
value: '🚀',
},
},
relations: [
{
@ -76,13 +82,13 @@ describe('UpdateProjectDto', () => {
},
{
name: 'invalid icon type',
request: { icon: { type: 'invalid', value: '🚀' } },
expectedErrorPath: ['icon', 'type'],
request: { settings: { icon: { type: 'invalid', value: '🚀' } } },
expectedErrorPath: ['settings', 'icon', 'type'],
},
{
name: 'invalid icon value',
request: { icon: { type: 'emoji', value: '' } },
expectedErrorPath: ['icon', 'value'],
request: { settings: { icon: { type: 'emoji', value: '' } } },
expectedErrorPath: ['settings', 'icon', 'value'],
},
{
name: 'invalid relations userId',

View file

@ -1,8 +1,8 @@
import { Z } from 'zod-class';
import { projectIconSchema, projectNameSchema } from '../../schemas/project.schema';
import { projectNameSchema, projectSettingsSchema } from '../../schemas/project.schema';
export class CreateProjectDto extends Z.class({
name: projectNameSchema,
icon: projectIconSchema.optional(),
settings: projectSettingsSchema.optional(),
}) {}

View file

@ -2,13 +2,13 @@ import { z } from 'zod';
import { Z } from 'zod-class';
import {
projectIconSchema,
projectNameSchema,
projectSettingsSchema,
projectRelationSchema,
} from '../../schemas/project.schema';
export class UpdateProjectDto extends Z.class({
name: projectNameSchema.optional(),
icon: projectIconSchema.optional(),
settings: projectSettingsSchema.optional(),
relations: z.array(projectRelationSchema).optional(),
}) {}

View file

@ -15,6 +15,7 @@ export { passwordSchema } from './schemas/password.schema';
export type {
ProjectType,
ProjectIcon,
ProjectSettings,
ProjectRole,
ProjectRelation,
} from './schemas/project.schema';

View file

@ -1,9 +1,9 @@
import {
projectNameSchema,
projectTypeSchema,
projectIconSchema,
projectRoleSchema,
projectRelationSchema,
projectSettingsSchema,
} from '../project.schema';
describe('project.schema', () => {
@ -29,31 +29,33 @@ describe('project.schema', () => {
});
});
describe('projectIconSchema', () => {
test.each([
{
name: 'valid emoji icon',
value: { type: 'emoji', value: '🚀' },
expected: true,
},
{
name: 'valid icon',
value: { type: 'icon', value: 'blah' },
expected: true,
},
{
name: 'invalid icon type',
value: { type: 'invalid', value: '🚀' },
expected: false,
},
{
name: 'empty icon value',
value: { type: 'emoji', value: '' },
expected: false,
},
])('should validate $name', ({ value, expected }) => {
const result = projectIconSchema.safeParse(value);
expect(result.success).toBe(expected);
describe('projectSettingsSchema', () => {
describe('.icon', () => {
test.each([
{
name: 'valid emoji icon',
value: { type: 'emoji', value: '🚀' },
expected: true,
},
{
name: 'valid icon',
value: { type: 'icon', value: 'blah' },
expected: true,
},
{
name: 'invalid icon type',
value: { type: 'invalid', value: '🚀' },
expected: false,
},
{
name: 'empty icon value',
value: { type: 'emoji', value: '' },
expected: false,
},
])('should validate $name', ({ value, expected }) => {
const result = projectSettingsSchema.safeParse({ icon: value });
expect(result.success).toBe(expected);
});
});
});

View file

@ -5,12 +5,20 @@ export const projectNameSchema = z.string().min(1).max(255);
export const projectTypeSchema = z.enum(['personal', 'team']);
export type ProjectType = z.infer<typeof projectTypeSchema>;
export const projectIconSchema = z.object({
const projectIconSchema = z.object({
type: z.enum(['emoji', 'icon']),
value: z.string().min(1),
});
export type ProjectIcon = z.infer<typeof projectIconSchema>;
export const projectSettingsSchema = z.object({
icon: projectIconSchema.optional(),
dedicatedQueue: z.boolean().default(false).optional(),
// TODO: add timezone, callerPolicy, saving options, etc
// TODO: add 'workflowsFromSameProject` to `CallerPolicy`
});
export type ProjectSettings = z.infer<typeof projectSettingsSchema>;
export const projectRoleSchema = z.enum([
'project:personalOwner', // personalOwner is only used for personal projects
'project:admin',

View file

@ -171,7 +171,7 @@ export class ProjectController {
_res: Response,
@Param('projectId') projectId: string,
): Promise<ProjectRequest.ProjectWithRelations> {
const [{ id, name, icon, type }, relations] = await Promise.all([
const [{ id, name, type, settings }, relations] = await Promise.all([
this.projectsService.getProject(projectId),
this.projectsService.getProjectRelations(projectId),
]);
@ -180,8 +180,8 @@ export class ProjectController {
return {
id,
name,
icon,
type,
settings,
relations: relations.map((r) => ({
id: r.user.id,
email: r.user.email,
@ -206,9 +206,9 @@ export class ProjectController {
@Body payload: UpdateProjectDto,
@Param('projectId') projectId: string,
) {
const { name, icon, relations } = payload;
if (name || icon) {
await this.projectsService.updateProject(projectId, { name, icon });
const { name, settings, relations } = payload;
if (name || settings) {
await this.projectsService.updateProject(projectId, { name, settings });
}
if (relations) {
try {

View file

@ -129,6 +129,12 @@ export class Column {
options.default = isSqlite
? "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
: 'CURRENT_TIMESTAMP(3)';
}
if (type === 'json' && isSqlite) {
if (typeof this.defaultValue !== 'string') {
this.defaultValue = JSON.stringify(this.defaultValue);
}
options.default = `'${this.defaultValue as string}'`;
} else {
options.default = this.defaultValue;
}

View file

@ -1,7 +1,7 @@
import { ProjectIcon, ProjectType } from '@n8n/api-types';
import { ProjectSettings, ProjectType } from '@n8n/api-types';
import { Column, Entity, OneToMany } from '@n8n/typeorm';
import { WithTimestampsAndStringId } from './abstract-entity';
import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
import type { ProjectRelation } from './project-relation';
import type { SharedCredentials } from './shared-credentials';
import type { SharedWorkflow } from './shared-workflow';
@ -14,8 +14,8 @@ export class Project extends WithTimestampsAndStringId {
@Column({ type: 'varchar', length: 36 })
type: ProjectType;
@Column({ type: 'json', nullable: true })
icon: ProjectIcon;
@Column({ type: jsonColumnType })
settings: ProjectSettings = {};
@OneToMany('ProjectRelation', 'project')
projectRelations: ProjectRelation[];

View file

@ -0,0 +1,14 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
export class AddProjectSettings1738496517619 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, dropColumns, column } }: MigrationContext) {
// TODO: copy over the icons data before dropping that column
await addColumns('project', [column('settings').json.notNull.default({})]);
await dropColumns('project', ['icon']);
}
async down({ schemaBuilder: { addColumns, dropColumns, column } }: MigrationContext) {
await addColumns('project', [column('icon').json]);
await dropColumns('project', ['settings']);
}
}

View file

@ -78,6 +78,7 @@ import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/173
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable';
import { AddProjectSettings1738496517619 } from '../common/1738496517619-AddProjectSettings';
export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238,
@ -158,4 +159,5 @@ export const mysqlMigrations: Migration[] = [
AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
CreateTestCaseExecutionTable1736947513045,
AddProjectSettings1738496517619,
];

View file

@ -78,6 +78,7 @@ import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/173
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable';
import { AddProjectSettings1738496517619 } from '../common/1738496517619-AddProjectSettings';
export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
@ -158,4 +159,5 @@ export const postgresMigrations: Migration[] = [
AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
CreateTestCaseExecutionTable1736947513045,
AddProjectSettings1738496517619,
];

View file

@ -0,0 +1,5 @@
import { AddProjectSettings1738496517619 as BaseMigration } from '../common/1738496517619-AddProjectSettings';
export class AddProjectSettings1738496517619 extends BaseMigration {
transaction = false as const;
}

View file

@ -42,6 +42,7 @@ import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from './17286
import { AddProjectIcons1729607673469 } from './1729607673469-AddProjectIcons';
import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition';
import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString';
import { AddProjectSettings1738496517619 } from './1738496517619-AddProjectSettings';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
@ -152,6 +153,7 @@ const sqliteMigrations: Migration[] = [
AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779,
CreateTestCaseExecutionTable1736947513045,
AddProjectSettings1738496517619,
];
export { sqliteMigrations };

View file

@ -1,4 +1,4 @@
import type { ProjectIcon, ProjectRole, ProjectType } from '@n8n/api-types';
import type { ProjectRole, ProjectType, ProjectSettings } from '@n8n/api-types';
import type { Scope } from '@n8n/permissions';
import type express from 'express';
import type {
@ -119,7 +119,7 @@ export namespace ListQuery {
}
type SlimUser = Pick<IUser, 'id' | 'email' | 'firstName' | 'lastName'>;
export type SlimProject = Pick<Project, 'id' | 'type' | 'name' | 'icon'>;
export type SlimProject = Pick<Project, 'id' | 'type' | 'name' | 'settings'>;
export function hasSharing(
workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[],
@ -383,8 +383,8 @@ export declare namespace ProjectRequest {
type ProjectWithRelations = {
id: string;
name: string | undefined;
icon: ProjectIcon;
type: ProjectType;
settings: ProjectSettings;
relations: ProjectRelationResponse[];
scopes: Scope[];
};

View file

@ -37,7 +37,9 @@ import type {
@Service()
export class ScalingService {
private queue: JobQueue;
private sharedQueue: JobQueue;
private readonly dedicatedQueues = new Map<string, JobQueue>();
constructor(
private readonly logger: Logger,
@ -63,7 +65,7 @@ export class ScalingService {
const bullPrefix = this.globalConfig.queue.bull.prefix;
const prefix = service.toValidPrefix(bullPrefix);
this.queue = new BullQueue(QUEUE_NAME, {
this.sharedQueue = new BullQueue(QUEUE_NAME, {
prefix,
settings: this.globalConfig.queue.bull.settings,
createClient: (type) => service.createClient({ type: `${type}(bull)` }),
@ -90,7 +92,7 @@ export class ScalingService {
this.assertWorker();
this.assertQueue();
void this.queue.process(JOB_TYPE_NAME, concurrency, async (job: Job) => {
void this.sharedQueue.process(JOB_TYPE_NAME, concurrency, async (job: Job) => {
try {
if (!this.hasValidJobData(job)) {
throw new ApplicationError('Worker received invalid job', {
@ -141,7 +143,7 @@ export class ScalingService {
private async stopMain() {
if (this.instanceSettings.isSingleMain) {
await this.queue.pause(true, true); // no more jobs will be picked up
await this.sharedQueue.pause(true, true); // no more jobs will be picked up
this.logger.debug('Queue paused');
}
@ -164,7 +166,7 @@ export class ScalingService {
}
async pingQueue() {
await this.queue.client.ping();
await this.sharedQueue.client.ping();
}
// #endregion
@ -172,7 +174,7 @@ export class ScalingService {
// #region Jobs
async getPendingJobCounts() {
const { active, waiting } = await this.queue.getJobCounts();
const { active, waiting } = await this.sharedQueue.getJobCounts();
return { active, waiting };
}
@ -192,7 +194,9 @@ export class ScalingService {
removeOnFail: true,
};
const job = await this.queue.add(JOB_TYPE_NAME, jobData, jobOptions);
const queue = this.dedicatedQueues.get(jobData.projectId!) ?? this.sharedQueue;
const job = await queue.add(JOB_TYPE_NAME, jobData, jobOptions);
const { executionId } = jobData;
const jobId = job.id;
@ -203,11 +207,15 @@ export class ScalingService {
}
async getJob(jobId: JobId) {
return await this.queue.getJob(jobId);
// TODO: keep a jobId -> projectId mapping in redis
// TODO: use the project specific queue if available
return await this.sharedQueue.getJob(jobId);
}
async findJobsByStatus(statuses: JobStatus[]) {
const jobs = await this.queue.getJobs(statuses);
// TODO: keep a jobId -> projectId mapping in redis
// TODO: use the project specific queue if available
const jobs = await this.sharedQueue.getJobs(statuses);
return jobs.filter((job) => job !== null);
}
@ -261,13 +269,13 @@ export class ScalingService {
* Register listeners on a `worker` process for Bull queue events.
*/
private registerWorkerListeners() {
this.queue.on('global:progress', (jobId: JobId, msg: unknown) => {
this.sharedQueue.on('global:progress', (jobId: JobId, msg: unknown) => {
if (!this.isJobMessage(msg)) return;
if (msg.kind === 'abort-job') this.jobProcessor.stopJob(jobId);
});
this.queue.on('error', (error: Error) => {
this.sharedQueue.on('error', (error: Error) => {
if ('code' in error && error.code === 'ECONNREFUSED') return; // handled by RedisClientService.retryStrategy
/**
@ -290,7 +298,7 @@ export class ScalingService {
* Register listeners on a `main` or `webhook` process for Bull queue events.
*/
private registerMainOrWebhookListeners() {
this.queue.on('error', (error: Error) => {
this.sharedQueue.on('error', (error: Error) => {
if ('code' in error && error.code === 'ECONNREFUSED') return; // handled by RedisClientService.retryStrategy
this.logger.error('Queue errored', { error });
@ -298,7 +306,7 @@ export class ScalingService {
throw error;
});
this.queue.on('global:progress', (jobId: JobId, msg: unknown) => {
this.sharedQueue.on('global:progress', (jobId: JobId, msg: unknown) => {
if (!this.isJobMessage(msg)) return;
// completion and failure are reported via `global:progress` to convey more details
@ -338,8 +346,8 @@ export class ScalingService {
});
if (this.isQueueMetricsEnabled) {
this.queue.on('global:completed', () => this.jobCounters.completed++);
this.queue.on('global:failed', () => this.jobCounters.failed++);
this.sharedQueue.on('global:completed', () => this.jobCounters.completed++);
this.sharedQueue.on('global:failed', () => this.jobCounters.failed++);
}
}
@ -367,7 +375,7 @@ export class ScalingService {
}
private assertQueue() {
if (this.queue) return;
if (this.sharedQueue) return;
throw new ApplicationError('This method must be called after `setupQueue`');
}

View file

@ -11,6 +11,7 @@ export type JobId = Job['id'];
export type JobData = {
executionId: string;
projectId?: string;
loadStaticData: boolean;
pushRef?: string;
};

View file

@ -81,21 +81,17 @@ export class OwnershipService {
for (const sharedEntity of shared) {
const { project, role } = sharedEntity;
const slimProject = {
id: project.id,
type: project.type,
name: project.name,
settings: project.settings,
};
if (role === 'credential:owner' || role === 'workflow:owner') {
entity.homeProject = {
id: project.id,
type: project.type,
name: project.name,
icon: project.icon,
};
entity.homeProject = slimProject;
} else {
entity.sharedWithProjects.push({
id: project.id,
type: project.type,
name: project.name,
icon: project.icon,
});
entity.sharedWithProjects.push(slimProject);
}
}

View file

@ -188,7 +188,7 @@ export class ProjectService {
async updateProject(
projectId: string,
data: Pick<UpdateProjectDto, 'name' | 'icon'>,
data: Pick<UpdateProjectDto, 'name' | 'settings'>,
): Promise<Project> {
const result = await this.projectRepository.update({ id: projectId, type: 'team' }, data);

View file

@ -344,6 +344,7 @@ export class WorkflowRunner {
): Promise<void> {
const jobData: JobData = {
executionId,
projectId: data.projectId,
loadStaticData: !!loadStaticData,
pushRef: data.pushRef,
};

View file

@ -10,7 +10,9 @@ import { ProjectTypes } from '@/types/projects.types';
export const createProjectSharingData = (projectType?: ProjectType): ProjectSharingData => ({
id: faker.string.uuid(),
name: faker.lorem.words({ min: 1, max: 3 }),
icon: { type: 'icon', value: 'folder' },
settings: {
icon: { type: 'icon', value: 'folder' },
},
type: projectType ?? ProjectTypes.Personal,
createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.recent().toISOString(),
@ -30,7 +32,9 @@ export function createTestProject(data: Partial<Project>): Project {
return {
id: faker.string.uuid(),
name: faker.lorem.words({ min: 1, max: 3 }),
icon: { type: 'icon', value: 'folder' },
settings: {
icon: { type: 'icon', value: 'folder' },
},
createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.recent().toISOString(),
type: ProjectTypes.Team,

View file

@ -2,8 +2,9 @@
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { N8nButton, N8nTooltip } from 'n8n-design-system';
import type { ProjectIcon } from '@n8n/api-types';
import { useI18n } from '@/composables/useI18n';
import { type ProjectIcon, ProjectTypes } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { getResourcePermissions } from '@/permissions';
@ -21,7 +22,7 @@ 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' };
return projectsStore.currentProject.settings.icon ?? { type: 'icon', value: 'layer-group' };
} else {
return { type: 'icon', value: 'home' };
}

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ProjectIcon } from '@/types/projects.types';
import type { ProjectIcon } from '@n8n/api-types';
type Props = {
icon: ProjectIcon;

View file

@ -33,7 +33,7 @@ const home = computed<IMenuItem>(() => ({
const getProjectMenuItem = (project: ProjectListItem) => ({
id: project.id,
label: project.name,
icon: project.icon,
icon: project.settings.icon,
route: {
to: {
name: VIEWS.PROJECTS_WORKFLOWS,

View file

@ -21,8 +21,8 @@ const processedName = computed(() => {
});
const projectIcon = computed(() => {
if (props.project.icon) {
return props.project.icon;
if (props.project.settings.icon) {
return props.project.settings.icon;
}
return null;
});

View file

@ -173,7 +173,9 @@ export const useGlobalEntityCreation = () => {
try {
const newProject = await projectsStore.createProject({
name: i18n.baseText('projects.settings.newProjectName'),
icon: { type: 'icon', value: 'layer-group' },
settings: {
icon: { type: 'icon', value: 'layer-group' },
},
});
await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } });
toast.showMessage({

View file

@ -32,7 +32,6 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
const projectsCount = ref<ProjectsCount>({
personal: 0,
team: 0,
public: 0,
});
const projectNavActiveIdState = ref<string | string[] | null>(null);
@ -120,14 +119,14 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
): Promise<void> => {
await projectsApi.updateProject(rootStore.restApiContext, id, projectData);
const projectIndex = myProjects.value.findIndex((p) => p.id === id);
const { name, icon } = projectData;
const { name, settings } = projectData;
if (projectIndex !== -1) {
myProjects.value[projectIndex].name = name;
myProjects.value[projectIndex].icon = icon;
myProjects.value[projectIndex].settings = settings;
}
if (currentProject.value) {
currentProject.value.name = name;
currentProject.value.icon = icon;
currentProject.value.settings = settings;
}
if (projectData.relations) {
await getProject(id);

View file

@ -1,3 +1,4 @@
import type { ProjectSettings } from '@n8n/api-types';
import type { Scope } from '@n8n/permissions';
import type { IUserResponse } from '@/Interface';
import type { ProjectRole } from '@/types/roles.types';
@ -5,7 +6,6 @@ import type { ProjectRole } from '@/types/roles.types';
export const ProjectTypes = {
Personal: 'personal',
Team: 'team',
Public: 'public',
} as const;
type ProjectTypeKeys = typeof ProjectTypes;
@ -18,8 +18,8 @@ export type ProjectRelationPayload = { userId: string; role: ProjectRole };
export type ProjectSharingData = {
id: string;
name: string | null;
icon: ProjectIcon | null;
type: ProjectType;
settings: ProjectSettings;
createdAt: string;
updatedAt: string;
};
@ -32,8 +32,3 @@ export type ProjectListItem = ProjectSharingData & {
scopes?: Scope[];
};
export type ProjectsCount = Record<ProjectType, number>;
export type ProjectIcon = {
type: 'icon' | 'emoji';
value: string;
};

View file

@ -66,7 +66,7 @@ describe('ProjectSettings', () => {
id: '123',
type: 'team',
name: 'Test Project',
icon: { type: 'icon', value: 'folder' },
settings: { icon: { type: 'icon', value: 'folder' } },
relations: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),

View file

@ -3,11 +3,11 @@ import { computed, ref, watch, onBeforeMount, onMounted, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { deepCopy } from 'n8n-workflow';
import { N8nFormInput } from 'n8n-design-system';
import type { ProjectIcon } from '@n8n/api-types';
import { useUsersStore } from '@/stores/users.store';
import type { IUser } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectIcon } from '@/types/projects.types';
import { type Project, type ProjectRelation } from '@/types/projects.types';
import { useToast } from '@/composables/useToast';
import { VIEWS } from '@/constants';
@ -194,7 +194,9 @@ const updateProject = async () => {
try {
await projectsStore.updateProject(projectsStore.currentProject.id, {
name: formData.value.name!,
icon: projectIcon.value,
settings: {
icon: projectIcon.value,
},
relations: formData.value.relations.map((r: ProjectRelation) => ({
userId: r.id,
role: r.role,
@ -269,8 +271,8 @@ watch(
: [];
await nextTick();
selectProjectNameIfMatchesDefault();
if (projectsStore.currentProject?.icon) {
projectIcon.value = projectsStore.currentProject.icon;
if (projectsStore.currentProject?.settings.icon) {
projectIcon.value = projectsStore.currentProject.settings.icon;
}
},
{ immediate: true },

View file

@ -125,8 +125,10 @@ export interface IUser {
export type ProjectSharingData = {
id: string;
name: string | null;
icon: { type: 'emoji' | 'icon'; value: string } | null;
type: 'personal' | 'team' | 'public';
type: 'personal' | 'team';
settings: {
icon?: { type: 'emoji' | 'icon'; value: string };
};
createdAt: string;
updatedAt: string;
};