From 1ddbb78909a06c46e2bee640c85bf7670d85b8d5 Mon Sep 17 00:00:00 2001 From: Daria Date: Thu, 12 Jun 2025 13:57:23 +0300 Subject: [PATCH] feat(core): Add description to projects (#15611) --- .../src/dto/project/update-project.dto.ts | 2 + .../schemas/__tests__/project.schema.test.ts | 12 +++ .../api-types/src/schemas/project.schema.ts | 2 + packages/@n8n/db/src/entities/project.ts | 3 + ...47824239000-AddProjectDescriptionColumn.ts | 20 ++++ .../@n8n/db/src/migrations/mysqldb/index.ts | 2 + .../db/src/migrations/postgresdb/index.ts | 2 + .../@n8n/db/src/migrations/sqlite/index.ts | 3 +- .../cli/src/controllers/project.controller.ts | 9 +- packages/cli/src/requests.ts | 1 + .../cli/src/services/project.service.ee.ts | 7 +- .../endpoints-with-scopes-enabled.test.ts | 1 + .../integration/public-api/projects.test.ts | 1 + .../src/components/N8nFormInput/FormInput.vue | 2 +- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../components/Projects/ProjectHeader.test.ts | 29 +++--- .../src/components/Projects/ProjectHeader.vue | 99 ++++++++++++++++--- .../src/composables/useProjectPages.ts | 10 ++ .../editor-ui/src/stores/projects.store.ts | 4 +- .../editor-ui/src/types/projects.types.ts | 1 + .../src/utils/formatters/textFormatter.ts | 28 ++++++ .../editor-ui/src/views/ProjectSettings.vue | 31 +++++- 22 files changed, 235 insertions(+), 35 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1747824239000-AddProjectDescriptionColumn.ts create mode 100644 packages/frontend/editor-ui/src/utils/formatters/textFormatter.ts diff --git a/packages/@n8n/api-types/src/dto/project/update-project.dto.ts b/packages/@n8n/api-types/src/dto/project/update-project.dto.ts index b167ed88d3..de282f7b79 100644 --- a/packages/@n8n/api-types/src/dto/project/update-project.dto.ts +++ b/packages/@n8n/api-types/src/dto/project/update-project.dto.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { Z } from 'zod-class'; import { + projectDescriptionSchema, projectIconSchema, projectNameSchema, projectRelationSchema, @@ -10,5 +11,6 @@ import { export class UpdateProjectDto extends Z.class({ name: projectNameSchema.optional(), icon: projectIconSchema.optional(), + description: projectDescriptionSchema.optional(), relations: z.array(projectRelationSchema).optional(), }) {} diff --git a/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts index 7702c49f02..87c9d8f6d5 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/project.schema.test.ts @@ -2,6 +2,7 @@ import { projectNameSchema, projectTypeSchema, projectIconSchema, + projectDescriptionSchema, projectRelationSchema, } from '../project.schema'; @@ -56,6 +57,17 @@ describe('project.schema', () => { }); }); + describe('projectDescriptionSchema', () => { + test.each([ + { description: 'valid description', value: 'Nice Description', expected: true }, + { description: 'empty description', value: '', expected: true }, + { description: 'name too long', value: 'a'.repeat(513), expected: false }, + ])('should validate $description', ({ value, expected }) => { + const result = projectDescriptionSchema.safeParse(value); + expect(result.success).toBe(expected); + }); + }); + describe('projectRelationSchema', () => { test.each([ { diff --git a/packages/@n8n/api-types/src/schemas/project.schema.ts b/packages/@n8n/api-types/src/schemas/project.schema.ts index 67de6fbd02..8f15173815 100644 --- a/packages/@n8n/api-types/src/schemas/project.schema.ts +++ b/packages/@n8n/api-types/src/schemas/project.schema.ts @@ -12,6 +12,8 @@ export const projectIconSchema = z.object({ }); export type ProjectIcon = z.infer; +export const projectDescriptionSchema = z.string().max(512); + export const projectRelationSchema = z.object({ userId: z.string().min(1), role: projectRoleSchema.exclude(['project:personalOwner']), diff --git a/packages/@n8n/db/src/entities/project.ts b/packages/@n8n/db/src/entities/project.ts index f95a31da3a..7856a219d1 100644 --- a/packages/@n8n/db/src/entities/project.ts +++ b/packages/@n8n/db/src/entities/project.ts @@ -16,6 +16,9 @@ export class Project extends WithTimestampsAndStringId { @Column({ type: 'json', nullable: true }) icon: { type: 'emoji' | 'icon'; value: string } | null; + @Column({ type: 'varchar', length: 512, nullable: true }) + description: string | null; + @OneToMany('ProjectRelation', 'project') projectRelations: ProjectRelation[]; diff --git a/packages/@n8n/db/src/migrations/common/1747824239000-AddProjectDescriptionColumn.ts b/packages/@n8n/db/src/migrations/common/1747824239000-AddProjectDescriptionColumn.ts new file mode 100644 index 0000000000..8912a97696 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1747824239000-AddProjectDescriptionColumn.ts @@ -0,0 +1,20 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const columnName = 'description'; +const tableName = 'project'; + +export class AddProjectDescriptionColumn1747824239000 implements ReversibleMigration { + async up({ escape, runQuery }: MigrationContext) { + const escapedTableName = escape.tableName(tableName); + const escapedColumnName = escape.columnName(columnName); + + await runQuery(`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} VARCHAR(512)`); + } + + async down({ escape, runQuery }: MigrationContext) { + const escapedTableName = escape.tableName(tableName); + const escapedColumnName = escape.columnName(columnName); + + await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index ee5a072156..7d4c0f1992 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -86,6 +86,7 @@ import { ClearEvaluation1745322634000 } from '../common/1745322634000-CleanEvalu import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn'; import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; +import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn'; import type { Migration } from '../migration-types'; import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn'; @@ -179,4 +180,5 @@ export const mysqlMigrations: Migration[] = [ AddWorkflowArchivedColumn1745934666076, DropRoleTable1745934666077, ClearEvaluation1745322634000, + AddProjectDescriptionColumn1747824239000, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 6bde208a26..17792f31b0 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -86,6 +86,7 @@ import { ClearEvaluation1745322634000 } from '../common/1745322634000-CleanEvalu import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn'; import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; +import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -177,4 +178,5 @@ export const postgresMigrations: Migration[] = [ AddWorkflowArchivedColumn1745934666076, DropRoleTable1745934666077, ClearEvaluation1745322634000, + AddProjectDescriptionColumn1747824239000, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index 686dd2d87b..80c98d4a06 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -83,8 +83,8 @@ import { ClearEvaluation1745322634000 } from '../common/1745322634000-CleanEvalu import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn'; import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable'; +import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn'; import type { Migration } from '../migration-types'; - const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, WebhookModel1592445003908, @@ -171,6 +171,7 @@ const sqliteMigrations: Migration[] = [ AddWorkflowArchivedColumn1745934666076, DropRoleTable1745934666077, ClearEvaluation1745322634000, + AddProjectDescriptionColumn1747824239000, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 6e57ee2593..f1954c5ad4 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -167,7 +167,7 @@ export class ProjectController { _res: Response, @Param('projectId') projectId: string, ): Promise { - const [{ id, name, icon, type }, relations] = await Promise.all([ + const [{ id, name, icon, type, description }, relations] = await Promise.all([ this.projectsService.getProject(projectId), this.projectsService.getProjectRelations(projectId), ]); @@ -178,6 +178,7 @@ export class ProjectController { name, icon, type, + description, relations: relations.map((r) => ({ id: r.user.id, email: r.user.email, @@ -202,9 +203,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, icon, relations, description } = payload; + if ([name, icon, description].some((data) => typeof data === 'string')) { + await this.projectsService.updateProject(projectId, { name, icon, description }); } if (relations) { try { diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 7027b454b0..0f1ea66962 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -294,6 +294,7 @@ export declare namespace ProjectRequest { name: string | undefined; icon: ProjectIcon | null; type: ProjectType; + description: string | null; relations: ProjectRelationResponse[]; scopes: Scope[]; }; diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index b53a51a828..9fa380b485 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -242,10 +242,13 @@ export class ProjectService { } } - async updateProject(projectId: string, { name, icon }: UpdateProjectDto): Promise { + async updateProject( + projectId: string, + { name, icon, description }: UpdateProjectDto, + ): Promise { const result = await this.projectRepository.update( { id: projectId, type: 'team' }, - { name, icon }, + { name, icon, description }, ); if (!result.affected) { throw new ProjectNotFoundError(projectId); diff --git a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts index 6978734649..e2ae972f18 100644 --- a/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts +++ b/packages/cli/test/integration/public-api/endpoints-with-scopes-enabled.test.ts @@ -1063,6 +1063,7 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => { name: 'some-project', icon: null, type: 'team', + description: null, id: expect.any(String), createdAt: expect.any(String), updatedAt: expect.any(String), diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index f1a3eb475f..1f55896b3e 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -143,6 +143,7 @@ describe('Projects in Public API', () => { name: 'some-project', icon: null, type: 'team', + description: null, id: expect.any(String), createdAt: expect.any(String), updatedAt: expect.any(String), diff --git a/packages/frontend/@n8n/design-system/src/components/N8nFormInput/FormInput.vue b/packages/frontend/@n8n/design-system/src/components/N8nFormInput/FormInput.vue index adfb24c783..2d2061997f 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nFormInput/FormInput.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nFormInput/FormInput.vue @@ -215,7 +215,7 @@ defineExpose({ inputRef }); :required="required && showRequiredAsterisk" :size="labelSize" > -
+
({ useProjectPages: vi.fn().mockReturnValue({ isOverviewSubPage: false, isSharedSubPage: false, + isProjectsSubPage: false, }), })); @@ -130,9 +131,10 @@ describe('ProjectHeader', () => { expect(getByTestId('project-subtitle')).toHaveTextContent(personalSubtitle); }); - it('Team project: should render the correct title and subtitle', async () => { + it('Team project: should render the correct title and no subtitle if there is no description', async () => { vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false); vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false); + vi.spyOn(projectPages, 'isProjectsSubPage', 'get').mockReturnValue(true); const { getByTestId, queryByTestId, rerender } = renderComponent(); const projectName = 'My Project'; @@ -144,18 +146,23 @@ describe('ProjectHeader', () => { expect(queryByTestId('project-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'; + it('Team project: should render the correct title and subtitle if there is a description', async () => { + vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false); + vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false); + vi.spyOn(projectPages, 'isProjectsSubPage', 'get').mockReturnValue(true); + const { getByTestId, rerender } = renderComponent(); - const { getByText, queryByText } = renderComponent({ - slots: { - subtitle, - }, - }); + const projectName = 'My Project'; + const projectDescription = 'This is a team project description'; + projectsStore.currentProject = { + name: projectName, + description: projectDescription, + } as Project; - expect(getByText(subtitle)).toBeVisible(); - expect(queryByText(defaultSubtitle)).not.toBeInTheDocument(); + await rerender({}); + + expect(getByTestId('project-name')).toHaveTextContent(projectName); + expect(getByTestId('project-subtitle')).toHaveTextContent(projectDescription); }); it('should render ProjectTabs Settings if project is team project and user has update scope', () => { diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index fe6b5dafa6..f0cf524304 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -1,6 +1,7 @@