Files
twenty/packages/twenty-sdk/src/cli/commands/add.ts
T
Weiko 876214bc1d 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"
/>
2026-04-22 16:30:45 +00:00

625 lines
17 KiB
TypeScript

import chalk from 'chalk';
import inquirer from 'inquirer';
import { writeFile } from 'node:fs/promises';
import { join, relative } from 'path';
import { SyncableEntity } from 'twenty-shared/application';
import { FieldMetadataType, ViewType } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
import { convertToLabel } from '@/cli/utilities/entity/entity-label';
import { getFieldBaseFile } from '@/cli/utilities/entity/entity-field-template';
import { getFrontComponentBaseFile } from '@/cli/utilities/entity/entity-front-component-template';
import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template';
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';
import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template';
import { ensureDir, pathExists } from '@/cli/utilities/file/fs-utils';
import { kebabCase } from '@/cli/utilities/string/kebab-case';
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 {
const entity = entityType ?? (await this.getEntity());
const entityName = this.getFolderName(entity);
const appPath = path
? join(CURRENT_EXECUTION_DIRECTORY, path)
: join(CURRENT_EXECUTION_DIRECTORY, APP_FOLDER, entityName);
await ensureDir(appPath);
const { name, file } = await this.getEntityData(entity);
const filePath = join(appPath, this.getFileName(name, entity));
if (await pathExists(filePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(filePath, file);
console.log(
chalk.green(`✓ Created ${entityName}:`),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, filePath)),
);
if (entity === SyncableEntity.Object) {
await this.promptAndCreateObjectCompanions(name, path);
}
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
error instanceof Error ? error.message : error,
);
process.exit(1);
}
}
private async getEntityData(entity: SyncableEntity) {
switch (entity) {
case SyncableEntity.Object: {
const entityData = await this.getObjectData();
const name = entityData.nameSingular;
const objectUniversalIdentifier = v4();
const nameFieldUniversalIdentifier = v4();
this.lastObjectUniversalIdentifier = objectUniversalIdentifier;
this.lastNameFieldUniversalIdentifier = nameFieldUniversalIdentifier;
this.lastObjectLabelSingular = entityData.labelSingular;
const file = getObjectBaseFile({
data: entityData,
name,
universalIdentifier: objectUniversalIdentifier,
nameFieldUniversalIdentifier,
});
return { name, file };
}
case SyncableEntity.Field: {
const entityData = await this.getFieldData();
const name = entityData.name;
const file = getFieldBaseFile({
data: entityData,
name,
});
return { name, file };
}
case SyncableEntity.LogicFunction: {
const name = await this.getEntityName(entity);
const file = getLogicFunctionBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.FrontComponent: {
const name = await this.getEntityName(entity);
const file = getFrontComponentBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.Role: {
const name = await this.getEntityName(entity);
const file = getRoleBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.Skill: {
const name = await this.getEntityName(entity);
const file = getSkillBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.Agent: {
const name = await this.getEntityName(entity);
const file = getAgentBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.View: {
const entityData = await this.getViewData();
const name = entityData.name;
const file = getViewBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.NavigationMenuItem: {
const name = await this.getEntityName(entity);
const file = getNavigationMenuItemBaseFile({
name,
});
return { name, file };
}
case SyncableEntity.PageLayout: {
const name = await this.getEntityName(entity);
const file = getPageLayoutBaseFile({
name,
});
return { name, file };
}
default:
assertUnreachable(entity);
}
}
private async promptAndCreateObjectCompanions(
objectName: string,
customPath?: string,
): Promise<void> {
const { createCompanions } = await inquirer.prompt<{
createCompanions: boolean;
}>([
{
type: 'confirm',
name: 'createCompanions',
message:
'Also create a view, navigation menu item, and record page layout for this object? (recommended)',
default: true,
},
]);
if (!createCompanions || !this.lastObjectUniversalIdentifier) {
return;
}
const viewUniversalIdentifier = v4();
const fieldsWidgetViewUniversalIdentifier = v4();
const viewFile = getViewBaseFile({
name: `all-${kebabCase(objectName)}`,
universalIdentifier: viewUniversalIdentifier,
objectUniversalIdentifier: this.lastObjectUniversalIdentifier,
fields: this.lastNameFieldUniversalIdentifier
? [
{
fieldMetadataUniversalIdentifier:
this.lastNameFieldUniversalIdentifier,
position: 0,
isVisible: true,
size: 200,
},
]
: [],
});
const viewFolderPath = customPath
? join(CURRENT_EXECUTION_DIRECTORY, customPath)
: join(
CURRENT_EXECUTION_DIRECTORY,
APP_FOLDER,
this.getFolderName(SyncableEntity.View),
);
await ensureDir(viewFolderPath);
const viewFileName = `all-${kebabCase(objectName)}.ts`;
const viewFilePath = join(viewFolderPath, viewFileName);
if (await pathExists(viewFilePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(viewFilePath, viewFile);
console.log(
chalk.green(`✓ Created view:`),
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',
targetObjectUniversalIdentifier: this.lastObjectUniversalIdentifier,
});
const navFolderPath = customPath
? join(CURRENT_EXECUTION_DIRECTORY, customPath)
: join(
CURRENT_EXECUTION_DIRECTORY,
APP_FOLDER,
this.getFolderName(SyncableEntity.NavigationMenuItem),
);
await ensureDir(navFolderPath);
const navFileName = `${kebabCase(objectName)}.ts`;
const navFilePath = join(navFolderPath, navFileName);
if (await pathExists(navFilePath)) {
const { overwrite } = await this.handleFileExist();
if (!overwrite) {
return;
}
}
await writeFile(navFilePath, navFile);
console.log(
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() {
const { entity } = await inquirer.prompt<{ entity: SyncableEntity }>([
{
type: 'select',
name: 'entity',
message: `What entity do you want to create?`,
default: '',
choices: Object.values(SyncableEntity),
},
]);
return entity;
}
private async handleFileExist() {
return await inquirer.prompt<{ overwrite: boolean }>([
{
type: 'confirm',
name: 'overwrite',
message: `File already exists. Do you want to overwrite it?`,
default: false,
},
]);
}
private async getEntityName(entity: SyncableEntity) {
const { name } = await inquirer.prompt<{ name: string }>([
{
type: 'input',
name: 'name',
message: `Enter a name for your new ${entity}:`,
default: '',
validate: (input) => {
if (input.length === 0) {
return `${entity} name is required`;
}
return true;
},
},
]);
return name;
}
private async getFieldData() {
return inquirer.prompt<{
name: string;
label: string;
type: FieldMetadataType;
objectUniversalIdentifier: string;
description: string;
}>([
{
type: 'input',
name: 'name',
message: 'Enter a name for your field:',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'label',
message: 'Enter a label for your field:',
default: (answers: any) => {
return convertToLabel(answers.name);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'select',
name: 'type',
message: 'Select the field type:',
choices: Object.values(FieldMetadataType),
default: FieldMetadataType.TEXT,
},
{
type: 'input',
name: 'objectUniversalIdentifier',
message:
'Enter the universalIdentifier of the object this field belongs to:',
default: 'fill-later',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'description',
message: 'Enter a description for your field (optional):',
default: '',
},
]);
}
private async getObjectData() {
return inquirer.prompt<{
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
}>([
{
type: 'input',
name: 'nameSingular',
message: 'Enter a name singular for your object (eg: company):',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'namePlural',
message: 'Enter a name plural for your object (eg: companies):',
default: '',
validate: (input: string, answers?: any) => {
if (input.trim() === answers?.nameSingular.trim()) {
return 'Name plural must be different from name singular';
}
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelSingular',
message: 'Enter a label singular for your object:',
default: (answers: any) => {
return convertToLabel(answers.nameSingular);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'labelPlural',
message: 'Enter a label plural for your object:',
default: (answers: any) => {
return convertToLabel(answers.namePlural);
},
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
]);
}
private async getViewData() {
return inquirer.prompt<{
name: string;
objectUniversalIdentifier: string;
}>([
{
type: 'input',
name: 'name',
message: 'Enter a name for your view:',
default: '',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
{
type: 'input',
name: 'objectUniversalIdentifier',
message:
'Enter the universalIdentifier of the object this view belongs to:',
default: 'fill-later',
validate: (input: string) => {
if (!input || input.trim().length === 0) {
return 'Please enter a non empty string';
}
return true;
},
},
]);
}
getFolderName(entity: SyncableEntity) {
return `${kebabCase(entity)}s`;
}
getFileName(name: string, entity: SyncableEntity) {
switch (entity) {
case SyncableEntity.FrontComponent: {
return `${kebabCase(name)}.tsx`;
}
default: {
return `${kebabCase(name)}.ts`;
}
}
}
}