Compare commits

...
Author SHA1 Message Date
Thomas Trompette 246697fe9e WIP 2026-02-19 18:08:27 +01:00
7 changed files with 198 additions and 39 deletions
@@ -135,7 +135,7 @@ export class CreateAppCommand {
message: 'Select which example files to include:',
choices: [
{
name: 'Example object (custom object definition)',
name: 'Example object (with view and navigation menu item)',
value: 'object',
checked: true,
},
@@ -154,29 +154,18 @@ export class CreateAppCommand {
value: 'frontComponent',
checked: true,
},
{
name: 'Example view (saved view for the example object)',
value: 'view',
checked: true,
},
{
name: 'Example navigation menu item (sidebar link)',
value: 'navigationMenuItem',
checked: true,
},
],
},
]);
const includeField = selectedExamples.includes('field');
const includeView = selectedExamples.includes('view');
const includeObject =
selectedExamples.includes('object') || includeField || includeView;
selectedExamples.includes('object') || includeField;
if ((includeField || includeView) && !selectedExamples.includes('object')) {
if (includeField && !selectedExamples.includes('object')) {
console.log(
chalk.yellow(
'Note: Example object auto-included because example field/view depends on it.',
'Note: Example object auto-included because example field depends on it.',
),
);
}
@@ -186,9 +175,8 @@ export class CreateAppCommand {
includeExampleField: includeField,
includeExampleLogicFunction: selectedExamples.includes('logicFunction'),
includeExampleFrontComponent: selectedExamples.includes('frontComponent'),
includeExampleView: includeView,
includeExampleNavigationMenuItem:
selectedExamples.includes('navigationMenuItem'),
includeExampleView: includeObject,
includeExampleNavigationMenuItem: includeObject,
};
}
@@ -380,9 +380,12 @@ const createExampleView = async ({
const content = `import { defineView } from 'twenty-sdk';
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object';
export const EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export default defineView({
universalIdentifier: '${universalIdentifier}',
name: 'example-view',
universalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All example items',
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
icon: 'IconList',
position: 0,
@@ -405,18 +408,14 @@ const createExampleNavigationMenuItem = async ({
const universalIdentifier = v4();
const content = `import { defineNavigationMenuItem } from 'twenty-sdk';
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from 'src/views/example-view';
export default defineNavigationMenuItem({
universalIdentifier: '${universalIdentifier}',
name: 'example-navigation-menu-item',
icon: 'IconList',
position: 0,
// Link to a view:
// viewUniversalIdentifier: '...',
// Or link to an object:
// targetObjectUniversalIdentifier: '...',
// Or link to an external URL:
// link: 'https://example.com',
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
});
`;
@@ -1,12 +1,18 @@
import { CURRENT_EXECUTION_DIRECTORY } from '@/cli/utilities/config/current-execution-directory';
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 {
getNavigationMenuItemBaseFile,
getNavigationMenuItemForViewBaseFile,
} from '@/cli/utilities/entity/entity-navigation-menu-item-template';
import { convertToLabel } from '@/cli/utilities/entity/entity-label';
import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template';
import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template';
import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template';
import { getViewBaseFile } from '@/cli/utilities/entity/entity-view-template';
import {
getViewBaseFile,
getViewForObjectBaseFile,
} from '@/cli/utilities/entity/entity-view-template';
import chalk from 'chalk';
import * as fs from 'fs-extra';
import inquirer from 'inquirer';
@@ -19,6 +25,13 @@ import { getFieldBaseFile } from '@/cli/utilities/entity/entity-field-template';
const APP_FOLDER = 'src';
type ObjectMetadata = {
constantName: string;
labelPlural: string;
namePlural: string;
nameSingular: string;
};
export class EntityAddCommand {
async execute(entityType?: SyncableEntity, path?: string): Promise<void> {
try {
@@ -32,7 +45,8 @@ export class EntityAddCommand {
await fs.ensureDir(appPath);
const { name, file } = await this.getEntityData(entity);
const { name, file, objectMetadata } =
await this.getEntityData(entity);
const filePath = join(appPath, this.getFileName(name, entity));
@@ -49,6 +63,13 @@ export class EntityAddCommand {
chalk.green(`✓ Created ${entityName}:`),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, filePath)),
);
if (entity === SyncableEntity.Object && objectMetadata) {
await this.promptAndCreateViewAndNavItem({
objectMetadata,
objectFileName: kebabcase(objectMetadata.nameSingular),
});
}
} catch (error) {
console.error(
chalk.red(`Add new entity failed:`),
@@ -58,19 +79,111 @@ export class EntityAddCommand {
}
}
private async getEntityData(entity: SyncableEntity) {
private async promptAndCreateViewAndNavItem({
objectMetadata,
objectFileName,
}: {
objectMetadata: ObjectMetadata;
objectFileName: string;
}): Promise<void> {
const { createViewAndNav } = await inquirer.prompt<{
createViewAndNav: boolean;
}>([
{
type: 'confirm',
name: 'createViewAndNav',
message:
'Create a view and navigation menu item for this object? (recommended)',
default: true,
},
]);
if (!createViewAndNav) {
return;
}
const objectImportPath = `src/objects/${objectFileName}`;
const viewName = `all-${kebabcase(objectMetadata.namePlural)}`;
const { file: viewFile, constantName: viewConstantName } =
getViewForObjectBaseFile({
objectConstantName: objectMetadata.constantName,
objectImportPath,
labelPlural: objectMetadata.labelPlural,
});
const viewsFolderPath = join(
CURRENT_EXECUTION_DIRECTORY,
APP_FOLDER,
'views',
);
await fs.ensureDir(viewsFolderPath);
const viewFilePath = join(viewsFolderPath, `${viewName}.ts`);
await fs.writeFile(viewFilePath, viewFile);
console.log(
chalk.green('✓ Created views:'),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, viewFilePath)),
);
const viewImportPath = `src/views/${viewName}`;
const navItemName = kebabcase(objectMetadata.namePlural);
const navItemFile = getNavigationMenuItemForViewBaseFile({
viewConstantName,
viewImportPath,
});
const navItemsFolderPath = join(
CURRENT_EXECUTION_DIRECTORY,
APP_FOLDER,
'navigation-menu-items',
);
await fs.ensureDir(navItemsFolderPath);
const navItemFilePath = join(
navItemsFolderPath,
`${navItemName}.ts`,
);
await fs.writeFile(navItemFilePath, navItemFile);
console.log(
chalk.green('✓ Created navigation-menu-items:'),
chalk.cyan(relative(CURRENT_EXECUTION_DIRECTORY, navItemFilePath)),
);
}
private async getEntityData(entity: SyncableEntity): Promise<{
name: string;
file: string;
objectMetadata?: ObjectMetadata;
}> {
switch (entity) {
case SyncableEntity.Object: {
const entityData = await this.getObjectData();
const name = entityData.nameSingular;
const file = getObjectBaseFile({
const { file, constantName } = getObjectBaseFile({
data: entityData,
name,
});
return { name, file };
return {
name,
file,
objectMetadata: {
constantName,
labelPlural: entityData.labelPlural,
namePlural: entityData.namePlural,
nameSingular: entityData.nameSingular,
},
};
}
case SyncableEntity.Field: {
@@ -26,3 +26,23 @@ export default defineNavigationMenuItem({
});
`;
};
export const getNavigationMenuItemForViewBaseFile = ({
viewConstantName,
viewImportPath,
}: {
viewConstantName: string;
viewImportPath: string;
}) => {
const universalIdentifier = v4();
return `import { defineNavigationMenuItem } from 'twenty-sdk';
import { ${viewConstantName} } from '${viewImportPath}';
export default defineNavigationMenuItem({
universalIdentifier: '${universalIdentifier}',
position: 0,
viewUniversalIdentifier: ${viewConstantName},
});
`;
};
@@ -1,5 +1,9 @@
import kebabCase from 'lodash.kebabcase';
import { v4 } from 'uuid';
const toScreamingSnakeCase = (value: string) =>
kebabCase(value).replace(/-/g, '_').toUpperCase();
export const getObjectBaseFile = ({
data,
}: {
@@ -12,11 +16,15 @@ export const getObjectBaseFile = ({
name: string;
}) => {
const universalIdentifier = v4();
const constantName = `${toScreamingSnakeCase(data.nameSingular)}_UNIVERSAL_IDENTIFIER`;
return `import { defineObject } from 'twenty-sdk';
const file = `import { defineObject } from 'twenty-sdk';
export const ${constantName} =
'${universalIdentifier}';
export default defineObject({
universalIdentifier: '${universalIdentifier}',
universalIdentifier: ${constantName},
nameSingular: '${data.nameSingular}',
namePlural: '${data.namePlural}',
labelSingular: '${data.labelSingular}',
@@ -34,4 +42,6 @@ export default defineObject({
],
});
`;
return { file, universalIdentifier, constantName };
};
@@ -1,6 +1,9 @@
import kebabCase from 'lodash.kebabcase';
import { v4 } from 'uuid';
const toScreamingSnakeCase = (value: string) =>
kebabCase(value).replace(/-/g, '_').toUpperCase();
export const getViewBaseFile = ({
name,
universalIdentifier = v4(),
@@ -37,3 +40,33 @@ export default defineView({
});
`;
};
export const getViewForObjectBaseFile = ({
objectConstantName,
objectImportPath,
labelPlural,
}: {
objectConstantName: string;
objectImportPath: string;
labelPlural: string;
}) => {
const universalIdentifier = v4();
const constantName = `ALL_${toScreamingSnakeCase(labelPlural)}_VIEW_UNIVERSAL_IDENTIFIER`;
const file = `import { defineView } from 'twenty-sdk';
import { ${objectConstantName} } from '${objectImportPath}';
export const ${constantName} =
'${universalIdentifier}';
export default defineView({
universalIdentifier: ${constantName},
name: 'All ${labelPlural}',
objectUniversalIdentifier: ${objectConstantName},
icon: 'IconList',
position: 0,
});
`;
return { file, universalIdentifier, constantName };
};
@@ -25,11 +25,7 @@ export type {
RelationFieldManifest,
FieldManifest,
} from './fieldManifestType';
export type {
CommandMenuItemManifest,
FrontComponentCommandManifest,
FrontComponentManifest,
} from './frontComponentManifestType';
export type { FrontComponentManifest } from './frontComponentManifestType';
export type {
LogicFunctionManifest,
CronTriggerSettings,