feat(editor): Add support for project icons (#12349)

This commit is contained in:
Milorad FIlipović 2024-12-27 19:00:40 +01:00 committed by GitHub
parent 7ea6c8b144
commit 9117718cc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 962 additions and 76 deletions

View file

@ -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');

View file

@ -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', '😀');
});
});

View file

@ -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 {

View file

@ -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[];

View file

@ -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']);
}
}

View file

@ -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,
];

View file

@ -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,
];

View file

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

View file

@ -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 };

View file

@ -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 }>;
}

View file

@ -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,
});
}
}

View file

@ -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,
},
);

View file

@ -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,
},
]),

View file

@ -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),

View file

@ -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: [],

View file

@ -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": "*"
}
}

View file

@ -15,3 +15,8 @@ window.ResizeObserver =
observe: vi.fn(),
unobserve: vi.fn(),
}));
// Globally mock is-emoji-supported
vi.mock('is-emoji-supported', () => ({
isEmojiSupported: () => true,
}));

View file

@ -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: [],
};

View file

@ -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: '😀',
});
});
});

View file

@ -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>

View 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',
];

View file

@ -0,0 +1,2 @@
import IconPicker from './IconPicker.vue';
export default IconPicker;

View file

@ -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)

View file

@ -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';

View file

@ -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;

View file

@ -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';

View file

@ -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,

View file

@ -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 (

View file

@ -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">

View 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>

View file

@ -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',

View file

@ -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 () => {

View file

@ -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,

View file

@ -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 }}

View file

@ -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({

View file

@ -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.",

View file

@ -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);
};

View file

@ -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);

View file

@ -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;
};

View file

@ -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(),

View file

@ -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>

View file

@ -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;

View file

@ -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