mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(core): Add description to projects (#15611)
This commit is contained in:
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|||||||
import { Z } from 'zod-class';
|
import { Z } from 'zod-class';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
projectDescriptionSchema,
|
||||||
projectIconSchema,
|
projectIconSchema,
|
||||||
projectNameSchema,
|
projectNameSchema,
|
||||||
projectRelationSchema,
|
projectRelationSchema,
|
||||||
@@ -10,5 +11,6 @@ import {
|
|||||||
export class UpdateProjectDto extends Z.class({
|
export class UpdateProjectDto extends Z.class({
|
||||||
name: projectNameSchema.optional(),
|
name: projectNameSchema.optional(),
|
||||||
icon: projectIconSchema.optional(),
|
icon: projectIconSchema.optional(),
|
||||||
|
description: projectDescriptionSchema.optional(),
|
||||||
relations: z.array(projectRelationSchema).optional(),
|
relations: z.array(projectRelationSchema).optional(),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
projectNameSchema,
|
projectNameSchema,
|
||||||
projectTypeSchema,
|
projectTypeSchema,
|
||||||
projectIconSchema,
|
projectIconSchema,
|
||||||
|
projectDescriptionSchema,
|
||||||
projectRelationSchema,
|
projectRelationSchema,
|
||||||
} from '../project.schema';
|
} from '../project.schema';
|
||||||
|
|
||||||
@@ -56,6 +57,17 @@ describe('project.schema', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('projectDescriptionSchema', () => {
|
||||||
|
test.each([
|
||||||
|
{ description: 'valid description', value: 'Nice Description', expected: true },
|
||||||
|
{ description: 'empty description', value: '', expected: true },
|
||||||
|
{ description: 'name too long', value: 'a'.repeat(513), expected: false },
|
||||||
|
])('should validate $description', ({ value, expected }) => {
|
||||||
|
const result = projectDescriptionSchema.safeParse(value);
|
||||||
|
expect(result.success).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('projectRelationSchema', () => {
|
describe('projectRelationSchema', () => {
|
||||||
test.each([
|
test.each([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const projectIconSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ProjectIcon = z.infer<typeof projectIconSchema>;
|
export type ProjectIcon = z.infer<typeof projectIconSchema>;
|
||||||
|
|
||||||
|
export const projectDescriptionSchema = z.string().max(512);
|
||||||
|
|
||||||
export const projectRelationSchema = z.object({
|
export const projectRelationSchema = z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
role: projectRoleSchema.exclude(['project:personalOwner']),
|
role: projectRoleSchema.exclude(['project:personalOwner']),
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export class Project extends WithTimestampsAndStringId {
|
|||||||
@Column({ type: 'json', nullable: true })
|
@Column({ type: 'json', nullable: true })
|
||||||
icon: { type: 'emoji' | 'icon'; value: string } | null;
|
icon: { type: 'emoji' | 'icon'; value: string } | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 512, nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
@OneToMany('ProjectRelation', 'project')
|
@OneToMany('ProjectRelation', 'project')
|
||||||
projectRelations: ProjectRelation[];
|
projectRelations: ProjectRelation[];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||||
|
|
||||||
|
const columnName = 'description';
|
||||||
|
const tableName = 'project';
|
||||||
|
|
||||||
|
export class AddProjectDescriptionColumn1747824239000 implements ReversibleMigration {
|
||||||
|
async up({ escape, runQuery }: MigrationContext) {
|
||||||
|
const escapedTableName = escape.tableName(tableName);
|
||||||
|
const escapedColumnName = escape.columnName(columnName);
|
||||||
|
|
||||||
|
await runQuery(`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} VARCHAR(512)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down({ escape, runQuery }: MigrationContext) {
|
||||||
|
const escapedTableName = escape.tableName(tableName);
|
||||||
|
const escapedColumnName = escape.columnName(columnName);
|
||||||
|
|
||||||
|
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,7 @@ import { ClearEvaluation1745322634000 } from '../common/1745322634000-CleanEvalu
|
|||||||
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
||||||
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
|
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
|
||||||
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
|
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
|
||||||
|
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
|
||||||
import type { Migration } from '../migration-types';
|
import type { Migration } from '../migration-types';
|
||||||
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
|
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
|
||||||
|
|
||||||
@@ -179,4 +180,5 @@ export const mysqlMigrations: Migration[] = [
|
|||||||
AddWorkflowArchivedColumn1745934666076,
|
AddWorkflowArchivedColumn1745934666076,
|
||||||
DropRoleTable1745934666077,
|
DropRoleTable1745934666077,
|
||||||
ClearEvaluation1745322634000,
|
ClearEvaluation1745322634000,
|
||||||
|
AddProjectDescriptionColumn1747824239000,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import { ClearEvaluation1745322634000 } from '../common/1745322634000-CleanEvalu
|
|||||||
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
||||||
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
|
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
|
||||||
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
|
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
|
||||||
|
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
|
||||||
import type { Migration } from '../migration-types';
|
import type { Migration } from '../migration-types';
|
||||||
|
|
||||||
export const postgresMigrations: Migration[] = [
|
export const postgresMigrations: Migration[] = [
|
||||||
@@ -177,4 +178,5 @@ export const postgresMigrations: Migration[] = [
|
|||||||
AddWorkflowArchivedColumn1745934666076,
|
AddWorkflowArchivedColumn1745934666076,
|
||||||
DropRoleTable1745934666077,
|
DropRoleTable1745934666077,
|
||||||
ClearEvaluation1745322634000,
|
ClearEvaluation1745322634000,
|
||||||
|
AddProjectDescriptionColumn1747824239000,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ import { ClearEvaluation1745322634000 } from '../common/1745322634000-CleanEvalu
|
|||||||
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
||||||
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
|
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
|
||||||
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
|
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
|
||||||
|
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
|
||||||
import type { Migration } from '../migration-types';
|
import type { Migration } from '../migration-types';
|
||||||
|
|
||||||
const sqliteMigrations: Migration[] = [
|
const sqliteMigrations: Migration[] = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
WebhookModel1592445003908,
|
WebhookModel1592445003908,
|
||||||
@@ -171,6 +171,7 @@ const sqliteMigrations: Migration[] = [
|
|||||||
AddWorkflowArchivedColumn1745934666076,
|
AddWorkflowArchivedColumn1745934666076,
|
||||||
DropRoleTable1745934666077,
|
DropRoleTable1745934666077,
|
||||||
ClearEvaluation1745322634000,
|
ClearEvaluation1745322634000,
|
||||||
|
AddProjectDescriptionColumn1747824239000,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export class ProjectController {
|
|||||||
_res: Response,
|
_res: Response,
|
||||||
@Param('projectId') projectId: string,
|
@Param('projectId') projectId: string,
|
||||||
): Promise<ProjectRequest.ProjectWithRelations> {
|
): Promise<ProjectRequest.ProjectWithRelations> {
|
||||||
const [{ id, name, icon, type }, relations] = await Promise.all([
|
const [{ id, name, icon, type, description }, relations] = await Promise.all([
|
||||||
this.projectsService.getProject(projectId),
|
this.projectsService.getProject(projectId),
|
||||||
this.projectsService.getProjectRelations(projectId),
|
this.projectsService.getProjectRelations(projectId),
|
||||||
]);
|
]);
|
||||||
@@ -178,6 +178,7 @@ export class ProjectController {
|
|||||||
name,
|
name,
|
||||||
icon,
|
icon,
|
||||||
type,
|
type,
|
||||||
|
description,
|
||||||
relations: relations.map((r) => ({
|
relations: relations.map((r) => ({
|
||||||
id: r.user.id,
|
id: r.user.id,
|
||||||
email: r.user.email,
|
email: r.user.email,
|
||||||
@@ -202,9 +203,9 @@ export class ProjectController {
|
|||||||
@Body payload: UpdateProjectDto,
|
@Body payload: UpdateProjectDto,
|
||||||
@Param('projectId') projectId: string,
|
@Param('projectId') projectId: string,
|
||||||
) {
|
) {
|
||||||
const { name, icon, relations } = payload;
|
const { name, icon, relations, description } = payload;
|
||||||
if (name || icon) {
|
if ([name, icon, description].some((data) => typeof data === 'string')) {
|
||||||
await this.projectsService.updateProject(projectId, { name, icon });
|
await this.projectsService.updateProject(projectId, { name, icon, description });
|
||||||
}
|
}
|
||||||
if (relations) {
|
if (relations) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ export declare namespace ProjectRequest {
|
|||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
icon: ProjectIcon | null;
|
icon: ProjectIcon | null;
|
||||||
type: ProjectType;
|
type: ProjectType;
|
||||||
|
description: string | null;
|
||||||
relations: ProjectRelationResponse[];
|
relations: ProjectRelationResponse[];
|
||||||
scopes: Scope[];
|
scopes: Scope[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -242,10 +242,13 @@ export class ProjectService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProject(projectId: string, { name, icon }: UpdateProjectDto): Promise<void> {
|
async updateProject(
|
||||||
|
projectId: string,
|
||||||
|
{ name, icon, description }: UpdateProjectDto,
|
||||||
|
): Promise<void> {
|
||||||
const result = await this.projectRepository.update(
|
const result = await this.projectRepository.update(
|
||||||
{ id: projectId, type: 'team' },
|
{ id: projectId, type: 'team' },
|
||||||
{ name, icon },
|
{ name, icon, description },
|
||||||
);
|
);
|
||||||
if (!result.affected) {
|
if (!result.affected) {
|
||||||
throw new ProjectNotFoundError(projectId);
|
throw new ProjectNotFoundError(projectId);
|
||||||
|
|||||||
@@ -1063,6 +1063,7 @@ describe('Public API endpoints with feat:apiKeyScopes enabled', () => {
|
|||||||
name: 'some-project',
|
name: 'some-project',
|
||||||
icon: null,
|
icon: null,
|
||||||
type: 'team',
|
type: 'team',
|
||||||
|
description: null,
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ describe('Projects in Public API', () => {
|
|||||||
name: 'some-project',
|
name: 'some-project',
|
||||||
icon: null,
|
icon: null,
|
||||||
type: 'team',
|
type: 'team',
|
||||||
|
description: null,
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ defineExpose({ inputRef });
|
|||||||
:required="required && showRequiredAsterisk"
|
:required="required && showRequiredAsterisk"
|
||||||
:size="labelSize"
|
:size="labelSize"
|
||||||
>
|
>
|
||||||
<div :class="showErrors ? $style.errorInput : ''" @keydown.stop @keydown.enter="onEnter">
|
<div :class="showErrors ? $style.errorInput : ''" @keydown.stop @keydown.enter.exact="onEnter">
|
||||||
<slot v-if="hasDefaultSlot" />
|
<slot v-if="hasDefaultSlot" />
|
||||||
<N8nSelect
|
<N8nSelect
|
||||||
v-else-if="type === 'select' || type === 'multi-select'"
|
v-else-if="type === 'select' || type === 'multi-select'"
|
||||||
|
|||||||
@@ -2823,6 +2823,7 @@
|
|||||||
"projects.settings.newProjectName": "My project",
|
"projects.settings.newProjectName": "My project",
|
||||||
"projects.settings.iconPicker.button.tooltip": "Choose project icon",
|
"projects.settings.iconPicker.button.tooltip": "Choose project icon",
|
||||||
"projects.settings.name": "Project icon and name",
|
"projects.settings.name": "Project icon and name",
|
||||||
|
"projects.settings.description": "Project description",
|
||||||
"projects.settings.projectMembers": "Project members",
|
"projects.settings.projectMembers": "Project members",
|
||||||
"projects.settings.message.unsavedChanges": "You have unsaved changes",
|
"projects.settings.message.unsavedChanges": "You have unsaved changes",
|
||||||
"projects.settings.danger.message": "When deleting a project, you can also choose to move all workflows and credentials to another project.",
|
"projects.settings.danger.message": "When deleting a project, you can also choose to move all workflows and credentials to another project.",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ vi.mock('@/composables/useProjectPages', () => ({
|
|||||||
useProjectPages: vi.fn().mockReturnValue({
|
useProjectPages: vi.fn().mockReturnValue({
|
||||||
isOverviewSubPage: false,
|
isOverviewSubPage: false,
|
||||||
isSharedSubPage: false,
|
isSharedSubPage: false,
|
||||||
|
isProjectsSubPage: false,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -130,9 +131,10 @@ describe('ProjectHeader', () => {
|
|||||||
expect(getByTestId('project-subtitle')).toHaveTextContent(personalSubtitle);
|
expect(getByTestId('project-subtitle')).toHaveTextContent(personalSubtitle);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Team project: should render the correct title and subtitle', async () => {
|
it('Team project: should render the correct title and no subtitle if there is no description', async () => {
|
||||||
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||||
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
|
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
|
||||||
|
vi.spyOn(projectPages, 'isProjectsSubPage', 'get').mockReturnValue(true);
|
||||||
const { getByTestId, queryByTestId, rerender } = renderComponent();
|
const { getByTestId, queryByTestId, rerender } = renderComponent();
|
||||||
|
|
||||||
const projectName = 'My Project';
|
const projectName = 'My Project';
|
||||||
@@ -144,18 +146,23 @@ describe('ProjectHeader', () => {
|
|||||||
expect(queryByTestId('project-subtitle')).not.toBeInTheDocument();
|
expect(queryByTestId('project-subtitle')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should overwrite default subtitle with slot', () => {
|
it('Team project: should render the correct title and subtitle if there is a description', async () => {
|
||||||
const defaultSubtitle = 'All the workflows, credentials and executions you have access to';
|
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
|
||||||
const subtitle = 'Custom subtitle';
|
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
|
||||||
|
vi.spyOn(projectPages, 'isProjectsSubPage', 'get').mockReturnValue(true);
|
||||||
|
const { getByTestId, rerender } = renderComponent();
|
||||||
|
|
||||||
const { getByText, queryByText } = renderComponent({
|
const projectName = 'My Project';
|
||||||
slots: {
|
const projectDescription = 'This is a team project description';
|
||||||
subtitle,
|
projectsStore.currentProject = {
|
||||||
},
|
name: projectName,
|
||||||
});
|
description: projectDescription,
|
||||||
|
} as Project;
|
||||||
|
|
||||||
expect(getByText(subtitle)).toBeVisible();
|
await rerender({});
|
||||||
expect(queryByText(defaultSubtitle)).not.toBeInTheDocument();
|
|
||||||
|
expect(getByTestId('project-name')).toHaveTextContent(projectName);
|
||||||
|
expect(getByTestId('project-subtitle')).toHaveTextContent(projectDescription);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render ProjectTabs Settings if project is team project and user has update scope', () => {
|
it('should render ProjectTabs Settings if project is team project and user has update scope', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useElementSize, useResizeObserver } from '@vueuse/core';
|
||||||
import type { UserAction } from '@n8n/design-system';
|
import type { UserAction } from '@n8n/design-system';
|
||||||
import { N8nButton, N8nTooltip } from '@n8n/design-system';
|
import { N8nButton, N8nTooltip } from '@n8n/design-system';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
@@ -14,6 +15,7 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
|
|||||||
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
|
import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useProjectPages } from '@/composables/useProjectPages';
|
import { useProjectPages } from '@/composables/useProjectPages';
|
||||||
|
import { truncateTextToFitWidth } from '@/utils/formatters/textFormatter';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -155,7 +157,7 @@ const pageType = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const subtitle = computed(() => {
|
const sectionDescription = computed(() => {
|
||||||
if (projectPages.isOverviewSubPage) {
|
if (projectPages.isOverviewSubPage) {
|
||||||
return i18n.baseText('projects.header.overview.subtitle');
|
return i18n.baseText('projects.header.overview.subtitle');
|
||||||
} else if (projectPages.isSharedSubPage) {
|
} else if (projectPages.isSharedSubPage) {
|
||||||
@@ -163,9 +165,51 @@ const subtitle = computed(() => {
|
|||||||
} else if (isPersonalProject.value) {
|
} else if (isPersonalProject.value) {
|
||||||
return i18n.baseText('projects.header.personal.subtitle');
|
return i18n.baseText('projects.header.personal.subtitle');
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectDescription = computed(() => {
|
||||||
|
if (projectPages.isProjectsSubPage) {
|
||||||
|
return projectsStore.currentProject?.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectHeaderRef = ref<HTMLElement | null>(null);
|
||||||
|
const { width: projectHeaderWidth } = useElementSize(projectHeaderRef);
|
||||||
|
|
||||||
|
const headerActionsRef = ref<HTMLElement | null>(null);
|
||||||
|
const { width: headerActionsWidth } = useElementSize(headerActionsRef);
|
||||||
|
|
||||||
|
const projectSubtitleFontSizeInPxs = ref<number | null>(null);
|
||||||
|
|
||||||
|
useResizeObserver(projectHeaderRef, () => {
|
||||||
|
if (!projectHeaderRef.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectSubtitleEl = projectHeaderRef.value.querySelector(
|
||||||
|
'span[data-test-id="project-subtitle"]',
|
||||||
|
);
|
||||||
|
if (projectSubtitleEl) {
|
||||||
|
const computedStyle = window.getComputedStyle(projectSubtitleEl);
|
||||||
|
projectSubtitleFontSizeInPxs.value = parseFloat(computedStyle.fontSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectDescriptionTruncated = computed(() => {
|
||||||
|
if (!projectDescription.value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableTextWidth = projectHeaderWidth.value - headerActionsWidth.value;
|
||||||
|
// Fallback to N8nText component default font-size, small
|
||||||
|
const fontSizeInPixels = projectSubtitleFontSizeInPxs.value ?? 14;
|
||||||
|
return truncateTextToFitWidth(projectDescription.value, availableTextWidth, fontSizeInPixels);
|
||||||
|
});
|
||||||
|
|
||||||
const onSelect = (action: string) => {
|
const onSelect = (action: string) => {
|
||||||
const executableAction = actions[action as ActionTypes];
|
const executableAction = actions[action as ActionTypes];
|
||||||
if (!homeProject.value) {
|
if (!homeProject.value) {
|
||||||
@@ -177,23 +221,33 @@ const onSelect = (action: string) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div :class="$style.projectHeader">
|
<div ref="projectHeaderRef" :class="$style.projectHeader">
|
||||||
<div :class="$style.projectDetails">
|
<div :class="$style.projectDetails">
|
||||||
<ProjectIcon v-if="showProjectIcon" :icon="headerIcon" :border-less="true" size="medium" />
|
<ProjectIcon v-if="showProjectIcon" :icon="headerIcon" :border-less="true" size="medium" />
|
||||||
<div :class="$style.headerActions">
|
<div :class="$style.headerActions">
|
||||||
<N8nHeading v-if="projectName" bold tag="h2" size="xlarge" data-test-id="project-name">{{
|
<N8nHeading v-if="projectName" bold tag="h2" size="xlarge" data-test-id="project-name">{{
|
||||||
projectName
|
projectName
|
||||||
}}</N8nHeading>
|
}}</N8nHeading>
|
||||||
<N8nText color="text-light">
|
<N8nText v-if="sectionDescription" color="text-light" data-test-id="project-subtitle">
|
||||||
<slot name="subtitle">
|
{{ sectionDescription }}
|
||||||
<N8nText v-if="subtitle" color="text-light" data-test-id="project-subtitle">{{
|
|
||||||
subtitle
|
|
||||||
}}</N8nText>
|
|
||||||
</slot>
|
|
||||||
</N8nText>
|
</N8nText>
|
||||||
|
<template v-else-if="projectDescription">
|
||||||
|
<div :class="$style.projectDescriptionWrapper">
|
||||||
|
<N8nText color="text-light" data-test-id="project-subtitle">
|
||||||
|
{{ projectDescriptionTruncated || projectDescription }}
|
||||||
|
</N8nText>
|
||||||
|
<div v-if="projectDescriptionTruncated" :class="$style.tooltip">
|
||||||
|
<N8nText color="text-light">{{ projectDescription }}</N8nText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="route.name !== VIEWS.PROJECT_SETTINGS"
|
||||||
|
ref="headerActionsRef"
|
||||||
|
:class="[$style.headerActions]"
|
||||||
|
>
|
||||||
<N8nTooltip
|
<N8nTooltip
|
||||||
:disabled="!sourceControlStore.preferences.branchReadOnly"
|
:disabled="!sourceControlStore.preferences.branchReadOnly"
|
||||||
:content="i18n.baseText('readOnlyEnv.cantAdd.any')"
|
:content="i18n.baseText('readOnlyEnv.cantAdd.any')"
|
||||||
@@ -225,8 +279,7 @@ const onSelect = (action: string) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.projectHeader,
|
.projectHeader {
|
||||||
.projectDescription {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -243,6 +296,28 @@ const onSelect = (action: string) => {
|
|||||||
padding: var(--spacing-2xs) 0 var(--spacing-xs);
|
padding: var(--spacing-2xs) 0 var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.projectDescriptionWrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
&:hover .tooltip {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: calc(-1 * var(--spacing-3xs));
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
padding: 0 var(--spacing-3xs) var(--spacing-3xs);
|
||||||
|
z-index: 10;
|
||||||
|
white-space: normal;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
@include mixins.breakpoint('xs-only') {
|
@include mixins.breakpoint('xs-only') {
|
||||||
.projectHeader {
|
.projectHeader {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -24,8 +24,18 @@ export const useProjectPages = () => {
|
|||||||
route.name === VIEWS.SHARED_CREDENTIALS,
|
route.name === VIEWS.SHARED_CREDENTIALS,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isProjectsSubPage = computed(
|
||||||
|
() =>
|
||||||
|
route.name === VIEWS.PROJECTS_WORKFLOWS ||
|
||||||
|
route.name === VIEWS.PROJECTS_CREDENTIALS ||
|
||||||
|
route.name === VIEWS.PROJECTS_EXECUTIONS ||
|
||||||
|
route.name === VIEWS.PROJECT_SETTINGS ||
|
||||||
|
route.name === VIEWS.PROJECTS_FOLDERS,
|
||||||
|
);
|
||||||
|
|
||||||
return reactive({
|
return reactive({
|
||||||
isOverviewSubPage,
|
isOverviewSubPage,
|
||||||
isSharedSubPage,
|
isSharedSubPage,
|
||||||
|
isProjectsSubPage,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -123,14 +123,16 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await projectsApi.updateProject(rootStore.restApiContext, id, projectData);
|
await projectsApi.updateProject(rootStore.restApiContext, id, projectData);
|
||||||
const projectIndex = myProjects.value.findIndex((p) => p.id === id);
|
const projectIndex = myProjects.value.findIndex((p) => p.id === id);
|
||||||
const { name, icon } = projectData;
|
const { name, icon, description } = projectData;
|
||||||
if (projectIndex !== -1) {
|
if (projectIndex !== -1) {
|
||||||
myProjects.value[projectIndex].name = name;
|
myProjects.value[projectIndex].name = name;
|
||||||
myProjects.value[projectIndex].icon = icon;
|
myProjects.value[projectIndex].icon = icon;
|
||||||
|
myProjects.value[projectIndex].description = description;
|
||||||
}
|
}
|
||||||
if (currentProject.value) {
|
if (currentProject.value) {
|
||||||
currentProject.value.name = name;
|
currentProject.value.name = name;
|
||||||
currentProject.value.icon = icon;
|
currentProject.value.icon = icon;
|
||||||
|
currentProject.value.description = description;
|
||||||
}
|
}
|
||||||
if (projectData.relations) {
|
if (projectData.relations) {
|
||||||
await getProject(id);
|
await getProject(id);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export type ProjectSharingData = {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
icon: ProjectIcon | null;
|
icon: ProjectIcon | null;
|
||||||
type: ProjectType;
|
type: ProjectType;
|
||||||
|
description?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Truncate text to fit within a specified width, adding an ellipsis if necessary.
|
||||||
|
* @param text The text to truncate.
|
||||||
|
* @param availableWidth The available width for the text in pixels.
|
||||||
|
* @param fontSizeInPixels The font size of the text in pixels.
|
||||||
|
* @returns The truncated text with ellipsis, or an empty string if the text fits within the available width.
|
||||||
|
*/
|
||||||
|
export const truncateTextToFitWidth = (
|
||||||
|
text: string,
|
||||||
|
availableWidth: number,
|
||||||
|
fontSizeInPixels: number,
|
||||||
|
): string => {
|
||||||
|
if (!text || availableWidth <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageCharWidth = 0.55 * fontSizeInPixels;
|
||||||
|
|
||||||
|
const maxLengthToDisplay = Math.floor(availableWidth / averageCharWidth);
|
||||||
|
|
||||||
|
if (text.length <= maxLengthToDisplay) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = text.slice(0, maxLengthToDisplay);
|
||||||
|
const lastSpaceIndex = truncated.lastIndexOf(' ');
|
||||||
|
return truncated.slice(0, lastSpaceIndex === -1 ? maxLengthToDisplay : lastSpaceIndex) + '...';
|
||||||
|
};
|
||||||
@@ -23,6 +23,7 @@ import { getAllIconNames } from '@/plugins/icons';
|
|||||||
|
|
||||||
type FormDataDiff = {
|
type FormDataDiff = {
|
||||||
name?: string;
|
name?: string;
|
||||||
|
description?: string;
|
||||||
role?: ProjectRelation[];
|
role?: ProjectRelation[];
|
||||||
memberAdded?: ProjectRelation[];
|
memberAdded?: ProjectRelation[];
|
||||||
memberRemoved?: ProjectRelation[];
|
memberRemoved?: ProjectRelation[];
|
||||||
@@ -44,8 +45,9 @@ const upgradeDialogVisible = ref(false);
|
|||||||
const isDirty = ref(false);
|
const isDirty = ref(false);
|
||||||
const isValid = ref(false);
|
const isValid = ref(false);
|
||||||
const isCurrentProjectEmpty = ref(true);
|
const isCurrentProjectEmpty = ref(true);
|
||||||
const formData = ref<Pick<Project, 'name' | 'relations'>>({
|
const formData = ref<Pick<Project, 'name' | 'description' | 'relations'>>({
|
||||||
name: '',
|
name: '',
|
||||||
|
description: '',
|
||||||
relations: [],
|
relations: [],
|
||||||
});
|
});
|
||||||
const projectRoleTranslations = ref<{ [key: string]: string }>({
|
const projectRoleTranslations = ref<{ [key: string]: string }>({
|
||||||
@@ -110,7 +112,7 @@ const onRoleAction = (user: Partial<IUser>, role: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onNameInput = () => {
|
const onTextInput = () => {
|
||||||
isDirty.value = true;
|
isDirty.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,6 +121,7 @@ const onCancel = () => {
|
|||||||
? deepCopy(projectsStore.currentProject.relations)
|
? deepCopy(projectsStore.currentProject.relations)
|
||||||
: [];
|
: [];
|
||||||
formData.value.name = projectsStore.currentProject?.name ?? '';
|
formData.value.name = projectsStore.currentProject?.name ?? '';
|
||||||
|
formData.value.description = projectsStore.currentProject?.description ?? '';
|
||||||
isDirty.value = false;
|
isDirty.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,6 +135,10 @@ const makeFormDataDiff = (): FormDataDiff => {
|
|||||||
diff.name = formData.value.name ?? '';
|
diff.name = formData.value.name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formData.value.description !== projectsStore.currentProject.description) {
|
||||||
|
diff.description = formData.value.description ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.value.relations.length !== projectsStore.currentProject.relations.length) {
|
if (formData.value.relations.length !== projectsStore.currentProject.relations.length) {
|
||||||
diff.memberAdded = formData.value.relations.filter(
|
diff.memberAdded = formData.value.relations.filter(
|
||||||
(r: ProjectRelation) => !projectsStore.currentProject?.relations.find((cr) => cr.id === r.id),
|
(r: ProjectRelation) => !projectsStore.currentProject?.relations.find((cr) => cr.id === r.id),
|
||||||
@@ -198,6 +205,7 @@ const updateProject = async () => {
|
|||||||
await projectsStore.updateProject(projectsStore.currentProject.id, {
|
await projectsStore.updateProject(projectsStore.currentProject.id, {
|
||||||
name: formData.value.name!,
|
name: formData.value.name!,
|
||||||
icon: projectIcon.value,
|
icon: projectIcon.value,
|
||||||
|
description: formData.value.description!,
|
||||||
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
relations: formData.value.relations.map((r: ProjectRelation) => ({
|
||||||
userId: r.id,
|
userId: r.id,
|
||||||
role: r.role as TeamProjectRole,
|
role: r.role as TeamProjectRole,
|
||||||
@@ -274,6 +282,7 @@ watch(
|
|||||||
() => projectsStore.currentProject,
|
() => projectsStore.currentProject,
|
||||||
async () => {
|
async () => {
|
||||||
formData.value.name = projectsStore.currentProject?.name ?? '';
|
formData.value.name = projectsStore.currentProject?.name ?? '';
|
||||||
|
formData.value.description = projectsStore.currentProject?.description ?? '';
|
||||||
formData.value.relations = projectsStore.currentProject?.relations
|
formData.value.relations = projectsStore.currentProject?.relations
|
||||||
? deepCopy(projectsStore.currentProject.relations)
|
? deepCopy(projectsStore.currentProject.relations)
|
||||||
: [];
|
: [];
|
||||||
@@ -335,11 +344,27 @@ onMounted(() => {
|
|||||||
data-test-id="project-settings-name-input"
|
data-test-id="project-settings-name-input"
|
||||||
:class="$style['project-name-input']"
|
:class="$style['project-name-input']"
|
||||||
@enter="onSubmit"
|
@enter="onSubmit"
|
||||||
@input="onNameInput"
|
@input="onTextInput"
|
||||||
@validate="isValid = $event"
|
@validate="isValid = $event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<label for="projectDescription">{{ i18n.baseText('projects.settings.description') }}</label>
|
||||||
|
<N8nFormInput
|
||||||
|
id="projectDescription"
|
||||||
|
v-model="formData.description"
|
||||||
|
label=""
|
||||||
|
name="description"
|
||||||
|
type="textarea"
|
||||||
|
:maxlength="512"
|
||||||
|
:autosize="true"
|
||||||
|
data-test-id="project-settings-description-input"
|
||||||
|
@enter="onSubmit"
|
||||||
|
@input="onTextInput"
|
||||||
|
@validate="isValid = $event"
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<label for="projectMembers">{{ i18n.baseText('projects.settings.projectMembers') }}</label>
|
<label for="projectMembers">{{ i18n.baseText('projects.settings.projectMembers') }}</label>
|
||||||
<N8nUserSelect
|
<N8nUserSelect
|
||||||
|
|||||||
Reference in New Issue
Block a user