mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Add support for project icons (#12349)
This commit is contained in:
parent
7ea6c8b144
commit
9117718cc9
|
@ -29,7 +29,11 @@ export const getAddProjectButton = () => {
|
||||||
|
|
||||||
return cy.get('@button');
|
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 = () =>
|
// export const getAddProjectButton = () =>
|
||||||
// cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible');
|
// cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible');
|
||||||
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
|
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
NDV,
|
NDV,
|
||||||
MainSidebar,
|
MainSidebar,
|
||||||
} from '../pages';
|
} from '../pages';
|
||||||
import { clearNotifications } from '../pages/notifications';
|
import { clearNotifications, successToast } from '../pages/notifications';
|
||||||
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
|
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
|
||||||
|
|
||||||
const workflowsPage = new WorkflowsPage();
|
const workflowsPage = new WorkflowsPage();
|
||||||
|
@ -830,4 +830,23 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
.should('not.have.length');
|
.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', '😀');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -51,7 +51,12 @@ export class ProjectController {
|
||||||
@Licensed('feat:projectRole:admin')
|
@Licensed('feat:projectRole:admin')
|
||||||
async createProject(req: ProjectRequest.Create) {
|
async createProject(req: ProjectRequest.Create) {
|
||||||
try {
|
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', {
|
this.eventService.emit('team-project-created', {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
|
@ -163,7 +168,7 @@ export class ProjectController {
|
||||||
@Get('/:projectId')
|
@Get('/:projectId')
|
||||||
@ProjectScope('project:read')
|
@ProjectScope('project:read')
|
||||||
async getProject(req: ProjectRequest.Get): Promise<ProjectRequest.ProjectWithRelations> {
|
async getProject(req: ProjectRequest.Get): Promise<ProjectRequest.ProjectWithRelations> {
|
||||||
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.getProject(req.params.projectId),
|
||||||
this.projectsService.getProjectRelations(req.params.projectId),
|
this.projectsService.getProjectRelations(req.params.projectId),
|
||||||
]);
|
]);
|
||||||
|
@ -172,6 +177,7 @@ export class ProjectController {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
icon,
|
||||||
type,
|
type,
|
||||||
relations: relations.map((r) => ({
|
relations: relations.map((r) => ({
|
||||||
id: r.user.id,
|
id: r.user.id,
|
||||||
|
@ -193,7 +199,7 @@ export class ProjectController {
|
||||||
@ProjectScope('project:update')
|
@ProjectScope('project:update')
|
||||||
async updateProject(req: ProjectRequest.Update) {
|
async updateProject(req: ProjectRequest.Update) {
|
||||||
if (req.body.name) {
|
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) {
|
if (req.body.relations) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -6,6 +6,7 @@ import type { SharedCredentials } from './shared-credentials';
|
||||||
import type { SharedWorkflow } from './shared-workflow';
|
import type { SharedWorkflow } from './shared-workflow';
|
||||||
|
|
||||||
export type ProjectType = 'personal' | 'team';
|
export type ProjectType = 'personal' | 'team';
|
||||||
|
export type ProjectIcon = { type: 'emoji' | 'icon'; value: string } | null;
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
export class Project extends WithTimestampsAndStringId {
|
export class Project extends WithTimestampsAndStringId {
|
||||||
|
@ -15,6 +16,9 @@ export class Project extends WithTimestampsAndStringId {
|
||||||
@Column({ length: 36 })
|
@Column({ length: 36 })
|
||||||
type: ProjectType;
|
type: ProjectType;
|
||||||
|
|
||||||
|
@Column({ type: 'json', nullable: true })
|
||||||
|
icon: ProjectIcon;
|
||||||
|
|
||||||
@OneToMany('ProjectRelation', 'project')
|
@OneToMany('ProjectRelation', 'project')
|
||||||
projectRelations: ProjectRelation[];
|
projectRelations: ProjectRelation[];
|
||||||
|
|
||||||
|
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
|
@ -69,6 +69,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C
|
||||||
import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart';
|
import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart';
|
||||||
import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping';
|
import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping';
|
||||||
import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText';
|
import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText';
|
||||||
|
import { AddProjectIcons1729607673469 } from '../common/1729607673469-AddProjectIcons';
|
||||||
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
||||||
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
||||||
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
||||||
|
@ -152,4 +153,5 @@ export const mysqlMigrations: Migration[] = [
|
||||||
CreateTestRun1732549866705,
|
CreateTestRun1732549866705,
|
||||||
AddMockedNodesColumnToTestDefinition1733133775640,
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
AddManagedColumnToCredentialsTable1734479635324,
|
AddManagedColumnToCredentialsTable1734479635324,
|
||||||
|
AddProjectIcons1729607673469,
|
||||||
];
|
];
|
||||||
|
|
|
@ -69,6 +69,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C
|
||||||
import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart';
|
import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart';
|
||||||
import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping';
|
import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping';
|
||||||
import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText';
|
import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText';
|
||||||
|
import { AddProjectIcons1729607673469 } from '../common/1729607673469-AddProjectIcons';
|
||||||
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
||||||
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
||||||
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
||||||
|
@ -152,4 +153,5 @@ export const postgresMigrations: Migration[] = [
|
||||||
CreateTestRun1732549866705,
|
CreateTestRun1732549866705,
|
||||||
AddMockedNodesColumnToTestDefinition1733133775640,
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
AddManagedColumnToCredentialsTable1734479635324,
|
AddManagedColumnToCredentialsTable1734479635324,
|
||||||
|
AddProjectIcons1729607673469,
|
||||||
];
|
];
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { AddProjectIcons1729607673469 as BaseMigration } from '../common/1729607673469-AddProjectIcons';
|
||||||
|
|
||||||
|
export class AddProjectIcons1729607673469 extends BaseMigration {
|
||||||
|
transaction = false as const;
|
||||||
|
}
|
|
@ -39,6 +39,7 @@ import { DropRoleMapping1705429061930 } from './1705429061930-DropRoleMapping';
|
||||||
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
|
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
|
||||||
import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable';
|
import { AddApiKeysTable1724951148974 } from './1724951148974-AddApiKeysTable';
|
||||||
import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from './1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping';
|
import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from './1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping';
|
||||||
|
import { AddProjectIcons1729607673469 } from './1729607673469-AddProjectIcons';
|
||||||
import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition';
|
import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition';
|
||||||
import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString';
|
import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString';
|
||||||
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
|
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
|
||||||
|
@ -146,6 +147,7 @@ const sqliteMigrations: Migration[] = [
|
||||||
CreateTestRun1732549866705,
|
CreateTestRun1732549866705,
|
||||||
AddMockedNodesColumnToTestDefinition1733133775640,
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
AddManagedColumnToCredentialsTable1734479635324,
|
AddManagedColumnToCredentialsTable1734479635324,
|
||||||
|
AddProjectIcons1729607673469,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -13,7 +13,7 @@ import type {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
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 { AssignableRole, GlobalRole, User } from '@/databases/entities/user';
|
||||||
import type { Variables } from '@/databases/entities/variables';
|
import type { Variables } from '@/databases/entities/variables';
|
||||||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
@ -123,7 +123,7 @@ export namespace ListQuery {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SlimUser = Pick<IUser, 'id' | 'email' | 'firstName' | 'lastName'>;
|
type SlimUser = Pick<IUser, 'id' | 'email' | 'firstName' | 'lastName'>;
|
||||||
export type SlimProject = Pick<Project, 'id' | 'type' | 'name'>;
|
export type SlimProject = Pick<Project, 'id' | 'type' | 'name' | 'icon'>;
|
||||||
|
|
||||||
export function hasSharing(
|
export function hasSharing(
|
||||||
workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[],
|
workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[],
|
||||||
|
@ -440,6 +440,7 @@ export declare namespace ProjectRequest {
|
||||||
Project,
|
Project,
|
||||||
{
|
{
|
||||||
name: string;
|
name: string;
|
||||||
|
icon?: ProjectIcon;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
@ -468,6 +469,7 @@ export declare namespace ProjectRequest {
|
||||||
type ProjectWithRelations = {
|
type ProjectWithRelations = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
|
icon: ProjectIcon;
|
||||||
type: ProjectType;
|
type: ProjectType;
|
||||||
relations: ProjectRelationResponse[];
|
relations: ProjectRelationResponse[];
|
||||||
scopes: Scope[];
|
scopes: Scope[];
|
||||||
|
@ -477,7 +479,11 @@ export declare namespace ProjectRequest {
|
||||||
type Update = AuthenticatedRequest<
|
type Update = AuthenticatedRequest<
|
||||||
{ projectId: string },
|
{ projectId: string },
|
||||||
{},
|
{},
|
||||||
{ name?: string; relations?: ProjectRelationPayload[] }
|
{
|
||||||
|
name?: string;
|
||||||
|
relations?: ProjectRelationPayload[];
|
||||||
|
icon?: { type: 'icon' | 'emoji'; value: string };
|
||||||
|
}
|
||||||
>;
|
>;
|
||||||
type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>;
|
type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,12 +87,14 @@ export class OwnershipService {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
type: project.type,
|
type: project.type,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
|
icon: project.icon,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
entity.sharedWithProjects.push({
|
entity.sharedWithProjects.push({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
type: project.type,
|
type: project.type,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
|
icon: project.icon,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,8 @@ import { ApplicationError } from 'n8n-workflow';
|
||||||
import Container, { Service } from 'typedi';
|
import Container, { Service } from 'typedi';
|
||||||
|
|
||||||
import { UNLIMITED_LICENSE_QUOTA } from '@/constants';
|
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 { ProjectRelation } from '@/databases/entities/project-relation';
|
||||||
import type { ProjectRole } from '@/databases/entities/project-relation';
|
import type { ProjectRole } from '@/databases/entities/project-relation';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
|
@ -167,7 +168,12 @@ export class ProjectService {
|
||||||
return await this.projectRelationRepository.getPersonalProjectOwners(projectIds);
|
return await this.projectRelationRepository.getPersonalProjectOwners(projectIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTeamProject(name: string, adminUser: User, id?: string): Promise<Project> {
|
async createTeamProject(
|
||||||
|
name: string,
|
||||||
|
adminUser: User,
|
||||||
|
id?: string,
|
||||||
|
icon?: ProjectIcon,
|
||||||
|
): Promise<Project> {
|
||||||
const limit = this.license.getTeamProjectLimit();
|
const limit = this.license.getTeamProjectLimit();
|
||||||
if (
|
if (
|
||||||
limit !== UNLIMITED_LICENSE_QUOTA &&
|
limit !== UNLIMITED_LICENSE_QUOTA &&
|
||||||
|
@ -180,6 +186,7 @@ export class ProjectService {
|
||||||
this.projectRepository.create({
|
this.projectRepository.create({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
|
icon,
|
||||||
type: 'team',
|
type: 'team',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
@ -190,7 +197,11 @@ export class ProjectService {
|
||||||
return project;
|
return project;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProject(name: string, projectId: string): Promise<Project> {
|
async updateProject(
|
||||||
|
name: string,
|
||||||
|
projectId: string,
|
||||||
|
icon?: { type: 'icon' | 'emoji'; value: string },
|
||||||
|
): Promise<Project> {
|
||||||
const result = await this.projectRepository.update(
|
const result = await this.projectRepository.update(
|
||||||
{
|
{
|
||||||
id: projectId,
|
id: projectId,
|
||||||
|
@ -198,6 +209,7 @@ export class ProjectService {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
|
icon,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -540,6 +540,7 @@ describe('GET /credentials/:id', () => {
|
||||||
id: ownerPersonalProject.id,
|
id: ownerPersonalProject.id,
|
||||||
name: owner.createPersonalProjectName(),
|
name: owner.createPersonalProjectName(),
|
||||||
type: ownerPersonalProject.type,
|
type: ownerPersonalProject.type,
|
||||||
|
icon: null,
|
||||||
});
|
});
|
||||||
expect(firstCredential.sharedWithProjects).toHaveLength(0);
|
expect(firstCredential.sharedWithProjects).toHaveLength(0);
|
||||||
|
|
||||||
|
@ -629,17 +630,20 @@ describe('GET /credentials/:id', () => {
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: member1PersonalProject.id,
|
id: member1PersonalProject.id,
|
||||||
name: member1.createPersonalProjectName(),
|
name: member1.createPersonalProjectName(),
|
||||||
|
icon: null,
|
||||||
type: 'personal',
|
type: 'personal',
|
||||||
},
|
},
|
||||||
sharedWithProjects: expect.arrayContaining([
|
sharedWithProjects: expect.arrayContaining([
|
||||||
{
|
{
|
||||||
id: member2PersonalProject.id,
|
id: member2PersonalProject.id,
|
||||||
name: member2.createPersonalProjectName(),
|
name: member2.createPersonalProjectName(),
|
||||||
|
icon: null,
|
||||||
type: member2PersonalProject.type,
|
type: member2PersonalProject.type,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: member3PersonalProject.id,
|
id: member3PersonalProject.id,
|
||||||
name: member3.createPersonalProjectName(),
|
name: member3.createPersonalProjectName(),
|
||||||
|
icon: null,
|
||||||
type: member3PersonalProject.type,
|
type: member3PersonalProject.type,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|
|
@ -131,6 +131,7 @@ describe('Projects in Public API', () => {
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(response.body).toEqual({
|
expect(response.body).toEqual({
|
||||||
name: 'some-project',
|
name: 'some-project',
|
||||||
|
icon: null,
|
||||||
type: 'team',
|
type: 'team',
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
|
|
|
@ -441,6 +441,7 @@ describe('GET /workflows', () => {
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: ownerPersonalProject.id,
|
id: ownerPersonalProject.id,
|
||||||
name: owner.createPersonalProjectName(),
|
name: owner.createPersonalProjectName(),
|
||||||
|
icon: null,
|
||||||
type: ownerPersonalProject.type,
|
type: ownerPersonalProject.type,
|
||||||
},
|
},
|
||||||
sharedWithProjects: [],
|
sharedWithProjects: [],
|
||||||
|
@ -456,6 +457,7 @@ describe('GET /workflows', () => {
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: ownerPersonalProject.id,
|
id: ownerPersonalProject.id,
|
||||||
name: owner.createPersonalProjectName(),
|
name: owner.createPersonalProjectName(),
|
||||||
|
icon: null,
|
||||||
type: ownerPersonalProject.type,
|
type: ownerPersonalProject.type,
|
||||||
},
|
},
|
||||||
sharedWithProjects: [],
|
sharedWithProjects: [],
|
||||||
|
@ -833,6 +835,7 @@ describe('GET /workflows', () => {
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: ownerPersonalProject.id,
|
id: ownerPersonalProject.id,
|
||||||
name: owner.createPersonalProjectName(),
|
name: owner.createPersonalProjectName(),
|
||||||
|
icon: null,
|
||||||
type: ownerPersonalProject.type,
|
type: ownerPersonalProject.type,
|
||||||
},
|
},
|
||||||
sharedWithProjects: [],
|
sharedWithProjects: [],
|
||||||
|
@ -842,6 +845,7 @@ describe('GET /workflows', () => {
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: ownerPersonalProject.id,
|
id: ownerPersonalProject.id,
|
||||||
name: owner.createPersonalProjectName(),
|
name: owner.createPersonalProjectName(),
|
||||||
|
icon: null,
|
||||||
type: ownerPersonalProject.type,
|
type: ownerPersonalProject.type,
|
||||||
},
|
},
|
||||||
sharedWithProjects: [],
|
sharedWithProjects: [],
|
||||||
|
|
|
@ -45,6 +45,7 @@
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||||
"element-plus": "2.4.3",
|
"element-plus": "2.4.3",
|
||||||
|
"is-emoji-supported": "^0.0.5",
|
||||||
"markdown-it": "^13.0.2",
|
"markdown-it": "^13.0.2",
|
||||||
"markdown-it-emoji": "^2.0.2",
|
"markdown-it-emoji": "^2.0.2",
|
||||||
"markdown-it-link-attributes": "^4.0.1",
|
"markdown-it-link-attributes": "^4.0.1",
|
||||||
|
@ -55,5 +56,8 @@
|
||||||
"vue-boring-avatars": "^1.3.0",
|
"vue-boring-avatars": "^1.3.0",
|
||||||
"vue-router": "catalog:frontend",
|
"vue-router": "catalog:frontend",
|
||||||
"xss": "catalog:"
|
"xss": "catalog:"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vueuse/core": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,3 +15,8 @@ window.ResizeObserver =
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
unobserve: vi.fn(),
|
unobserve: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Globally mock is-emoji-supported
|
||||||
|
vi.mock('is-emoji-supported', () => ({
|
||||||
|
isEmojiSupported: () => true,
|
||||||
|
}));
|
||||||
|
|
|
@ -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:
|
||||||
|
'<div style="height: 500px"><n8n-icon-picker v-model="icon" v-bind="args" @update:model-value="onIconSelected" /></div>',
|
||||||
|
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: [],
|
||||||
|
};
|
|
@ -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: '<h1>emojis</h1>' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component stubs
|
||||||
|
const components = {
|
||||||
|
N8nIconButton: {
|
||||||
|
template: '<button :data-icon="icon" data-testid="icon-picker-button" />',
|
||||||
|
props: ['icon'],
|
||||||
|
},
|
||||||
|
N8nIcon: {
|
||||||
|
template:
|
||||||
|
'<div class="mock-icon" :data-icon="typeof icon === \'string\' ? icon : icon.iconName" />',
|
||||||
|
props: ['icon'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('IconPicker', () => {
|
||||||
|
it('renders icons and emojis', async () => {
|
||||||
|
const { getByTestId, getAllByTestId } = render(IconPicker, {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: 'icon', value: 'smile' },
|
||||||
|
buttonTooltip: 'Select an icon',
|
||||||
|
availableIcons: TEST_ICONS,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
components,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const TEST_EMOJI_COUNT = 1962;
|
||||||
|
|
||||||
|
await fireEvent.click(getByTestId('icon-picker-button'));
|
||||||
|
// Tabs should be visible and icons should be selected by default
|
||||||
|
expect(getByTestId('icon-picker-tabs')).toBeVisible();
|
||||||
|
expect(getByTestId('tab-icons').className).toContain('activeTab');
|
||||||
|
expect(getByTestId('icon-picker-popup')).toBeVisible();
|
||||||
|
// All icons should be rendered
|
||||||
|
expect(getAllByTestId('icon-picker-icon')).toHaveLength(TEST_ICONS.length);
|
||||||
|
// Click on emojis tab
|
||||||
|
await fireEvent.click(getByTestId('tab-emojis'));
|
||||||
|
// Emojis tab should be active
|
||||||
|
expect(getByTestId('tab-emojis').className).toContain('activeTab');
|
||||||
|
// All emojis should be rendered
|
||||||
|
expect(getAllByTestId('icon-picker-emoji')).toHaveLength(TEST_EMOJI_COUNT);
|
||||||
|
});
|
||||||
|
it('renders icon picker with custom icon and tooltip', async () => {
|
||||||
|
const ICON = 'layer-group';
|
||||||
|
const TOOLTIP = 'Select something...';
|
||||||
|
const { getByTestId, getByRole } = render(IconPicker, {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: 'icon', value: ICON },
|
||||||
|
availableIcons: [...TEST_ICONS],
|
||||||
|
buttonTooltip: TOOLTIP,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
components,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await userEvent.hover(getByTestId('icon-picker-button'));
|
||||||
|
expect(getByRole('tooltip').textContent).toBe(TOOLTIP);
|
||||||
|
expect(getByTestId('icon-picker-button').dataset.icon).toBe(ICON);
|
||||||
|
});
|
||||||
|
it('renders emoji as default icon correctly', async () => {
|
||||||
|
const ICON = '🔥';
|
||||||
|
const TOOLTIP = 'Select something...';
|
||||||
|
const { getByTestId, getByRole } = render(IconPicker, {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: 'emoji', value: ICON },
|
||||||
|
availableIcons: [...TEST_ICONS],
|
||||||
|
buttonTooltip: TOOLTIP,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
components,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await userEvent.hover(getByTestId('icon-picker-button'));
|
||||||
|
expect(getByRole('tooltip').textContent).toBe(TOOLTIP);
|
||||||
|
expect(getByTestId('icon-picker-button')).toHaveTextContent(ICON);
|
||||||
|
});
|
||||||
|
it('renders icon picker with only emojis', () => {
|
||||||
|
const { queryByTestId } = render(IconPicker, {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: 'icon', value: 'smile' },
|
||||||
|
buttonTooltip: 'Select an emoji',
|
||||||
|
availableIcons: [],
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
components,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(queryByTestId('tab-icons')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('is able to select an icon', async () => {
|
||||||
|
const { getByTestId, getAllByTestId, queryByTestId, emitted } = render(IconPicker, {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: 'icon', value: 'smile' },
|
||||||
|
buttonTooltip: 'Select an icon',
|
||||||
|
availableIcons: TEST_ICONS,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
components,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fireEvent.click(getByTestId('icon-picker-button'));
|
||||||
|
// Select the first icon
|
||||||
|
await fireEvent.click(getAllByTestId('icon-picker-icon')[0]);
|
||||||
|
// Icon should be selected and popup should be closed
|
||||||
|
expect(getByTestId('icon-picker-button').dataset.icon).toBe(TEST_ICONS[0]);
|
||||||
|
expect(queryByTestId('icon-picker-popup')).toBeNull();
|
||||||
|
expect(emitted()).toHaveProperty('update:modelValue');
|
||||||
|
// Should emit the selected icon
|
||||||
|
expect((emitted()['update:modelValue'] as unknown[][])[0][0]).toEqual({
|
||||||
|
type: 'icon',
|
||||||
|
value: TEST_ICONS[0],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('is able to select an emoji', async () => {
|
||||||
|
const { getByTestId, getAllByTestId, queryByTestId, emitted } = render(IconPicker, {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: 'emoji', value: '🔥' },
|
||||||
|
buttonTooltip: 'Select an emoji',
|
||||||
|
availableIcons: TEST_ICONS,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [router],
|
||||||
|
components,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fireEvent.click(getByTestId('icon-picker-button'));
|
||||||
|
await fireEvent.click(getByTestId('tab-emojis'));
|
||||||
|
expect(getByTestId('icon-picker-popup')).toBeVisible();
|
||||||
|
// Select the first emoji
|
||||||
|
await fireEvent.click(getAllByTestId('icon-picker-emoji')[0]);
|
||||||
|
// Emoji should be selected and popup should be closed
|
||||||
|
expect(getByTestId('icon-picker-button')).toHaveTextContent('😀');
|
||||||
|
expect(queryByTestId('icon-picker-popup')).toBeNull();
|
||||||
|
// Should emit the selected emoji
|
||||||
|
expect(emitted()).toHaveProperty('update:modelValue');
|
||||||
|
expect((emitted()['update:modelValue'] as unknown[][])[0][0]).toEqual({
|
||||||
|
type: 'emoji',
|
||||||
|
value: '😀',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,215 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
// vueuse is a peer dependency
|
||||||
|
// eslint-disable import/no-extraneous-dependencies
|
||||||
|
import { onClickOutside } from '@vueuse/core';
|
||||||
|
import { isEmojiSupported } from 'is-emoji-supported';
|
||||||
|
import { ref, defineProps, computed } from 'vue';
|
||||||
|
|
||||||
|
import { useI18n } from '../../composables/useI18n';
|
||||||
|
import N8nTooltip from '../N8nTooltip';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple n8n icon picker component with support for font icons and emojis.
|
||||||
|
* In order to keep this component as dependency-free as possible, it only renders externally provided font icons.
|
||||||
|
* Emojis are rendered from `emojiRanges` array.
|
||||||
|
* If we want to introduce advanced features like search, we need to use libraries like `emojilib`.
|
||||||
|
*/
|
||||||
|
defineOptions({ name: 'N8nIconPicker' });
|
||||||
|
|
||||||
|
const emojiRanges = [
|
||||||
|
[0x1f600, 0x1f64f], // Emoticons
|
||||||
|
[0x1f300, 0x1f5ff], // Symbols & Pictographs
|
||||||
|
[0x1f680, 0x1f6ff], // Transport & Map Symbols
|
||||||
|
[0x2600, 0x26ff], // Miscellaneous Symbols
|
||||||
|
[0x2700, 0x27bf], // Dingbats
|
||||||
|
[0x1f900, 0x1f9ff], // Supplemental Symbols
|
||||||
|
[0x1f1e6, 0x1f1ff], // Regional Indicator Symbols
|
||||||
|
[0x1f400, 0x1f4ff], // Additional pictographs
|
||||||
|
];
|
||||||
|
|
||||||
|
export type Icon = {
|
||||||
|
type: 'icon' | 'emoji';
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
buttonTooltip: string;
|
||||||
|
availableIcons: string[];
|
||||||
|
buttonSize?: 'small' | 'large';
|
||||||
|
};
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
availableIcons: () => [],
|
||||||
|
buttonSize: 'large',
|
||||||
|
});
|
||||||
|
|
||||||
|
const model = defineModel<Icon>({ default: { type: 'icon', value: 'smile' } });
|
||||||
|
|
||||||
|
const hasAvailableIcons = computed(() => props.availableIcons.length > 0);
|
||||||
|
|
||||||
|
const emojis = computed(() => {
|
||||||
|
const emojisArray: string[] = [];
|
||||||
|
emojiRanges.forEach(([start, end]) => {
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
const emoji = String.fromCodePoint(i);
|
||||||
|
if (isEmojiSupported(emoji)) {
|
||||||
|
emojisArray.push(emoji);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return emojisArray;
|
||||||
|
});
|
||||||
|
|
||||||
|
const popupVisible = ref(false);
|
||||||
|
const tabs = ref<Array<{ value: string; label: string }>>(
|
||||||
|
hasAvailableIcons.value
|
||||||
|
? [
|
||||||
|
{ value: 'icons', label: t('iconPicker.tabs.icons') },
|
||||||
|
{ value: 'emojis', label: t('iconPicker.tabs.emojis') },
|
||||||
|
]
|
||||||
|
: [{ value: 'emojis', label: t('iconPicker.tabs.emojis') }],
|
||||||
|
);
|
||||||
|
const selectedTab = ref<string>(tabs.value[0].value);
|
||||||
|
|
||||||
|
const container = ref<HTMLDivElement>();
|
||||||
|
|
||||||
|
onClickOutside(container, () => {
|
||||||
|
popupVisible.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectIcon = (value: Icon) => {
|
||||||
|
model.value = value;
|
||||||
|
popupVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePopup = () => {
|
||||||
|
popupVisible.value = !popupVisible.value;
|
||||||
|
if (popupVisible.value) {
|
||||||
|
selectedTab.value = tabs.value[0].value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="container"
|
||||||
|
:class="$style.container"
|
||||||
|
:aria-expanded="popupVisible"
|
||||||
|
role="button"
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<div :class="$style['icon-picker-button']">
|
||||||
|
<N8nTooltip placement="right" data-test-id="icon-picker-tooltip">
|
||||||
|
<template #content>
|
||||||
|
{{ props.buttonTooltip ?? t('iconPicker.button.defaultToolTip') }}
|
||||||
|
</template>
|
||||||
|
<N8nIconButton
|
||||||
|
v-if="model.type === 'icon'"
|
||||||
|
:class="$style['icon-button']"
|
||||||
|
:icon="model.value ?? 'smile'"
|
||||||
|
:size="buttonSize"
|
||||||
|
:square="true"
|
||||||
|
type="tertiary"
|
||||||
|
data-test-id="icon-picker-button"
|
||||||
|
@click="togglePopup"
|
||||||
|
/>
|
||||||
|
<N8nButton
|
||||||
|
v-else-if="model.type === 'emoji'"
|
||||||
|
:class="$style['emoji-button']"
|
||||||
|
:size="buttonSize"
|
||||||
|
:square="true"
|
||||||
|
type="tertiary"
|
||||||
|
data-test-id="icon-picker-button"
|
||||||
|
@click="togglePopup"
|
||||||
|
>
|
||||||
|
{{ model.value }}
|
||||||
|
</N8nButton>
|
||||||
|
</N8nTooltip>
|
||||||
|
</div>
|
||||||
|
<div v-if="popupVisible" :class="$style.popup" data-test-id="icon-picker-popup">
|
||||||
|
<div :class="$style.tabs">
|
||||||
|
<N8nTabs v-model="selectedTab" :options="tabs" data-test-id="icon-picker-tabs" />
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTab === 'icons'" :class="$style.content">
|
||||||
|
<N8nIcon
|
||||||
|
v-for="icon in availableIcons"
|
||||||
|
:key="icon"
|
||||||
|
:icon="icon"
|
||||||
|
:class="$style.icon"
|
||||||
|
size="large"
|
||||||
|
data-test-id="icon-picker-icon"
|
||||||
|
@click="selectIcon({ type: 'icon', value: icon })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedTab === 'emojis'" :class="$style.content">
|
||||||
|
<span
|
||||||
|
v-for="emoji in emojis"
|
||||||
|
:key="emoji"
|
||||||
|
:class="$style.emoji"
|
||||||
|
data-test-id="icon-picker-emoji"
|
||||||
|
@click="selectIcon({ type: 'emoji', value: emoji })"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
width: 426px;
|
||||||
|
max-height: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: var(--spacing-4xs);
|
||||||
|
background-color: var(--color-background-xlight);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
border: var(--border-base);
|
||||||
|
border-color: var(--color-foreground-dark);
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
padding-bottom: var(--spacing-5xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon,
|
||||||
|
.emoji {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--spacing-4xs);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-background-medium);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-text-light);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
font-family: 'Segoe UI Emoji', 'Segoe UI Symbol', 'Segoe UI', 'Apple Color Emoji',
|
||||||
|
'Twemoji Mozilla', 'Noto Color Emoji', 'Android Emoji', sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
163
packages/design-system/src/components/N8nIconPicker/constants.ts
Normal file
163
packages/design-system/src/components/N8nIconPicker/constants.ts
Normal file
|
@ -0,0 +1,163 @@
|
||||||
|
export const TEST_ICONS = [
|
||||||
|
'angle-double-left',
|
||||||
|
'angle-down',
|
||||||
|
'angle-left',
|
||||||
|
'angle-right',
|
||||||
|
'angle-up',
|
||||||
|
'archive',
|
||||||
|
'arrow-left',
|
||||||
|
'arrow-right',
|
||||||
|
'arrow-up',
|
||||||
|
'arrow-down',
|
||||||
|
'at',
|
||||||
|
'ban',
|
||||||
|
'balance-scale-left',
|
||||||
|
'bars',
|
||||||
|
'bolt',
|
||||||
|
'book',
|
||||||
|
'box-open',
|
||||||
|
'bug',
|
||||||
|
'brain',
|
||||||
|
'calculator',
|
||||||
|
'calendar',
|
||||||
|
'chart-bar',
|
||||||
|
'check',
|
||||||
|
'check-circle',
|
||||||
|
'check-square',
|
||||||
|
'chevron-left',
|
||||||
|
'chevron-right',
|
||||||
|
'chevron-down',
|
||||||
|
'chevron-up',
|
||||||
|
'code',
|
||||||
|
'code-branch',
|
||||||
|
'cog',
|
||||||
|
'cogs',
|
||||||
|
'comment',
|
||||||
|
'comments',
|
||||||
|
'clipboard-list',
|
||||||
|
'clock',
|
||||||
|
'clone',
|
||||||
|
'cloud',
|
||||||
|
'cloud-download-alt',
|
||||||
|
'copy',
|
||||||
|
'cube',
|
||||||
|
'cut',
|
||||||
|
'database',
|
||||||
|
'dot-circle',
|
||||||
|
'grip-lines-vertical',
|
||||||
|
'grip-vertical',
|
||||||
|
'edit',
|
||||||
|
'ellipsis-h',
|
||||||
|
'ellipsis-v',
|
||||||
|
'envelope',
|
||||||
|
'equals',
|
||||||
|
'eye',
|
||||||
|
'exclamation-triangle',
|
||||||
|
'expand',
|
||||||
|
'expand-alt',
|
||||||
|
'external-link-alt',
|
||||||
|
'exchange-alt',
|
||||||
|
'file',
|
||||||
|
'file-alt',
|
||||||
|
'file-archive',
|
||||||
|
'file-code',
|
||||||
|
'file-download',
|
||||||
|
'file-export',
|
||||||
|
'file-import',
|
||||||
|
'file-pdf',
|
||||||
|
'filter',
|
||||||
|
'fingerprint',
|
||||||
|
'flask',
|
||||||
|
'folder-open',
|
||||||
|
'font',
|
||||||
|
'gift',
|
||||||
|
'globe',
|
||||||
|
'globe-americas',
|
||||||
|
'graduation-cap',
|
||||||
|
'hand-holding-usd',
|
||||||
|
'hand-scissors',
|
||||||
|
'handshake',
|
||||||
|
'hand-point-left',
|
||||||
|
'hashtag',
|
||||||
|
'hdd',
|
||||||
|
'history',
|
||||||
|
'home',
|
||||||
|
'hourglass',
|
||||||
|
'image',
|
||||||
|
'inbox',
|
||||||
|
'info',
|
||||||
|
'info-circle',
|
||||||
|
'key',
|
||||||
|
'language',
|
||||||
|
'layer-group',
|
||||||
|
'link',
|
||||||
|
'list',
|
||||||
|
'lightbulb',
|
||||||
|
'lock',
|
||||||
|
'map-signs',
|
||||||
|
'mouse-pointer',
|
||||||
|
'network-wired',
|
||||||
|
'palette',
|
||||||
|
'pause',
|
||||||
|
'pause-circle',
|
||||||
|
'pen',
|
||||||
|
'pencil-alt',
|
||||||
|
'play',
|
||||||
|
'play-circle',
|
||||||
|
'plug',
|
||||||
|
'plus',
|
||||||
|
'plus-circle',
|
||||||
|
'plus-square',
|
||||||
|
'project-diagram',
|
||||||
|
'question',
|
||||||
|
'question-circle',
|
||||||
|
'redo',
|
||||||
|
'remove-format',
|
||||||
|
'robot',
|
||||||
|
'rss',
|
||||||
|
'save',
|
||||||
|
'satellite-dish',
|
||||||
|
'search',
|
||||||
|
'search-minus',
|
||||||
|
'search-plus',
|
||||||
|
'server',
|
||||||
|
'screwdriver',
|
||||||
|
'smile',
|
||||||
|
'sign-in-alt',
|
||||||
|
'sign-out-alt',
|
||||||
|
'sliders-h',
|
||||||
|
'spinner',
|
||||||
|
'sticky-note',
|
||||||
|
'stop',
|
||||||
|
'stream',
|
||||||
|
'sun',
|
||||||
|
'sync',
|
||||||
|
'sync-alt',
|
||||||
|
'table',
|
||||||
|
'tags',
|
||||||
|
'tasks',
|
||||||
|
'terminal',
|
||||||
|
'th-large',
|
||||||
|
'thumbtack',
|
||||||
|
'thumbs-down',
|
||||||
|
'thumbs-up',
|
||||||
|
'times',
|
||||||
|
'times-circle',
|
||||||
|
'toolbox',
|
||||||
|
'tools',
|
||||||
|
'trash',
|
||||||
|
'undo',
|
||||||
|
'unlink',
|
||||||
|
'user',
|
||||||
|
'user-circle',
|
||||||
|
'user-friends',
|
||||||
|
'users',
|
||||||
|
'vector-square',
|
||||||
|
'video',
|
||||||
|
'tree',
|
||||||
|
'user-lock',
|
||||||
|
'gem',
|
||||||
|
'download',
|
||||||
|
'power-off',
|
||||||
|
'paper-plane',
|
||||||
|
];
|
|
@ -0,0 +1,2 @@
|
||||||
|
import IconPicker from './IconPicker.vue';
|
||||||
|
export default IconPicker;
|
|
@ -123,12 +123,17 @@ const isItemActive = (item: IMenuItem): boolean => {
|
||||||
:disabled="item.disabled"
|
:disabled="item.disabled"
|
||||||
@click="handleSelect?.(item)"
|
@click="handleSelect?.(item)"
|
||||||
>
|
>
|
||||||
<N8nIcon
|
<div v-if="item.icon">
|
||||||
v-if="item.icon"
|
<N8nIcon
|
||||||
:class="$style.icon"
|
v-if="typeof item.icon === 'string' || item.icon.type === 'icon'"
|
||||||
:icon="item.icon"
|
:class="$style.icon"
|
||||||
:size="item.customIconSize || 'large'"
|
:icon="typeof item.icon === 'object' ? item.icon.value : item.icon"
|
||||||
/>
|
:size="item.customIconSize || 'large'"
|
||||||
|
/>
|
||||||
|
<span v-else-if="item.icon.type === 'emoji'" :class="$style.icon">{{
|
||||||
|
item.icon.value
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
<span v-if="!compact" :class="$style.label">{{ item.label }}</span>
|
<span v-if="!compact" :class="$style.label">{{ item.label }}</span>
|
||||||
<span v-if="!item.icon && compact" :class="[$style.label, $style.compactLabel]">{{
|
<span v-if="!item.icon && compact" :class="[$style.label, $style.compactLabel]">{{
|
||||||
getInitials(item.label)
|
getInitials(item.label)
|
||||||
|
|
|
@ -54,3 +54,4 @@ export { default as N8nUserSelect } from './N8nUserSelect';
|
||||||
export { default as N8nUsersList } from './N8nUsersList';
|
export { default as N8nUsersList } from './N8nUsersList';
|
||||||
export { default as N8nResizeObserver } from './ResizeObserver';
|
export { default as N8nResizeObserver } from './ResizeObserver';
|
||||||
export { N8nKeyboardShortcut } from './N8nKeyboardShortcut';
|
export { N8nKeyboardShortcut } from './N8nKeyboardShortcut';
|
||||||
|
export { default as N8nIconPicker } from './N8nIconPicker';
|
||||||
|
|
|
@ -48,4 +48,7 @@ export default {
|
||||||
'assistantChat.copy': 'Copy',
|
'assistantChat.copy': 'Copy',
|
||||||
'assistantChat.copied': 'Copied',
|
'assistantChat.copied': 'Copied',
|
||||||
'inlineAskAssistantButton.asked': 'Asked',
|
'inlineAskAssistantButton.asked': 'Asked',
|
||||||
|
'iconPicker.button.defaultToolTip': 'Choose icon',
|
||||||
|
'iconPicker.tabs.icons': 'Icons',
|
||||||
|
'iconPicker.tabs.emojis': 'Emojis',
|
||||||
} as N8nLocale;
|
} as N8nLocale;
|
||||||
|
|
|
@ -5,7 +5,7 @@ import type { RouteLocationRaw, RouterLinkProps } from 'vue-router';
|
||||||
export type IMenuItem = {
|
export type IMenuItem = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon?: string;
|
icon?: string | { type: 'icon' | 'emoji'; value: string };
|
||||||
secondaryIcon?: {
|
secondaryIcon?: {
|
||||||
name: string;
|
name: string;
|
||||||
size?: 'xsmall' | 'small' | 'medium' | 'large';
|
size?: 'xsmall' | 'small' | 'medium' | 'large';
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { ProjectTypes } from '@/types/projects.types';
|
||||||
export const createProjectSharingData = (projectType?: ProjectType): ProjectSharingData => ({
|
export const createProjectSharingData = (projectType?: ProjectType): ProjectSharingData => ({
|
||||||
id: faker.string.uuid(),
|
id: faker.string.uuid(),
|
||||||
name: faker.lorem.words({ min: 1, max: 3 }),
|
name: faker.lorem.words({ min: 1, max: 3 }),
|
||||||
|
icon: { type: 'icon', value: 'folder' },
|
||||||
type: projectType ?? ProjectTypes.Personal,
|
type: projectType ?? ProjectTypes.Personal,
|
||||||
createdAt: faker.date.past().toISOString(),
|
createdAt: faker.date.past().toISOString(),
|
||||||
updatedAt: faker.date.recent().toISOString(),
|
updatedAt: faker.date.recent().toISOString(),
|
||||||
|
@ -29,6 +30,7 @@ export function createTestProject(data: Partial<Project>): Project {
|
||||||
return {
|
return {
|
||||||
id: faker.string.uuid(),
|
id: faker.string.uuid(),
|
||||||
name: faker.lorem.words({ min: 1, max: 3 }),
|
name: faker.lorem.words({ min: 1, max: 3 }),
|
||||||
|
icon: { type: 'icon', value: 'folder' },
|
||||||
createdAt: faker.date.past().toISOString(),
|
createdAt: faker.date.past().toISOString(),
|
||||||
updatedAt: faker.date.recent().toISOString(),
|
updatedAt: faker.date.recent().toISOString(),
|
||||||
type: ProjectTypes.Team,
|
type: ProjectTypes.Team,
|
||||||
|
|
|
@ -37,8 +37,8 @@ export const updateProject = async (
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
req: ProjectUpdateRequest,
|
req: ProjectUpdateRequest,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const { id, name, relations } = req;
|
const { id, name, icon, relations } = req;
|
||||||
await makeRestApiRequest(context, 'PATCH', `/projects/${id}`, { name, relations });
|
await makeRestApiRequest(context, 'PATCH', `/projects/${id}`, { name, icon, relations });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteProject = async (
|
export const deleteProject = async (
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { computed } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { N8nButton, N8nTooltip } from 'n8n-design-system';
|
import { N8nButton, N8nTooltip } from 'n8n-design-system';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { type ProjectIcon, ProjectTypes } from '@/types/projects.types';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
@ -17,13 +17,13 @@ const i18n = useI18n();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
|
|
||||||
const headerIcon = computed(() => {
|
const headerIcon = computed((): ProjectIcon => {
|
||||||
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
||||||
return 'user';
|
return { type: 'icon', value: 'user' };
|
||||||
} else if (projectsStore.currentProject?.name) {
|
} else if (projectsStore.currentProject?.name) {
|
||||||
return 'layer-group';
|
return projectsStore.currentProject.icon ?? { type: 'icon', value: 'layer-group' };
|
||||||
} else {
|
} else {
|
||||||
return 'home';
|
return { type: 'icon', value: 'home' };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -107,9 +107,7 @@ const onSelect = (action: string) => {
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div :class="[$style.projectHeader]">
|
<div :class="[$style.projectHeader]">
|
||||||
<div :class="[$style.icon]">
|
<ProjectIcon :icon="headerIcon" size="medium" />
|
||||||
<N8nIcon :icon="headerIcon" color="text-light"></N8nIcon>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
|
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
|
||||||
<N8nText color="text-light">
|
<N8nText color="text-light">
|
||||||
|
|
68
packages/editor-ui/src/components/Projects/ProjectIcon.vue
Normal file
68
packages/editor-ui/src/components/Projects/ProjectIcon.vue
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ProjectIcon } from '@/types/projects.types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
icon: ProjectIcon;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
round?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: 'medium',
|
||||||
|
round: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div :class="[$style.container, $style[props.size], { [$style.round]: props.round }]">
|
||||||
|
<N8nIcon
|
||||||
|
v-if="props.icon.type === 'icon'"
|
||||||
|
:icon="props.icon.value"
|
||||||
|
color="text-light"
|
||||||
|
></N8nIcon>
|
||||||
|
<N8nText v-else-if="icon.type === 'emoji'" color="text-light" :class="$style.emoji">
|
||||||
|
{{ icon.value }}
|
||||||
|
</N8nText>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-light);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
|
||||||
|
&.round {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
width: var(--spacing-l);
|
||||||
|
height: var(--spacing-l);
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
font-size: var(--font-size-2xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium {
|
||||||
|
width: var(--spacing-xl);
|
||||||
|
height: var(--spacing-xl);
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
// Making this in line with user avatar size
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -33,6 +33,7 @@ describe('ProjectMoveResourceModal', () => {
|
||||||
{
|
{
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'My Project',
|
name: 'My Project',
|
||||||
|
icon: { type: 'icon', value: 'folder' },
|
||||||
type: 'personal',
|
type: 'personal',
|
||||||
role: 'project:personalOwner',
|
role: 'project:personalOwner',
|
||||||
createdAt: '2021-01-01T00:00:00.000Z',
|
createdAt: '2021-01-01T00:00:00.000Z',
|
||||||
|
|
|
@ -40,6 +40,10 @@ vi.mock('@/composables/usePageRedirectionHelper', () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('is-emoji-supported', () => ({
|
||||||
|
isEmojiSupported: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(ProjectsNavigation, {
|
const renderComponent = createComponentRenderer(ProjectsNavigation, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
@ -120,7 +124,7 @@ describe('ProjectsNavigation', () => {
|
||||||
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
|
expect(queryByRole('heading', { level: 3, name: 'Projects' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show project icons when the menu is collapsed', async () => {
|
it('should show project icons when the menu is collapsed', async () => {
|
||||||
projectsStore.teamProjectsLimit = -1;
|
projectsStore.teamProjectsLimit = -1;
|
||||||
|
|
||||||
const { getByTestId } = renderComponent({
|
const { getByTestId } = renderComponent({
|
||||||
|
@ -130,7 +134,7 @@ describe('ProjectsNavigation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(getByTestId('project-personal-menu-item')).toBeVisible();
|
expect(getByTestId('project-personal-menu-item')).toBeVisible();
|
||||||
expect(getByTestId('project-personal-menu-item').querySelector('svg')).not.toBeInTheDocument();
|
expect(getByTestId('project-personal-menu-item').querySelector('svg')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not show add first project button if there are projects already', async () => {
|
it('should not show add first project button if there are projects already', async () => {
|
||||||
|
|
|
@ -33,7 +33,7 @@ const home = computed<IMenuItem>(() => ({
|
||||||
const getProjectMenuItem = (project: ProjectListItem) => ({
|
const getProjectMenuItem = (project: ProjectListItem) => ({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
label: project.name,
|
label: project.name,
|
||||||
icon: props.collapsed ? undefined : 'layer-group',
|
icon: project.icon,
|
||||||
route: {
|
route: {
|
||||||
to: {
|
to: {
|
||||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||||
|
@ -45,7 +45,7 @@ const getProjectMenuItem = (project: ProjectListItem) => ({
|
||||||
const personalProject = computed<IMenuItem>(() => ({
|
const personalProject = computed<IMenuItem>(() => ({
|
||||||
id: projectsStore.personalProject?.id ?? '',
|
id: projectsStore.personalProject?.id ?? '',
|
||||||
label: locale.baseText('projects.menu.personal'),
|
label: locale.baseText('projects.menu.personal'),
|
||||||
icon: props.collapsed ? undefined : 'user',
|
icon: 'user',
|
||||||
route: {
|
route: {
|
||||||
to: {
|
to: {
|
||||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||||
|
|
|
@ -19,11 +19,19 @@ const processedName = computed(() => {
|
||||||
email,
|
email,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectIcon = computed(() => {
|
||||||
|
if (props.project.icon) {
|
||||||
|
return props.project.icon;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.projectInfo" data-test-id="project-sharing-info">
|
<div :class="$style.projectInfo" data-test-id="project-sharing-info">
|
||||||
<div>
|
<div>
|
||||||
<N8nAvatar :first-name="processedName.firstName" :last-name="processedName.lastName" />
|
<ProjectIcon v-if="projectIcon" :icon="projectIcon" size="large" :round="true" />
|
||||||
|
<N8nAvatar v-else :first-name="processedName.firstName" :last-name="processedName.lastName" />
|
||||||
<div :class="$style.text">
|
<div :class="$style.text">
|
||||||
<p v-if="processedName.firstName || processedName.lastName">
|
<p v-if="processedName.firstName || processedName.lastName">
|
||||||
{{ processedName.firstName }} {{ processedName.lastName }}
|
{{ processedName.firstName }} {{ processedName.lastName }}
|
||||||
|
|
|
@ -173,6 +173,7 @@ export const useGlobalEntityCreation = () => {
|
||||||
try {
|
try {
|
||||||
const newProject = await projectsStore.createProject({
|
const newProject = await projectsStore.createProject({
|
||||||
name: i18n.baseText('projects.settings.newProjectName'),
|
name: i18n.baseText('projects.settings.newProjectName'),
|
||||||
|
icon: { type: 'icon', value: 'layer-group' },
|
||||||
});
|
});
|
||||||
await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } });
|
await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } });
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
|
|
|
@ -2556,7 +2556,8 @@
|
||||||
"projects.menu.addFirstProject": "Add first project",
|
"projects.menu.addFirstProject": "Add first project",
|
||||||
"projects.settings": "Project settings",
|
"projects.settings": "Project settings",
|
||||||
"projects.settings.newProjectName": "My project",
|
"projects.settings.newProjectName": "My project",
|
||||||
"projects.settings.name": "Project name",
|
"projects.settings.iconPicker.button.tooltip": "Choose project icon",
|
||||||
|
"projects.settings.name": "Project icon and name",
|
||||||
"projects.settings.projectMembers": "Project members",
|
"projects.settings.projectMembers": "Project members",
|
||||||
"projects.settings.message.unsavedChanges": "You have unsaved changes",
|
"projects.settings.message.unsavedChanges": "You have unsaved changes",
|
||||||
"projects.settings.danger.message": "When deleting a project, you can also choose to move all workflows and credentials to another project.",
|
"projects.settings.danger.message": "When deleting a project, you can also choose to move all workflows and credentials to another project.",
|
||||||
|
@ -2580,6 +2581,7 @@
|
||||||
"projects.settings.delete.successful.title": "Project {projectName} deleted",
|
"projects.settings.delete.successful.title": "Project {projectName} deleted",
|
||||||
"projects.settings.delete.error.title": "An error occurred while deleting the project",
|
"projects.settings.delete.error.title": "An error occurred while deleting the project",
|
||||||
"projects.settings.save.successful.title": "Project {projectName} saved successfully",
|
"projects.settings.save.successful.title": "Project {projectName} saved successfully",
|
||||||
|
"projects.settings.icon.update.successful.title": "Project icon updated successfully",
|
||||||
"projects.settings.save.error.title": "An error occurred while saving the project",
|
"projects.settings.save.error.title": "An error occurred while saving the project",
|
||||||
"projects.settings.role.upgrade.title": "Upgrade to unlock additional roles",
|
"projects.settings.role.upgrade.title": "Upgrade to unlock additional roles",
|
||||||
"projects.settings.role.upgrade.message": "You're currently limited to {limit} on the {planName} plan and can only assign the admin role to users within this project. To create more projects and unlock additional roles, upgrade your plan.",
|
"projects.settings.role.upgrade.message": "You're currently limited to {limit} on the {planName} plan and can only assign the admin role to users within this project. To create more projects and unlock additional roles, upgrade your plan.",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Plugin } from 'vue';
|
import type { Plugin } from 'vue';
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
import type { IconDefinition, Library } from '@fortawesome/fontawesome-svg-core';
|
||||||
import {
|
import {
|
||||||
faAngleDoubleLeft,
|
faAngleDoubleLeft,
|
||||||
faAngleDown,
|
faAngleDown,
|
||||||
|
@ -123,6 +123,7 @@ import {
|
||||||
faSearchPlus,
|
faSearchPlus,
|
||||||
faServer,
|
faServer,
|
||||||
faScrewdriver,
|
faScrewdriver,
|
||||||
|
faSmile,
|
||||||
faSignInAlt,
|
faSignInAlt,
|
||||||
faSignOutAlt,
|
faSignOutAlt,
|
||||||
faSlidersH,
|
faSlidersH,
|
||||||
|
@ -296,6 +297,7 @@ export const FontAwesomePlugin: Plugin = {
|
||||||
addIcon(faSearchPlus);
|
addIcon(faSearchPlus);
|
||||||
addIcon(faServer);
|
addIcon(faServer);
|
||||||
addIcon(faScrewdriver);
|
addIcon(faScrewdriver);
|
||||||
|
addIcon(faSmile);
|
||||||
addIcon(faSignInAlt);
|
addIcon(faSignInAlt);
|
||||||
addIcon(faSignOutAlt);
|
addIcon(faSignOutAlt);
|
||||||
addIcon(faSlidersH);
|
addIcon(faSlidersH);
|
||||||
|
@ -342,3 +344,13 @@ export const FontAwesomePlugin: Plugin = {
|
||||||
app.component('FontAwesomeIcon', FontAwesomeIcon);
|
app.component('FontAwesomeIcon', FontAwesomeIcon);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type LibraryWithDefinitions = Library & {
|
||||||
|
definitions: Record<string, Record<string, IconDefinition>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const iconLibrary = library as LibraryWithDefinitions;
|
||||||
|
|
||||||
|
export const getAllIconNames = () => {
|
||||||
|
return Object.keys(iconLibrary.definitions.fas);
|
||||||
|
};
|
||||||
|
|
|
@ -124,9 +124,11 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
||||||
const projectIndex = myProjects.value.findIndex((p) => p.id === projectData.id);
|
const projectIndex = myProjects.value.findIndex((p) => p.id === projectData.id);
|
||||||
if (projectIndex !== -1) {
|
if (projectIndex !== -1) {
|
||||||
myProjects.value[projectIndex].name = projectData.name;
|
myProjects.value[projectIndex].name = projectData.name;
|
||||||
|
myProjects.value[projectIndex].icon = projectData.icon;
|
||||||
}
|
}
|
||||||
if (currentProject.value) {
|
if (currentProject.value) {
|
||||||
currentProject.value.name = projectData.name;
|
currentProject.value.name = projectData.name;
|
||||||
|
currentProject.value.icon = projectData.icon;
|
||||||
}
|
}
|
||||||
if (projectData.relations) {
|
if (projectData.relations) {
|
||||||
await getProject(projectData.id);
|
await getProject(projectData.id);
|
||||||
|
|
|
@ -18,6 +18,7 @@ export type ProjectRelationPayload = { userId: string; role: ProjectRole };
|
||||||
export type ProjectSharingData = {
|
export type ProjectSharingData = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
icon: ProjectIcon | null;
|
||||||
type: ProjectType;
|
type: ProjectType;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
@ -30,8 +31,13 @@ export type ProjectListItem = ProjectSharingData & {
|
||||||
role: ProjectRole;
|
role: ProjectRole;
|
||||||
scopes?: Scope[];
|
scopes?: Scope[];
|
||||||
};
|
};
|
||||||
export type ProjectCreateRequest = { name: string };
|
export type ProjectCreateRequest = { name: string; icon: ProjectIcon };
|
||||||
export type ProjectUpdateRequest = Pick<Project, 'id' | 'name'> & {
|
export type ProjectUpdateRequest = Pick<Project, 'id' | 'name' | 'icon'> & {
|
||||||
relations: ProjectRelationPayload[];
|
relations: ProjectRelationPayload[];
|
||||||
};
|
};
|
||||||
export type ProjectsCount = Record<ProjectType, number>;
|
export type ProjectsCount = Record<ProjectType, number>;
|
||||||
|
|
||||||
|
export type ProjectIcon = {
|
||||||
|
type: 'icon' | 'emoji';
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
|
@ -66,6 +66,7 @@ describe('ProjectSettings', () => {
|
||||||
id: '123',
|
id: '123',
|
||||||
type: 'team',
|
type: 'team',
|
||||||
name: 'Test Project',
|
name: 'Test Project',
|
||||||
|
icon: { type: 'icon', value: 'folder' },
|
||||||
relations: [],
|
relations: [],
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { useUsersStore } from '@/stores/users.store';
|
||||||
import type { IUser } from '@/Interface';
|
import type { IUser } from '@/Interface';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import type { ProjectIcon } from '@/types/projects.types';
|
||||||
import { type Project, type ProjectRelation } from '@/types/projects.types';
|
import { type Project, type ProjectRelation } from '@/types/projects.types';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
|
@ -19,6 +20,8 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
|
|
||||||
|
import { getAllIconNames } from '@/plugins/icons';
|
||||||
|
|
||||||
type FormDataDiff = {
|
type FormDataDiff = {
|
||||||
name?: string;
|
name?: string;
|
||||||
role?: ProjectRelation[];
|
role?: ProjectRelation[];
|
||||||
|
@ -52,6 +55,13 @@ const projectRoleTranslations = ref<{ [key: string]: string }>({
|
||||||
});
|
});
|
||||||
const nameInput = ref<InstanceType<typeof N8nFormInput> | null>(null);
|
const nameInput = ref<InstanceType<typeof N8nFormInput> | null>(null);
|
||||||
|
|
||||||
|
const availableProjectIcons: string[] = getAllIconNames();
|
||||||
|
|
||||||
|
const projectIcon = ref<ProjectIcon>({
|
||||||
|
type: 'icon',
|
||||||
|
value: 'layer-group',
|
||||||
|
});
|
||||||
|
|
||||||
const usersList = computed(() =>
|
const usersList = computed(() =>
|
||||||
usersStore.allUsers.filter((user: IUser) => {
|
usersStore.allUsers.filter((user: IUser) => {
|
||||||
const isAlreadySharedWithUser = (formData.value.relations || []).find(
|
const isAlreadySharedWithUser = (formData.value.relations || []).find(
|
||||||
|
@ -177,33 +187,41 @@ const sendTelemetry = (diff: FormDataDiff) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const updateProject = async () => {
|
||||||
|
if (!projectsStore.currentProject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (isDirty.value && projectsStore.currentProject) {
|
await projectsStore.updateProject({
|
||||||
const diff = makeFormDataDiff();
|
id: projectsStore.currentProject.id,
|
||||||
|
name: formData.value.name,
|
||||||
await projectsStore.updateProject({
|
icon: projectIcon.value,
|
||||||
id: projectsStore.currentProject.id,
|
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
||||||
name: formData.value.name,
|
userId: r.id,
|
||||||
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
role: r.role,
|
||||||
userId: r.id,
|
})),
|
||||||
role: r.role,
|
});
|
||||||
})),
|
isDirty.value = false;
|
||||||
});
|
|
||||||
sendTelemetry(diff);
|
|
||||||
isDirty.value = false;
|
|
||||||
toast.showMessage({
|
|
||||||
title: i18n.baseText('projects.settings.save.successful.title', {
|
|
||||||
interpolate: { projectName: formData.value.name ?? '' },
|
|
||||||
}),
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, i18n.baseText('projects.settings.save.error.title'));
|
toast.showError(error, i18n.baseText('projects.settings.save.error.title'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!isDirty.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateProject();
|
||||||
|
const diff = makeFormDataDiff();
|
||||||
|
sendTelemetry(diff);
|
||||||
|
toast.showMessage({
|
||||||
|
title: i18n.baseText('projects.settings.save.successful.title', {
|
||||||
|
interpolate: { projectName: formData.value.name ?? '' },
|
||||||
|
}),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
await projectsStore.getAvailableProjects();
|
await projectsStore.getAvailableProjects();
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
|
@ -235,6 +253,14 @@ const selectProjectNameIfMatchesDefault = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onIconUpdated = async () => {
|
||||||
|
await updateProject();
|
||||||
|
toast.showMessage({
|
||||||
|
title: i18n.baseText('projects.settings.icon.update.successful.title'),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => projectsStore.currentProject,
|
() => projectsStore.currentProject,
|
||||||
async () => {
|
async () => {
|
||||||
|
@ -244,6 +270,9 @@ watch(
|
||||||
: [];
|
: [];
|
||||||
await nextTick();
|
await nextTick();
|
||||||
selectProjectNameIfMatchesDefault();
|
selectProjectNameIfMatchesDefault();
|
||||||
|
if (projectsStore.currentProject?.icon) {
|
||||||
|
projectIcon.value = projectsStore.currentProject.icon;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
@ -266,18 +295,27 @@ onMounted(() => {
|
||||||
<form @submit.prevent="onSubmit">
|
<form @submit.prevent="onSubmit">
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<label for="projectName">{{ i18n.baseText('projects.settings.name') }}</label>
|
<label for="projectName">{{ i18n.baseText('projects.settings.name') }}</label>
|
||||||
<N8nFormInput
|
<div :class="$style['project-name']">
|
||||||
id="projectName"
|
<N8nIconPicker
|
||||||
ref="nameInput"
|
v-model="projectIcon"
|
||||||
v-model="formData.name"
|
:button-tooltip="i18n.baseText('projects.settings.iconPicker.button.tooltip')"
|
||||||
label=""
|
:available-icons="availableProjectIcons"
|
||||||
type="text"
|
@update:model-value="onIconUpdated"
|
||||||
name="name"
|
/>
|
||||||
required
|
<N8nFormInput
|
||||||
data-test-id="project-settings-name-input"
|
id="projectName"
|
||||||
@input="onNameInput"
|
ref="nameInput"
|
||||||
@validate="isValid = $event"
|
v-model="formData.name"
|
||||||
/>
|
label=""
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
data-test-id="project-settings-name-input"
|
||||||
|
:class="$style['project-name-input']"
|
||||||
|
@input="onNameInput"
|
||||||
|
@validate="isValid = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<label for="projectMembers">{{ i18n.baseText('projects.settings.projectMembers') }}</label>
|
<label for="projectMembers">{{ i18n.baseText('projects.settings.projectMembers') }}</label>
|
||||||
|
@ -429,4 +467,14 @@ onMounted(() => {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.project-name-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -125,6 +125,7 @@ export interface IUser {
|
||||||
export type ProjectSharingData = {
|
export type ProjectSharingData = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
icon: { type: 'emoji' | 'icon'; value: string } | null;
|
||||||
type: 'personal' | 'team' | 'public';
|
type: 'personal' | 'team' | 'public';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
|
@ -1242,9 +1242,15 @@ importers:
|
||||||
'@fortawesome/vue-fontawesome':
|
'@fortawesome/vue-fontawesome':
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3(@fortawesome/fontawesome-svg-core@1.2.36)(vue@3.5.13(typescript@5.7.2))
|
version: 3.0.3(@fortawesome/fontawesome-svg-core@1.2.36)(vue@3.5.13(typescript@5.7.2))
|
||||||
|
'@vueuse/core':
|
||||||
|
specifier: '*'
|
||||||
|
version: 10.11.0(vue@3.5.13(typescript@5.7.2))
|
||||||
element-plus:
|
element-plus:
|
||||||
specifier: 2.4.3
|
specifier: 2.4.3
|
||||||
version: 2.4.3(vue@3.5.13(typescript@5.7.2))
|
version: 2.4.3(vue@3.5.13(typescript@5.7.2))
|
||||||
|
is-emoji-supported:
|
||||||
|
specifier: ^0.0.5
|
||||||
|
version: 0.0.5
|
||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^13.0.2
|
specifier: ^13.0.2
|
||||||
version: 13.0.2
|
version: 13.0.2
|
||||||
|
@ -8954,6 +8960,9 @@ packages:
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
is-emoji-supported@0.0.5:
|
||||||
|
resolution: {integrity: sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==}
|
||||||
|
|
||||||
is-expression@4.0.0:
|
is-expression@4.0.0:
|
||||||
resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==}
|
resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==}
|
||||||
|
|
||||||
|
@ -21182,7 +21191,7 @@ snapshots:
|
||||||
|
|
||||||
eslint-import-resolver-node@0.3.9:
|
eslint-import-resolver-node@0.3.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
is-core-module: 2.13.1
|
is-core-module: 2.13.1
|
||||||
resolve: 1.22.8
|
resolve: 1.22.8
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
@ -21207,7 +21216,7 @@ snapshots:
|
||||||
|
|
||||||
eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
|
eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.7.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.7.2)
|
'@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.7.2)
|
||||||
eslint: 8.57.0
|
eslint: 8.57.0
|
||||||
|
@ -21227,7 +21236,7 @@ snapshots:
|
||||||
array.prototype.findlastindex: 1.2.3
|
array.prototype.findlastindex: 1.2.3
|
||||||
array.prototype.flat: 1.3.2
|
array.prototype.flat: 1.3.2
|
||||||
array.prototype.flatmap: 1.3.2
|
array.prototype.flatmap: 1.3.2
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 8.57.0
|
eslint: 8.57.0
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
|
@ -22006,7 +22015,7 @@ snapshots:
|
||||||
array-parallel: 0.1.3
|
array-parallel: 0.1.3
|
||||||
array-series: 0.1.5
|
array-series: 0.1.5
|
||||||
cross-spawn: 4.0.2
|
cross-spawn: 4.0.2
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
@ -22524,6 +22533,8 @@ snapshots:
|
||||||
|
|
||||||
is-docker@2.2.1: {}
|
is-docker@2.2.1: {}
|
||||||
|
|
||||||
|
is-emoji-supported@0.0.5: {}
|
||||||
|
|
||||||
is-expression@4.0.0:
|
is-expression@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 7.4.1
|
acorn: 7.4.1
|
||||||
|
@ -24876,7 +24887,7 @@ snapshots:
|
||||||
|
|
||||||
pdf-parse@1.1.1:
|
pdf-parse@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
node-ensure: 0.0.0
|
node-ensure: 0.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
@ -25718,7 +25729,7 @@ snapshots:
|
||||||
|
|
||||||
rhea@1.0.24:
|
rhea@1.0.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue