diff --git a/packages/twenty-sdk/src/cli/commands/add.ts b/packages/twenty-sdk/src/cli/commands/add.ts index b1973e60dfe..bf47c64fe09 100644 --- a/packages/twenty-sdk/src/cli/commands/add.ts +++ b/packages/twenty-sdk/src/cli/commands/add.ts @@ -3,7 +3,7 @@ import inquirer from 'inquirer'; import { writeFile } from 'node:fs/promises'; import { join, relative } from 'path'; import { SyncableEntity } from 'twenty-shared/application'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { FieldMetadataType, ViewType } from 'twenty-shared/types'; import { assertUnreachable } from 'twenty-shared/utils'; import { v4 } from 'uuid'; @@ -15,6 +15,7 @@ import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-fu import { getNavigationMenuItemBaseFile } from '@/cli/utilities/entity/entity-navigation-menu-item-template'; import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template'; import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template'; +import { getRecordPageLayoutBaseFile } from '@/cli/utilities/entity/entity-record-page-layout-template'; import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template'; import { getAgentBaseFile } from '@/cli/utilities/entity/entity-agent-template'; import { getSkillBaseFile } from '@/cli/utilities/entity/entity-skill-template'; @@ -27,6 +28,7 @@ const APP_FOLDER = 'src'; export class EntityAddCommand { private lastObjectUniversalIdentifier: string | undefined; private lastNameFieldUniversalIdentifier: string | undefined; + private lastObjectLabelSingular: string | undefined; async execute(entityType?: SyncableEntity, path?: string): Promise { try { @@ -59,7 +61,7 @@ export class EntityAddCommand { ); if (entity === SyncableEntity.Object) { - await this.promptAndCreateViewAndNavigationMenuItem(name, path); + await this.promptAndCreateObjectCompanions(name, path); } } catch (error) { console.error( @@ -81,6 +83,7 @@ export class EntityAddCommand { this.lastObjectUniversalIdentifier = objectUniversalIdentifier; this.lastNameFieldUniversalIdentifier = nameFieldUniversalIdentifier; + this.lastObjectLabelSingular = entityData.labelSingular; const file = getObjectBaseFile({ data: entityData, @@ -191,27 +194,28 @@ export class EntityAddCommand { } } - private async promptAndCreateViewAndNavigationMenuItem( + private async promptAndCreateObjectCompanions( objectName: string, customPath?: string, ): Promise { - const { createViewAndNavItem } = await inquirer.prompt<{ - createViewAndNavItem: boolean; + const { createCompanions } = await inquirer.prompt<{ + createCompanions: boolean; }>([ { type: 'confirm', - name: 'createViewAndNavItem', + name: 'createCompanions', message: - 'Also create a view and navigation menu item for this object? (recommended)', + 'Also create a view, navigation menu item, and record page layout for this object? (recommended)', default: true, }, ]); - if (!createViewAndNavItem || !this.lastObjectUniversalIdentifier) { + if (!createCompanions || !this.lastObjectUniversalIdentifier) { return; } const viewUniversalIdentifier = v4(); + const fieldsWidgetViewUniversalIdentifier = v4(); const viewFile = getViewBaseFile({ name: `all-${kebabCase(objectName)}`, @@ -258,6 +262,41 @@ export class EntityAddCommand { chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, viewFilePath)), ); + const recordPageFieldsViewFields = this.buildRecordPageFieldsViewFields( + this.lastNameFieldUniversalIdentifier, + ); + + const recordPageFieldsViewFile = getViewBaseFile({ + name: `${kebabCase(objectName)}-record-page-fields`, + universalIdentifier: fieldsWidgetViewUniversalIdentifier, + objectUniversalIdentifier: this.lastObjectUniversalIdentifier, + type: ViewType.FIELDS_WIDGET, + fields: recordPageFieldsViewFields, + }); + + const recordPageFieldsViewFileName = `${kebabCase(objectName)}-record-page-fields.ts`; + const recordPageFieldsViewFilePath = join( + viewFolderPath, + recordPageFieldsViewFileName, + ); + + if (await pathExists(recordPageFieldsViewFilePath)) { + const { overwrite } = await this.handleFileExist(); + + if (!overwrite) { + return; + } + } + + await writeFile(recordPageFieldsViewFilePath, recordPageFieldsViewFile); + + console.log( + chalk.green(`✓ Created record-page-fields view:`), + chalk.cyan( + relative(CURRENT_EXECUTION_DIRECTORY, recordPageFieldsViewFilePath), + ), + ); + const navFile = getNavigationMenuItemBaseFile({ name: objectName, type: 'OBJECT', @@ -291,6 +330,75 @@ export class EntityAddCommand { chalk.green(`✓ Created navigation menu item:`), chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, navFilePath)), ); + + const recordPageLayoutFile = getRecordPageLayoutBaseFile({ + objectLabelSingular: this.lastObjectLabelSingular ?? objectName, + objectUniversalIdentifier: this.lastObjectUniversalIdentifier, + fieldsWidgetViewUniversalIdentifier, + }); + + const pageLayoutFolderPath = customPath + ? join(CURRENT_EXECUTION_DIRECTORY, customPath) + : join( + CURRENT_EXECUTION_DIRECTORY, + APP_FOLDER, + this.getFolderName(SyncableEntity.PageLayout), + ); + + await ensureDir(pageLayoutFolderPath); + + const recordPageLayoutFileName = `${kebabCase(objectName)}-record-page-layout.ts`; + const recordPageLayoutFilePath = join( + pageLayoutFolderPath, + recordPageLayoutFileName, + ); + + if (await pathExists(recordPageLayoutFilePath)) { + const { overwrite } = await this.handleFileExist(); + + if (!overwrite) { + return; + } + } + + await writeFile(recordPageLayoutFilePath, recordPageLayoutFile); + + console.log( + chalk.green(`✓ Created record page layout:`), + chalk.cyan( + relative(CURRENT_EXECUTION_DIRECTORY, recordPageLayoutFilePath), + ), + ); + } + + private buildRecordPageFieldsViewFields( + nameFieldUniversalIdentifier: string | undefined, + ) { + const autoGeneratedFieldNames = [ + 'createdAt', + 'updatedAt', + 'createdBy', + 'updatedBy', + ] as const; + + const autoGeneratedFields = autoGeneratedFieldNames.map((fieldName) => ({ + defaultFieldName: fieldName, + isVisible: true, + size: 200, + })); + + const fields = nameFieldUniversalIdentifier + ? [ + { + fieldMetadataUniversalIdentifier: nameFieldUniversalIdentifier, + isVisible: true, + size: 200, + }, + ...autoGeneratedFields, + ] + : autoGeneratedFields; + + return fields.map((field, index) => ({ ...field, position: index })); } private async getEntity() { diff --git a/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-view-base-file.spec.ts b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-view-base-file.spec.ts index 68536985b89..16e20f301e9 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-view-base-file.spec.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-view-base-file.spec.ts @@ -72,8 +72,8 @@ describe('getViewBaseFile', () => { }); // Default isVisible is true, default size is 200 - expect(result).toContain('"isVisible": true'); - expect(result).toContain('"size": 200'); + expect(result).toContain('isVisible: true'); + expect(result).toContain('size: 200'); }); it('should generate unique UUID when not provided', () => { @@ -113,4 +113,41 @@ describe('getViewBaseFile', () => { // At least 2 UUIDs: one for the view and one for the field expect(matches!.length).toBeGreaterThanOrEqual(2); }); + + it('should emit a generateDefaultFieldUniversalIdentifier call for fields using defaultFieldName', () => { + const result = getViewBaseFile({ + name: 'view-default-field', + objectUniversalIdentifier: 'obj-abc-123', + fields: [ + { + defaultFieldName: 'createdAt', + position: 0, + }, + ], + }); + + expect(result).toContain( + "import {\n defineView,\n generateDefaultFieldUniversalIdentifier,\n} from 'twenty-sdk/define';", + ); + expect(result).toContain( + 'fieldMetadataUniversalIdentifier: generateDefaultFieldUniversalIdentifier({', + ); + expect(result).toContain("objectUniversalIdentifier: 'obj-abc-123'"); + expect(result).toContain("fieldName: 'createdAt'"); + }); + + it('should not import generateDefaultFieldUniversalIdentifier when no field uses defaultFieldName', () => { + const result = getViewBaseFile({ + name: 'view-literal-only', + fields: [ + { + fieldMetadataUniversalIdentifier: 'field-uuid-1', + position: 0, + }, + ], + }); + + expect(result).not.toContain('generateDefaultFieldUniversalIdentifier'); + expect(result).toContain("import { defineView } from 'twenty-sdk/define';"); + }); }); diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-record-page-layout-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-record-page-layout-template.ts new file mode 100644 index 00000000000..7837b555be6 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-record-page-layout-template.ts @@ -0,0 +1,58 @@ +import { v4 as uuidv4 } from 'uuid'; + +export const getRecordPageLayoutBaseFile = ({ + objectLabelSingular, + objectUniversalIdentifier, + fieldsWidgetViewUniversalIdentifier, +}: { + objectLabelSingular: string; + objectUniversalIdentifier: string; + fieldsWidgetViewUniversalIdentifier: string; +}) => { + return `import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define'; + +export default definePageLayout({ + universalIdentifier: '${uuidv4()}', + name: 'Default ${objectLabelSingular} Layout', + type: 'RECORD_PAGE', + objectUniversalIdentifier: '${objectUniversalIdentifier}', + tabs: [ + { + universalIdentifier: '${uuidv4()}', + title: 'Home', + position: 10, + icon: 'IconHome', + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + widgets: [ + { + universalIdentifier: '${uuidv4()}', + title: 'Fields', + type: 'FIELDS', + configuration: { + configurationType: 'FIELDS', + viewUniversalIdentifier: '${fieldsWidgetViewUniversalIdentifier}', + }, + }, + ], + }, + { + universalIdentifier: '${uuidv4()}', + title: 'Timeline', + position: 20, + icon: 'IconTimelineEvent', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: '${uuidv4()}', + title: 'Timeline', + type: 'TIMELINE', + configuration: { + configurationType: 'TIMELINE', + }, + }, + ], + }, + ], +}); +`; +}; diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts index 98e013c464e..d0c2254e556 100644 --- a/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts @@ -1,37 +1,67 @@ import { kebabCase } from '@/cli/utilities/string/kebab-case'; +import { type ViewType } from 'twenty-shared/types'; import { v4 } from 'uuid'; -type ViewFieldTemplate = { +type ViewFieldTemplateBase = { universalIdentifier?: string; - fieldMetadataUniversalIdentifier: string; position: number; isVisible?: boolean; size?: number; }; +type ViewFieldTemplate = + | (ViewFieldTemplateBase & { fieldMetadataUniversalIdentifier: string }) + | (ViewFieldTemplateBase & { defaultFieldName: string }); + +const renderFieldEntry = ({ + field, + index, + objectUniversalIdentifier, +}: { + field: ViewFieldTemplate; + index: number; + objectUniversalIdentifier: string; +}) => { + const universalIdentifier = field.universalIdentifier ?? v4(); + const position = field.position ?? index; + const isVisible = field.isVisible ?? true; + const size = field.size ?? 200; + + const fieldMetadataUniversalIdentifierLine = + 'defaultFieldName' in field + ? ` fieldMetadataUniversalIdentifier: generateDefaultFieldUniversalIdentifier({ + objectUniversalIdentifier: '${objectUniversalIdentifier}', + fieldName: '${field.defaultFieldName}', + })` + : ` fieldMetadataUniversalIdentifier: '${field.fieldMetadataUniversalIdentifier}'`; + + return ` { + universalIdentifier: '${universalIdentifier}', +${fieldMetadataUniversalIdentifierLine}, + position: ${position}, + isVisible: ${isVisible}, + size: ${size}, + }`; +}; + export const getViewBaseFile = ({ name, universalIdentifier = v4(), objectUniversalIdentifier = 'fill-later', fields = [], + type, }: { name: string; universalIdentifier?: string; objectUniversalIdentifier?: string; fields?: ViewFieldTemplate[]; + type?: ViewType; }) => { const kebabCaseName = kebabCase(name); - const formattedFields = fields.map((field, index) => { - const uid = field.universalIdentifier ?? v4(); - return { - universalIdentifier: uid, - fieldMetadataUniversalIdentifier: field.fieldMetadataUniversalIdentifier, - position: field.position ?? index, - isVisible: field.isVisible ?? true, - size: field.size ?? 200, - }; - }); + const hasDefaultFieldEntry = fields.some( + (field) => 'defaultFieldName' in field, + ); const defaultFields = ` // fields: [ // { @@ -45,19 +75,30 @@ export const getViewBaseFile = ({ const fieldsBlock = fields.length > 0 ? ` fields: [ - ${formattedFields - .map((field) => JSON.stringify(field, null, 2)) - .join(',\n')},\n +${fields + .map((field, index) => + renderFieldEntry({ field, index, objectUniversalIdentifier }), + ) + .join(',\n')}, ],` : defaultFields; - return `import { defineView } from 'twenty-sdk/define'; + const typeBlock = type !== undefined ? ` type: '${type}',\n` : ''; + + const imports = hasDefaultFieldEntry + ? `import { + defineView, + generateDefaultFieldUniversalIdentifier, +} from 'twenty-sdk/define';` + : `import { defineView } from 'twenty-sdk/define';`; + + return `${imports} export default defineView({ universalIdentifier: '${universalIdentifier}', name: '${kebabCaseName}', objectUniversalIdentifier: '${objectUniversalIdentifier}', - icon: 'IconList', +${typeBlock} icon: 'IconList', position: 0, ${fieldsBlock} // filters: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts index bfc1a939c29..7ef6ff8e55e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts @@ -295,7 +295,7 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({ return { ...rest, newFieldDefaultVisibility, - viewId: viewUniversalIdentifier, + viewUniversalIdentifier, }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/validators/utils/validate-fields-flat-page-layout-widget-for-creation.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/validators/utils/validate-fields-flat-page-layout-widget-for-creation.util.ts index 390e53ed04e..bc712b8d6d6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/validators/utils/validate-fields-flat-page-layout-widget-for-creation.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/validators/utils/validate-fields-flat-page-layout-widget-for-creation.util.ts @@ -40,17 +40,23 @@ export const validateFieldsFlatPageLayoutWidgetForCreation = ( return errors; } - const viewId = ( - universalConfiguration as { configurationType: string; viewId?: unknown } - ).viewId; + const viewUniversalIdentifier = ( + universalConfiguration as { + configurationType: string; + viewUniversalIdentifier?: unknown; + } + ).viewUniversalIdentifier; - if (isDefined(viewId) && viewId !== null) { - if (typeof viewId !== 'string' || !uuidValidate(viewId)) { + if (isDefined(viewUniversalIdentifier) && viewUniversalIdentifier !== null) { + if ( + typeof viewUniversalIdentifier !== 'string' || + !uuidValidate(viewUniversalIdentifier) + ) { errors.push({ code: PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA, - message: t`Invalid viewId for fields widget "${widgetTitle}". Expected a valid UUID`, - userFriendlyMessage: msg`Invalid viewId for fields widget`, - value: viewId, + message: t`Invalid viewUniversalIdentifier for fields widget "${widgetTitle}". Expected a valid UUID`, + userFriendlyMessage: msg`Invalid viewUniversalIdentifier for fields widget`, + value: viewUniversalIdentifier, }); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util.ts index 6d140d5570c..b3d23854f84 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util.ts @@ -89,7 +89,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({ const universalConfiguration = isFieldsWidget ? { configurationType: WidgetConfigurationType.FIELDS, - viewId: recordPageFieldsView.universalIdentifier, + viewUniversalIdentifier: recordPageFieldsView.universalIdentifier, } : { configurationType: diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/fields-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/fields-configuration.dto.ts index 16179c5537d..1adab25f2f7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/fields-configuration.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/fields-configuration.dto.ts @@ -7,7 +7,10 @@ import { IsOptional, IsUUID, } from 'class-validator'; -import { type FieldsConfiguration } from 'twenty-shared/types'; +import { + type FieldsConfiguration, + type SerializedRelation, +} from 'twenty-shared/types'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; @@ -21,7 +24,7 @@ export class FieldsConfigurationDTO implements FieldsConfiguration { @Field(() => String, { nullable: true }) @IsOptional() @IsUUID() - viewId: string | null; + viewId: SerializedRelation | null; @Field(() => Boolean, { nullable: true }) @IsOptional() diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util.ts index 5ce09c53821..26f6bac32a4 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util.ts @@ -160,7 +160,7 @@ const buildFieldsWidgetConfiguration = ({ }, universalConfiguration: { configurationType: WidgetConfigurationType.FIELDS, - viewId: null, + viewUniversalIdentifier: null, newFieldDefaultVisibility: true, }, }; @@ -197,7 +197,7 @@ const buildFieldsWidgetConfiguration = ({ }, universalConfiguration: { configurationType: WidgetConfigurationType.FIELDS, - viewId: viewUniversalIdentifier, + viewUniversalIdentifier, newFieldDefaultVisibility: true, }, }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts index 873c623afab..222cc0f60f6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts @@ -255,11 +255,8 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({ } case WidgetConfigurationType.FIELDS: { - const { - viewId: viewUniversalIdentifier, - newFieldDefaultVisibility, - ...rest - } = universalConfiguration; + const { viewUniversalIdentifier, newFieldDefaultVisibility, ...rest } = + universalConfiguration; let viewId: string | null = null; diff --git a/packages/twenty-shared/src/types/page-layout/page-layout-widget-configuration.type.ts b/packages/twenty-shared/src/types/page-layout/page-layout-widget-configuration.type.ts index 52f331a0e06..0b8f728f07c 100644 --- a/packages/twenty-shared/src/types/page-layout/page-layout-widget-configuration.type.ts +++ b/packages/twenty-shared/src/types/page-layout/page-layout-widget-configuration.type.ts @@ -104,7 +104,7 @@ export type FieldConfiguration = { export type FieldsConfiguration = { configurationType: 'FIELDS'; - viewId?: string | null; + viewId?: SerializedRelation | null; newFieldDefaultVisibility?: boolean | null; shouldAllowUserToSeeHiddenFields?: boolean; };