scaffold record page layout + fields view when adding an object (#19977)

## Summary

Extends `yarn twenty add` → **Object** so it scaffolds a complete record
page out
of the box:

- A **record-page-fields view** (`<name>-record-page-fields.ts`,
FIELDS_WIDGET)
pre-populated with the `name` field plus the auto-generated default
fields (`createdAt`,
`updatedAt`, `createdBy`, `updatedBy`) — the default-field entries are
emitted as
`generateDefaultFieldUniversalIdentifier({ objectUniversalIdentifier,
fieldName: '...' })`
calls rather than pre-computed UUIDs, so the generated file
double-serves as
documentation for the public util.
- A **record page layout** (`<name>-record-page-layout.ts`) with a Home
tab whose Fields
widget points at the new view (via `viewUniversalIdentifier`), plus a
Timeline tab.
- The companion prompt now covers all three artefacts (was view + nav
menu item).

Fix: Server-side, renames `viewId` → `viewUniversalIdentifier` on the
universal-flat FIELDS
widget configuration so it is consistent with other universal-flat
references. The DB-side
DTO keeps `viewId` (now typed as `SerializedRelation`), and the
conversion utils map
between the two.

<img width="337" height="349" alt="Screenshot 2026-04-22 at 15 40 22"
src="https://github.com/user-attachments/assets/59e36540-1761-46b0-808d-648c68604268"
/>
This commit is contained in:
Weiko
2026-04-22 16:30:45 +00:00
committed by GitHub
parent 0d996a5629
commit 876214bc1d
11 changed files with 297 additions and 47 deletions
+116 -8
View File
@@ -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<void> {
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<void> {
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() {
@@ -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';");
});
});
@@ -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',
},
},
],
},
],
});
`;
};
@@ -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: [
@@ -295,7 +295,7 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
return {
...rest,
newFieldDefaultVisibility,
viewId: viewUniversalIdentifier,
viewUniversalIdentifier,
};
}
@@ -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,
});
}
}
@@ -89,7 +89,7 @@ export const computeFlatDefaultRecordPageLayoutToCreate = ({
const universalConfiguration = isFieldsWidget
? {
configurationType: WidgetConfigurationType.FIELDS,
viewId: recordPageFieldsView.universalIdentifier,
viewUniversalIdentifier: recordPageFieldsView.universalIdentifier,
}
: {
configurationType:
@@ -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()
@@ -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,
},
};
@@ -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;
@@ -104,7 +104,7 @@ export type FieldConfiguration = {
export type FieldsConfiguration = {
configurationType: 'FIELDS';
viewId?: string | null;
viewId?: SerializedRelation | null;
newFieldDefaultVisibility?: boolean | null;
shouldAllowUserToSeeHiddenFields?: boolean;
};