mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -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');
|
||||
};
|
||||
|
||||
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');
|
||||
|
|
|
@ -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', '😀');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<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.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 {
|
||||
|
|
|
@ -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[];
|
||||
|
||||
|
|
|
@ -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 { 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,
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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 { 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 };
|
||||
|
|
|
@ -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<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(
|
||||
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 }>;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Project> {
|
||||
async createTeamProject(
|
||||
name: string,
|
||||
adminUser: User,
|
||||
id?: string,
|
||||
icon?: ProjectIcon,
|
||||
): Promise<Project> {
|
||||
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<Project> {
|
||||
async updateProject(
|
||||
name: string,
|
||||
projectId: string,
|
||||
icon?: { type: 'icon' | 'emoji'; value: string },
|
||||
): Promise<Project> {
|
||||
const result = await this.projectRepository.update(
|
||||
{
|
||||
id: projectId,
|
||||
|
@ -198,6 +209,7 @@ export class ProjectService {
|
|||
},
|
||||
{
|
||||
name,
|
||||
icon,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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": "*"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,3 +15,8 @@ window.ResizeObserver =
|
|||
observe: 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"
|
||||
@click="handleSelect?.(item)"
|
||||
>
|
||||
<N8nIcon
|
||||
v-if="item.icon"
|
||||
:class="$style.icon"
|
||||
:icon="item.icon"
|
||||
:size="item.customIconSize || 'large'"
|
||||
/>
|
||||
<div v-if="item.icon">
|
||||
<N8nIcon
|
||||
v-if="typeof item.icon === 'string' || item.icon.type === 'icon'"
|
||||
:class="$style.icon"
|
||||
: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="!item.icon && compact" :class="[$style.label, $style.compactLabel]">{{
|
||||
getInitials(item.label)
|
||||
|
|
|
@ -54,3 +54,4 @@ export { default as N8nUserSelect } from './N8nUserSelect';
|
|||
export { default as N8nUsersList } from './N8nUsersList';
|
||||
export { default as N8nResizeObserver } from './ResizeObserver';
|
||||
export { N8nKeyboardShortcut } from './N8nKeyboardShortcut';
|
||||
export { default as N8nIconPicker } from './N8nIconPicker';
|
||||
|
|
|
@ -48,4 +48,7 @@ export default {
|
|||
'assistantChat.copy': 'Copy',
|
||||
'assistantChat.copied': 'Copied',
|
||||
'inlineAskAssistantButton.asked': 'Asked',
|
||||
'iconPicker.button.defaultToolTip': 'Choose icon',
|
||||
'iconPicker.tabs.icons': 'Icons',
|
||||
'iconPicker.tabs.emojis': 'Emojis',
|
||||
} as N8nLocale;
|
||||
|
|
|
@ -5,7 +5,7 @@ import type { RouteLocationRaw, RouterLinkProps } from 'vue-router';
|
|||
export type IMenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
icon?: string | { type: 'icon' | 'emoji'; value: string };
|
||||
secondaryIcon?: {
|
||||
name: string;
|
||||
size?: 'xsmall' | 'small' | 'medium' | 'large';
|
||||
|
|
|
@ -10,6 +10,7 @@ import { ProjectTypes } from '@/types/projects.types';
|
|||
export const createProjectSharingData = (projectType?: ProjectType): ProjectSharingData => ({
|
||||
id: faker.string.uuid(),
|
||||
name: faker.lorem.words({ min: 1, max: 3 }),
|
||||
icon: { type: 'icon', value: 'folder' },
|
||||
type: projectType ?? ProjectTypes.Personal,
|
||||
createdAt: faker.date.past().toISOString(),
|
||||
updatedAt: faker.date.recent().toISOString(),
|
||||
|
@ -29,6 +30,7 @@ export function createTestProject(data: Partial<Project>): Project {
|
|||
return {
|
||||
id: faker.string.uuid(),
|
||||
name: faker.lorem.words({ min: 1, max: 3 }),
|
||||
icon: { type: 'icon', value: 'folder' },
|
||||
createdAt: faker.date.past().toISOString(),
|
||||
updatedAt: faker.date.recent().toISOString(),
|
||||
type: ProjectTypes.Team,
|
||||
|
|
|
@ -37,8 +37,8 @@ export const updateProject = async (
|
|||
context: IRestApiContext,
|
||||
req: ProjectUpdateRequest,
|
||||
): Promise<void> => {
|
||||
const { id, name, relations } = req;
|
||||
await makeRestApiRequest(context, 'PATCH', `/projects/${id}`, { name, relations });
|
||||
const { id, name, icon, relations } = req;
|
||||
await makeRestApiRequest(context, 'PATCH', `/projects/${id}`, { name, icon, relations });
|
||||
};
|
||||
|
||||
export const deleteProject = async (
|
||||
|
|
|
@ -3,7 +3,7 @@ import { computed } from 'vue';
|
|||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { N8nButton, N8nTooltip } from 'n8n-design-system';
|
||||
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 ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
|
@ -17,13 +17,13 @@ const i18n = useI18n();
|
|||
const projectsStore = useProjectsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
|
||||
const headerIcon = computed(() => {
|
||||
const headerIcon = computed((): ProjectIcon => {
|
||||
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
||||
return 'user';
|
||||
return { type: 'icon', value: 'user' };
|
||||
} else if (projectsStore.currentProject?.name) {
|
||||
return 'layer-group';
|
||||
return projectsStore.currentProject.icon ?? { type: 'icon', value: 'layer-group' };
|
||||
} else {
|
||||
return 'home';
|
||||
return { type: 'icon', value: 'home' };
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -107,9 +107,7 @@ const onSelect = (action: string) => {
|
|||
<template>
|
||||
<div>
|
||||
<div :class="[$style.projectHeader]">
|
||||
<div :class="[$style.icon]">
|
||||
<N8nIcon :icon="headerIcon" color="text-light"></N8nIcon>
|
||||
</div>
|
||||
<ProjectIcon :icon="headerIcon" size="medium" />
|
||||
<div>
|
||||
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
|
||||
<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',
|
||||
name: 'My Project',
|
||||
icon: { type: 'icon', value: 'folder' },
|
||||
type: 'personal',
|
||||
role: 'project:personalOwner',
|
||||
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, {
|
||||
global: {
|
||||
plugins: [
|
||||
|
@ -120,7 +124,7 @@ describe('ProjectsNavigation', () => {
|
|||
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;
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
|
@ -130,7 +134,7 @@ describe('ProjectsNavigation', () => {
|
|||
});
|
||||
|
||||
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 () => {
|
||||
|
|
|
@ -33,7 +33,7 @@ const home = computed<IMenuItem>(() => ({
|
|||
const getProjectMenuItem = (project: ProjectListItem) => ({
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
icon: props.collapsed ? undefined : 'layer-group',
|
||||
icon: project.icon,
|
||||
route: {
|
||||
to: {
|
||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||
|
@ -45,7 +45,7 @@ const getProjectMenuItem = (project: ProjectListItem) => ({
|
|||
const personalProject = computed<IMenuItem>(() => ({
|
||||
id: projectsStore.personalProject?.id ?? '',
|
||||
label: locale.baseText('projects.menu.personal'),
|
||||
icon: props.collapsed ? undefined : 'user',
|
||||
icon: 'user',
|
||||
route: {
|
||||
to: {
|
||||
name: VIEWS.PROJECTS_WORKFLOWS,
|
||||
|
|
|
@ -19,11 +19,19 @@ const processedName = computed(() => {
|
|||
email,
|
||||
};
|
||||
});
|
||||
|
||||
const projectIcon = computed(() => {
|
||||
if (props.project.icon) {
|
||||
return props.project.icon;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div :class="$style.projectInfo" data-test-id="project-sharing-info">
|
||||
<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">
|
||||
<p v-if="processedName.firstName || processedName.lastName">
|
||||
{{ processedName.firstName }} {{ processedName.lastName }}
|
||||
|
|
|
@ -173,6 +173,7 @@ export const useGlobalEntityCreation = () => {
|
|||
try {
|
||||
const newProject = await projectsStore.createProject({
|
||||
name: i18n.baseText('projects.settings.newProjectName'),
|
||||
icon: { type: 'icon', value: 'layer-group' },
|
||||
});
|
||||
await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } });
|
||||
toast.showMessage({
|
||||
|
|
|
@ -2556,7 +2556,8 @@
|
|||
"projects.menu.addFirstProject": "Add first project",
|
||||
"projects.settings": "Project settings",
|
||||
"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.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.",
|
||||
|
@ -2580,6 +2581,7 @@
|
|||
"projects.settings.delete.successful.title": "Project {projectName} deleted",
|
||||
"projects.settings.delete.error.title": "An error occurred while deleting the project",
|
||||
"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.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.",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { Plugin } from 'vue';
|
||||
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 {
|
||||
faAngleDoubleLeft,
|
||||
faAngleDown,
|
||||
|
@ -123,6 +123,7 @@ import {
|
|||
faSearchPlus,
|
||||
faServer,
|
||||
faScrewdriver,
|
||||
faSmile,
|
||||
faSignInAlt,
|
||||
faSignOutAlt,
|
||||
faSlidersH,
|
||||
|
@ -296,6 +297,7 @@ export const FontAwesomePlugin: Plugin = {
|
|||
addIcon(faSearchPlus);
|
||||
addIcon(faServer);
|
||||
addIcon(faScrewdriver);
|
||||
addIcon(faSmile);
|
||||
addIcon(faSignInAlt);
|
||||
addIcon(faSignOutAlt);
|
||||
addIcon(faSlidersH);
|
||||
|
@ -342,3 +344,13 @@ export const FontAwesomePlugin: Plugin = {
|
|||
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);
|
||||
if (projectIndex !== -1) {
|
||||
myProjects.value[projectIndex].name = projectData.name;
|
||||
myProjects.value[projectIndex].icon = projectData.icon;
|
||||
}
|
||||
if (currentProject.value) {
|
||||
currentProject.value.name = projectData.name;
|
||||
currentProject.value.icon = projectData.icon;
|
||||
}
|
||||
if (projectData.relations) {
|
||||
await getProject(projectData.id);
|
||||
|
|
|
@ -18,6 +18,7 @@ export type ProjectRelationPayload = { userId: string; role: ProjectRole };
|
|||
export type ProjectSharingData = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
icon: ProjectIcon | null;
|
||||
type: ProjectType;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
@ -30,8 +31,13 @@ export type ProjectListItem = ProjectSharingData & {
|
|||
role: ProjectRole;
|
||||
scopes?: Scope[];
|
||||
};
|
||||
export type ProjectCreateRequest = { name: string };
|
||||
export type ProjectUpdateRequest = Pick<Project, 'id' | 'name'> & {
|
||||
export type ProjectCreateRequest = { name: string; icon: ProjectIcon };
|
||||
export type ProjectUpdateRequest = Pick<Project, 'id' | 'name' | 'icon'> & {
|
||||
relations: ProjectRelationPayload[];
|
||||
};
|
||||
export type ProjectsCount = Record<ProjectType, number>;
|
||||
|
||||
export type ProjectIcon = {
|
||||
type: 'icon' | 'emoji';
|
||||
value: string;
|
||||
};
|
||||
|
|
|
@ -66,6 +66,7 @@ describe('ProjectSettings', () => {
|
|||
id: '123',
|
||||
type: 'team',
|
||||
name: 'Test Project',
|
||||
icon: { type: 'icon', value: 'folder' },
|
||||
relations: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
|
|
@ -7,6 +7,7 @@ import { useUsersStore } from '@/stores/users.store';
|
|||
import type { IUser } from '@/Interface';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { ProjectIcon } from '@/types/projects.types';
|
||||
import { type Project, type ProjectRelation } from '@/types/projects.types';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
@ -19,6 +20,8 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
|||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||
|
||||
import { getAllIconNames } from '@/plugins/icons';
|
||||
|
||||
type FormDataDiff = {
|
||||
name?: string;
|
||||
role?: ProjectRelation[];
|
||||
|
@ -52,6 +55,13 @@ const projectRoleTranslations = ref<{ [key: string]: string }>({
|
|||
});
|
||||
const nameInput = ref<InstanceType<typeof N8nFormInput> | null>(null);
|
||||
|
||||
const availableProjectIcons: string[] = getAllIconNames();
|
||||
|
||||
const projectIcon = ref<ProjectIcon>({
|
||||
type: 'icon',
|
||||
value: 'layer-group',
|
||||
});
|
||||
|
||||
const usersList = computed(() =>
|
||||
usersStore.allUsers.filter((user: IUser) => {
|
||||
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 {
|
||||
if (isDirty.value && projectsStore.currentProject) {
|
||||
const diff = makeFormDataDiff();
|
||||
|
||||
await projectsStore.updateProject({
|
||||
id: projectsStore.currentProject.id,
|
||||
name: formData.value.name,
|
||||
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
||||
userId: r.id,
|
||||
role: r.role,
|
||||
})),
|
||||
});
|
||||
sendTelemetry(diff);
|
||||
isDirty.value = false;
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('projects.settings.save.successful.title', {
|
||||
interpolate: { projectName: formData.value.name ?? '' },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
await projectsStore.updateProject({
|
||||
id: projectsStore.currentProject.id,
|
||||
name: formData.value.name,
|
||||
icon: projectIcon.value,
|
||||
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
||||
userId: r.id,
|
||||
role: r.role,
|
||||
})),
|
||||
});
|
||||
isDirty.value = false;
|
||||
} catch (error) {
|
||||
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 () => {
|
||||
await projectsStore.getAvailableProjects();
|
||||
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(
|
||||
() => projectsStore.currentProject,
|
||||
async () => {
|
||||
|
@ -244,6 +270,9 @@ watch(
|
|||
: [];
|
||||
await nextTick();
|
||||
selectProjectNameIfMatchesDefault();
|
||||
if (projectsStore.currentProject?.icon) {
|
||||
projectIcon.value = projectsStore.currentProject.icon;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
@ -266,18 +295,27 @@ onMounted(() => {
|
|||
<form @submit.prevent="onSubmit">
|
||||
<fieldset>
|
||||
<label for="projectName">{{ i18n.baseText('projects.settings.name') }}</label>
|
||||
<N8nFormInput
|
||||
id="projectName"
|
||||
ref="nameInput"
|
||||
v-model="formData.name"
|
||||
label=""
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
data-test-id="project-settings-name-input"
|
||||
@input="onNameInput"
|
||||
@validate="isValid = $event"
|
||||
/>
|
||||
<div :class="$style['project-name']">
|
||||
<N8nIconPicker
|
||||
v-model="projectIcon"
|
||||
:button-tooltip="i18n.baseText('projects.settings.iconPicker.button.tooltip')"
|
||||
:available-icons="availableProjectIcons"
|
||||
@update:model-value="onIconUpdated"
|
||||
/>
|
||||
<N8nFormInput
|
||||
id="projectName"
|
||||
ref="nameInput"
|
||||
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>
|
||||
<label for="projectMembers">{{ i18n.baseText('projects.settings.projectMembers') }}</label>
|
||||
|
@ -429,4 +467,14 @@ onMounted(() => {
|
|||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
align-items: center;
|
||||
|
||||
.project-name-input {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -125,6 +125,7 @@ export interface IUser {
|
|||
export type ProjectSharingData = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
icon: { type: 'emoji' | 'icon'; value: string } | null;
|
||||
type: 'personal' | 'team' | 'public';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
|
|
@ -1242,9 +1242,15 @@ importers:
|
|||
'@fortawesome/vue-fontawesome':
|
||||
specifier: ^3.0.3
|
||||
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:
|
||||
specifier: 2.4.3
|
||||
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:
|
||||
specifier: ^13.0.2
|
||||
version: 13.0.2
|
||||
|
@ -8954,6 +8960,9 @@ packages:
|
|||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
is-emoji-supported@0.0.5:
|
||||
resolution: {integrity: sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==}
|
||||
|
||||
is-expression@4.0.0:
|
||||
resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==}
|
||||
|
||||
|
@ -21182,7 +21191,7 @@ snapshots:
|
|||
|
||||
eslint-import-resolver-node@0.3.9:
|
||||
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
|
||||
resolve: 1.22.8
|
||||
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):
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.7.2)
|
||||
eslint: 8.57.0
|
||||
|
@ -21227,7 +21236,7 @@ snapshots:
|
|||
array.prototype.findlastindex: 1.2.3
|
||||
array.prototype.flat: 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
|
||||
eslint: 8.57.0
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
|
@ -22006,7 +22015,7 @@ snapshots:
|
|||
array-parallel: 0.1.3
|
||||
array-series: 0.1.5
|
||||
cross-spawn: 4.0.2
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
@ -22524,6 +22533,8 @@ snapshots:
|
|||
|
||||
is-docker@2.2.1: {}
|
||||
|
||||
is-emoji-supported@0.0.5: {}
|
||||
|
||||
is-expression@4.0.0:
|
||||
dependencies:
|
||||
acorn: 7.4.1
|
||||
|
@ -24876,7 +24887,7 @@ snapshots:
|
|||
|
||||
pdf-parse@1.1.1:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
node-ensure: 0.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
@ -25718,7 +25729,7 @@ snapshots:
|
|||
|
||||
rhea@1.0.24:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
Loading…
Reference in a new issue