diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index 3e1b2fd46a..09d7a341ce 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -29,7 +29,11 @@ export const getAddProjectButton = () => { return cy.get('@button'); }; - +export const getAddFirstProjectButton = () => cy.getByTestId('add-first-project-button'); +export const getIconPickerButton = () => cy.getByTestId('icon-picker-button'); +export const getIconPickerTab = (tab: string) => cy.getByTestId('icon-picker-tabs').contains(tab); +export const getIconPickerIcons = () => cy.getByTestId('icon-picker-icon'); +export const getIconPickerEmojis = () => cy.getByTestId('icon-picker-emoji'); // export const getAddProjectButton = () => // cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible'); export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 327bff4f93..efd18bca74 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -15,7 +15,7 @@ import { NDV, MainSidebar, } from '../pages'; -import { clearNotifications } from '../pages/notifications'; +import { clearNotifications, successToast } from '../pages/notifications'; import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils'; const workflowsPage = new WorkflowsPage(); @@ -830,4 +830,23 @@ describe('Projects', { disableAutoLogin: true }, () => { .should('not.have.length'); }); }); + + it('should set and update project icon', () => { + const DEFAULT_ICON = 'fa-layer-group'; + const NEW_PROJECT_NAME = 'Test Project'; + + cy.signinAsAdmin(); + cy.visit(workflowsPage.url); + projects.createProject(NEW_PROJECT_NAME); + // New project should have default icon + projects.getIconPickerButton().find('svg').should('have.class', DEFAULT_ICON); + // Choose another icon + projects.getIconPickerButton().click(); + projects.getIconPickerTab('Emojis').click(); + projects.getIconPickerEmojis().first().click(); + // Project should be updated with new icon + successToast().contains('Project icon updated successfully'); + projects.getIconPickerButton().should('contain', '😀'); + projects.getMenuItems().contains(NEW_PROJECT_NAME).should('contain', '😀'); + }); }); diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index d16883f3dd..ab48f38c5b 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -51,7 +51,12 @@ export class ProjectController { @Licensed('feat:projectRole:admin') async createProject(req: ProjectRequest.Create) { try { - const project = await this.projectsService.createTeamProject(req.body.name, req.user); + const project = await this.projectsService.createTeamProject( + req.body.name, + req.user, + undefined, + req.body.icon, + ); this.eventService.emit('team-project-created', { userId: req.user.id, @@ -163,7 +168,7 @@ export class ProjectController { @Get('/:projectId') @ProjectScope('project:read') async getProject(req: ProjectRequest.Get): Promise { - const [{ id, name, type }, relations] = await Promise.all([ + const [{ id, name, icon, type }, relations] = await Promise.all([ this.projectsService.getProject(req.params.projectId), this.projectsService.getProjectRelations(req.params.projectId), ]); @@ -172,6 +177,7 @@ export class ProjectController { return { id, name, + icon, type, relations: relations.map((r) => ({ id: r.user.id, @@ -193,7 +199,7 @@ export class ProjectController { @ProjectScope('project:update') async updateProject(req: ProjectRequest.Update) { if (req.body.name) { - await this.projectsService.updateProject(req.body.name, req.params.projectId); + await this.projectsService.updateProject(req.body.name, req.params.projectId, req.body.icon); } if (req.body.relations) { try { diff --git a/packages/cli/src/databases/entities/project.ts b/packages/cli/src/databases/entities/project.ts index 88c4ed009a..aa867807fd 100644 --- a/packages/cli/src/databases/entities/project.ts +++ b/packages/cli/src/databases/entities/project.ts @@ -6,6 +6,7 @@ import type { SharedCredentials } from './shared-credentials'; import type { SharedWorkflow } from './shared-workflow'; export type ProjectType = 'personal' | 'team'; +export type ProjectIcon = { type: 'emoji' | 'icon'; value: string } | null; @Entity() export class Project extends WithTimestampsAndStringId { @@ -15,6 +16,9 @@ export class Project extends WithTimestampsAndStringId { @Column({ length: 36 }) type: ProjectType; + @Column({ type: 'json', nullable: true }) + icon: ProjectIcon; + @OneToMany('ProjectRelation', 'project') projectRelations: ProjectRelation[]; diff --git a/packages/cli/src/databases/migrations/common/1729607673469-AddProjectIcons.ts b/packages/cli/src/databases/migrations/common/1729607673469-AddProjectIcons.ts new file mode 100644 index 0000000000..e2c710428a --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1729607673469-AddProjectIcons.ts @@ -0,0 +1,10 @@ +import type { MigrationContext, ReversibleMigration } from '@/databases/types'; +export class AddProjectIcons1729607673469 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns('project', [column('icon').json]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns('project', ['icon']); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 2fc39079d4..89df273472 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -69,6 +69,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart'; import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText'; +import { AddProjectIcons1729607673469 } from '../common/1729607673469-AddProjectIcons'; import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; @@ -152,4 +153,5 @@ export const mysqlMigrations: Migration[] = [ CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, AddManagedColumnToCredentialsTable1734479635324, + AddProjectIcons1729607673469, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 605c156003..d5d72282f4 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -69,6 +69,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart'; import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText'; +import { AddProjectIcons1729607673469 } from '../common/1729607673469-AddProjectIcons'; import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; @@ -152,4 +153,5 @@ export const postgresMigrations: Migration[] = [ CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, AddManagedColumnToCredentialsTable1734479635324, + AddProjectIcons1729607673469, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1729607673469-AddProjectIcons.ts b/packages/cli/src/databases/migrations/sqlite/1729607673469-AddProjectIcons.ts new file mode 100644 index 0000000000..f5eb94ffc0 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1729607673469-AddProjectIcons.ts @@ -0,0 +1,5 @@ +import { AddProjectIcons1729607673469 as BaseMigration } from '../common/1729607673469-AddProjectIcons'; + +export class AddProjectIcons1729607673469 extends BaseMigration { + transaction = false as const; +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 0981ece99b..7fec59baf2 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -39,6 +39,7 @@ import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping'; import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable'; import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from './1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping'; +import { AddProjectIcons1729607673469 } from './1729607673469-AddProjectIcons'; import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition'; import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; @@ -146,6 +147,7 @@ const sqliteMigrations: Migration[] = [ CreateTestRun1732549866705, AddMockedNodesColumnToTestDefinition1733133775640, AddManagedColumnToCredentialsTable1734479635324, + AddProjectIcons1729607673469, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index fdde592f28..8110682556 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -13,7 +13,7 @@ import type { } from 'n8n-workflow'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; -import type { Project, ProjectType } from '@/databases/entities/project'; +import type { Project, ProjectIcon, ProjectType } from '@/databases/entities/project'; import type { AssignableRole, GlobalRole, User } from '@/databases/entities/user'; import type { Variables } from '@/databases/entities/variables'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; @@ -123,7 +123,7 @@ export namespace ListQuery { } type SlimUser = Pick; -export type SlimProject = Pick; +export type SlimProject = Pick; export function hasSharing( workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], @@ -440,6 +440,7 @@ export declare namespace ProjectRequest { Project, { name: string; + icon?: ProjectIcon; } >; @@ -468,6 +469,7 @@ export declare namespace ProjectRequest { type ProjectWithRelations = { id: string; name: string | undefined; + icon: ProjectIcon; type: ProjectType; relations: ProjectRelationResponse[]; scopes: Scope[]; @@ -477,7 +479,11 @@ export declare namespace ProjectRequest { type Update = AuthenticatedRequest< { projectId: string }, {}, - { name?: string; relations?: ProjectRelationPayload[] } + { + name?: string; + relations?: ProjectRelationPayload[]; + icon?: { type: 'icon' | 'emoji'; value: string }; + } >; type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>; } diff --git a/packages/cli/src/services/ownership.service.ts b/packages/cli/src/services/ownership.service.ts index d0ad442bc1..22403304a1 100644 --- a/packages/cli/src/services/ownership.service.ts +++ b/packages/cli/src/services/ownership.service.ts @@ -87,12 +87,14 @@ export class OwnershipService { id: project.id, type: project.type, name: project.name, + icon: project.icon, }; } else { entity.sharedWithProjects.push({ id: project.id, type: project.type, name: project.name, + icon: project.icon, }); } } diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index d78e3a07e1..af4505217f 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -7,7 +7,8 @@ import { ApplicationError } from 'n8n-workflow'; import Container, { Service } from 'typedi'; import { UNLIMITED_LICENSE_QUOTA } from '@/constants'; -import { Project, type ProjectType } from '@/databases/entities/project'; +import type { ProjectIcon, ProjectType } from '@/databases/entities/project'; +import { Project } from '@/databases/entities/project'; import { ProjectRelation } from '@/databases/entities/project-relation'; import type { ProjectRole } from '@/databases/entities/project-relation'; import type { User } from '@/databases/entities/user'; @@ -167,7 +168,12 @@ export class ProjectService { return await this.projectRelationRepository.getPersonalProjectOwners(projectIds); } - async createTeamProject(name: string, adminUser: User, id?: string): Promise { + async createTeamProject( + name: string, + adminUser: User, + id?: string, + icon?: ProjectIcon, + ): Promise { const limit = this.license.getTeamProjectLimit(); if ( limit !== UNLIMITED_LICENSE_QUOTA && @@ -180,6 +186,7 @@ export class ProjectService { this.projectRepository.create({ id, name, + icon, type: 'team', }), ); @@ -190,7 +197,11 @@ export class ProjectService { return project; } - async updateProject(name: string, projectId: string): Promise { + async updateProject( + name: string, + projectId: string, + icon?: { type: 'icon' | 'emoji'; value: string }, + ): Promise { const result = await this.projectRepository.update( { id: projectId, @@ -198,6 +209,7 @@ export class ProjectService { }, { name, + icon, }, ); diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index 1606de934d..962f5591d5 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -540,6 +540,7 @@ describe('GET /credentials/:id', () => { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), type: ownerPersonalProject.type, + icon: null, }); expect(firstCredential.sharedWithProjects).toHaveLength(0); @@ -629,17 +630,20 @@ describe('GET /credentials/:id', () => { homeProject: { id: member1PersonalProject.id, name: member1.createPersonalProjectName(), + icon: null, type: 'personal', }, sharedWithProjects: expect.arrayContaining([ { id: member2PersonalProject.id, name: member2.createPersonalProjectName(), + icon: null, type: member2PersonalProject.type, }, { id: member3PersonalProject.id, name: member3.createPersonalProjectName(), + icon: null, type: member3PersonalProject.type, }, ]), diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index f815d9d07b..4283382558 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -131,6 +131,7 @@ describe('Projects in Public API', () => { expect(response.status).toBe(201); expect(response.body).toEqual({ name: 'some-project', + icon: null, type: 'team', id: expect.any(String), createdAt: expect.any(String), diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index d0ee2f3d67..e7e00d63c8 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -441,6 +441,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], @@ -456,6 +457,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], @@ -833,6 +835,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], @@ -842,6 +845,7 @@ describe('GET /workflows', () => { homeProject: { id: ownerPersonalProject.id, name: owner.createPersonalProjectName(), + icon: null, type: ownerPersonalProject.type, }, sharedWithProjects: [], diff --git a/packages/design-system/package.json b/packages/design-system/package.json index d4a7e1dcec..2ecd0513ac 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -45,6 +45,7 @@ "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/vue-fontawesome": "^3.0.3", "element-plus": "2.4.3", + "is-emoji-supported": "^0.0.5", "markdown-it": "^13.0.2", "markdown-it-emoji": "^2.0.2", "markdown-it-link-attributes": "^4.0.1", @@ -55,5 +56,8 @@ "vue-boring-avatars": "^1.3.0", "vue-router": "catalog:frontend", "xss": "catalog:" + }, + "peerDependencies": { + "@vueuse/core": "*" } } diff --git a/packages/design-system/src/__tests__/setup.ts b/packages/design-system/src/__tests__/setup.ts index 981c9d5a60..5c091e2925 100644 --- a/packages/design-system/src/__tests__/setup.ts +++ b/packages/design-system/src/__tests__/setup.ts @@ -15,3 +15,8 @@ window.ResizeObserver = observe: vi.fn(), unobserve: vi.fn(), })); + +// Globally mock is-emoji-supported +vi.mock('is-emoji-supported', () => ({ + isEmojiSupported: () => true, +})); diff --git a/packages/design-system/src/components/N8nIconPicker/IconPicker.stories.ts b/packages/design-system/src/components/N8nIconPicker/IconPicker.stories.ts new file mode 100644 index 0000000000..b03a0cc332 --- /dev/null +++ b/packages/design-system/src/components/N8nIconPicker/IconPicker.stories.ts @@ -0,0 +1,57 @@ +import { action } from '@storybook/addon-actions'; +import type { StoryFn } from '@storybook/vue3'; + +import { TEST_ICONS } from './constants'; +import type { Icon } from './IconPicker.vue'; +import N8nIconPicker from './IconPicker.vue'; + +export default { + title: 'Atoms/Icon Picker', + component: N8nIconPicker, + argTypes: { + buttonTooltip: { + control: 'text', + }, + buttonSize: { + type: 'select', + options: ['small', 'large'], + }, + }, +}; + +function createTemplate(icon: Icon): StoryFn { + return (args, { argTypes }) => ({ + components: { N8nIconPicker }, + props: Object.keys(argTypes), + setup: () => ({ args }), + data: () => ({ + icon, + }), + template: + '
', + methods: { + onIconSelected: action('iconSelected'), + }, + }); +} + +const DefaultTemplate = createTemplate({ type: 'icon', value: 'smile' }); +export const Default = DefaultTemplate.bind({}); +Default.args = { + buttonTooltip: 'Select an icon', + availableIcons: TEST_ICONS, +}; + +const CustomTooltipTemplate = createTemplate({ type: 'icon', value: 'layer-group' }); +export const WithCustomIconAndTooltip = CustomTooltipTemplate.bind({}); +WithCustomIconAndTooltip.args = { + availableIcons: [...TEST_ICONS], + buttonTooltip: 'Select something...', +}; + +const OnlyEmojiTemplate = createTemplate({ type: 'emoji', value: '🔥' }); +export const OnlyEmojis = OnlyEmojiTemplate.bind({}); +OnlyEmojis.args = { + buttonTooltip: 'Select an emoji', + availableIcons: [], +}; diff --git a/packages/design-system/src/components/N8nIconPicker/IconPicker.test.ts b/packages/design-system/src/components/N8nIconPicker/IconPicker.test.ts new file mode 100644 index 0000000000..f3295ba5e6 --- /dev/null +++ b/packages/design-system/src/components/N8nIconPicker/IconPicker.test.ts @@ -0,0 +1,183 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render } from '@testing-library/vue'; +import { createRouter, createWebHistory } from 'vue-router'; + +import IconPicker from '.'; +import { TEST_ICONS } from './constants'; + +// Create a proxy handler that returns a mock icon object for any icon name +// and mock the entire icon library with the proxy +vi.mock( + '@fortawesome/free-solid-svg-icons', + () => + new Proxy( + {}, + { + get: (_target, prop) => { + return { prefix: 'fas', iconName: prop.toString().replace('fa', '').toLowerCase() }; + }, + }, + ), +); + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/icons', + name: 'icons', + redirect: '/icons', + }, + { + path: '/emojis', + name: 'emojis', + component: { template: '

emojis

' }, + }, + ], +}); + +// Component stubs +const components = { + N8nIconButton: { + template: '