diff --git a/packages/create-twenty-app/README.md b/packages/create-twenty-app/README.md index b02f851816..e733e83da1 100644 --- a/packages/create-twenty-app/README.md +++ b/packages/create-twenty-app/README.md @@ -61,20 +61,56 @@ yarn twenty function:execute --postInstall yarn twenty app:uninstall ``` +## Scaffolding modes + +Control which example files are included when creating a new app: + +| Flag | Behavior | +|------|----------| +| `-e, --exhaustive` | **(default)** Creates all example files without prompting | +| `-m, --minimal` | Creates only core files (`application-config.ts` and `default-role.ts`) | +| `-i, --interactive` | Prompts you to select which examples to include | + +```bash +# Default: all examples included +npx create-twenty-app@latest my-app + +# Minimal: only core files +npx create-twenty-app@latest my-app -m + +# Interactive: choose which examples to include +npx create-twenty-app@latest my-app -i +``` + +In interactive mode, you can pick from: +- **Example object** — a custom CRM object definition (`objects/example-object.ts`) +- **Example field** — a custom field on the example object (`fields/example-field.ts`) +- **Example logic function** — a server-side handler with HTTP trigger (`logic-functions/hello-world.ts`) +- **Example front component** — a React UI component (`front-components/hello-world.tsx`) +- **Example view** — a saved view for the example object (`views/example-view.ts`) +- **Example navigation menu item** — a sidebar link (`navigation-menu-items/example-navigation-menu-item.ts`) + ## What gets scaffolded -- A minimal app structure ready for Twenty with example files: - - `application-config.ts` - Application metadata configuration - - `roles/default-role.ts` - Default role for logic functions - - `logic-functions/hello-world.ts` - Example logic function with HTTP trigger - - `logic-functions/post-install.ts` - Post-install logic function (runs after app installation) - - `front-components/hello-world.tsx` - Example front component -- TypeScript configuration + +**Core files (always created):** +- `application-config.ts` — Application metadata configuration +- `roles/default-role.ts` — Default role for logic functions +- `logic-functions/post-install.ts` — Post-install logic function (runs after app installation) +- TypeScript configuration, ESLint, package.json, .gitignore - A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk +**Example files (controlled by scaffolding mode):** +- `objects/example-object.ts` — Example custom object with a text field +- `fields/example-field.ts` — Example standalone field extending the example object +- `logic-functions/hello-world.ts` — Example logic function with HTTP trigger +- `front-components/hello-world.tsx` — Example front component +- `views/example-view.ts` — Example saved view for the example object +- `navigation-menu-items/example-navigation-menu-item.ts` — Example sidebar navigation link + ## Next steps - Run `yarn twenty help` to see all available commands. - Use `yarn twenty auth:login` to authenticate with your Twenty workspace. -- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles). +- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items). - Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time. - Types are auto‑generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`. diff --git a/packages/create-twenty-app/src/cli.ts b/packages/create-twenty-app/src/cli.ts index 9294a94ade..1527265d4d 100644 --- a/packages/create-twenty-app/src/cli.ts +++ b/packages/create-twenty-app/src/cli.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import { Command, CommanderError } from 'commander'; import { CreateAppCommand } from '@/create-app.command'; +import { type ScaffoldingMode } from '@/types/scaffolding-options'; import packageJson from '../package.json'; const program = new Command(packageJson.name) @@ -12,18 +13,58 @@ const program = new Command(packageJson.name) 'Output the current version of create-twenty-app.', ) .argument('[directory]') + .option('-e, --exhaustive', 'Create all example entities (default)') + .option( + '-m, --minimal', + 'Create only core entities (application-config and default-role)', + ) + .option( + '-i, --interactive', + 'Interactively choose which entity examples to include', + ) .helpOption('-h, --help', 'Display this help message.') - .action(async (directory?: string) => { - if (directory && !/^[a-z0-9-]+$/.test(directory)) { - console.error( - chalk.red( - `Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`, - ), - ); - process.exit(1); - } - await new CreateAppCommand().execute(directory); - }); + .action( + async ( + directory?: string, + options?: { + exhaustive?: boolean; + minimal?: boolean; + interactive?: boolean; + }, + ) => { + const modeFlags = [ + options?.exhaustive, + options?.minimal, + options?.interactive, + ].filter(Boolean); + + if (modeFlags.length > 1) { + console.error( + chalk.red( + 'Error: --exhaustive, --minimal, and --interactive are mutually exclusive.', + ), + ); + process.exit(1); + } + + if (directory && !/^[a-z0-9-]+$/.test(directory)) { + console.error( + chalk.red( + `Invalid directory "${directory}". Must contain only lowercase letters, numbers, and hyphens`, + ), + ); + process.exit(1); + } + + const mode: ScaffoldingMode = options?.minimal + ? 'minimal' + : options?.interactive + ? 'interactive' + : 'exhaustive'; + + await new CreateAppCommand().execute(directory, mode); + }, + ); program.exitOverride(); diff --git a/packages/create-twenty-app/src/constants/base-application/README.md b/packages/create-twenty-app/src/constants/base-application/README.md index 26c135bbec..35ea775887 100644 --- a/packages/create-twenty-app/src/constants/base-application/README.md +++ b/packages/create-twenty-app/src/constants/base-application/README.md @@ -30,7 +30,7 @@ yarn twenty auth:list # List all configured workspaces # Application yarn twenty app:dev # Start dev mode (watch, build, sync, and auto-generate typed client) -yarn twenty entity:add # Add a new entity (function, front-component, object, role) +yarn twenty entity:add # Add a new entity (object, field, function, front-component, role, view, navigation-menu-item) yarn twenty function:logs # Stream function logs yarn twenty function:execute # Execute a function with JSON payload yarn twenty app:uninstall # Uninstall app from workspace diff --git a/packages/create-twenty-app/src/create-app.command.ts b/packages/create-twenty-app/src/create-app.command.ts index 6b91820897..9f2d77e463 100644 --- a/packages/create-twenty-app/src/create-app.command.ts +++ b/packages/create-twenty-app/src/create-app.command.ts @@ -8,14 +8,24 @@ import inquirer from 'inquirer'; import kebabCase from 'lodash.kebabcase'; import * as path from 'path'; +import { + type ExampleOptions, + type ScaffoldingMode, +} from '@/types/scaffolding-options'; + const CURRENT_EXECUTION_DIRECTORY = process.env.INIT_CWD || process.cwd(); export class CreateAppCommand { - async execute(directory?: string): Promise { + async execute( + directory?: string, + mode: ScaffoldingMode = 'exhaustive', + ): Promise { try { const { appName, appDisplayName, appDirectory, appDescription } = await this.getAppInfos(directory); + const exampleOptions = await this.resolveExampleOptions(mode); + await this.validateDirectory(appDirectory); this.logCreationInfo({ appDirectory, appName }); @@ -27,6 +37,7 @@ export class CreateAppCommand { appDisplayName, appDescription, appDirectory, + exampleOptions, }); await install(appDirectory); @@ -92,6 +103,95 @@ export class CreateAppCommand { return { appName, appDisplayName, appDirectory, appDescription }; } + private async resolveExampleOptions( + mode: ScaffoldingMode, + ): Promise { + if (mode === 'minimal') { + return { + includeExampleObject: false, + includeExampleField: false, + includeExampleLogicFunction: false, + includeExampleFrontComponent: false, + includeExampleView: false, + includeExampleNavigationMenuItem: false, + }; + } + + if (mode === 'exhaustive') { + return { + includeExampleObject: true, + includeExampleField: true, + includeExampleLogicFunction: true, + includeExampleFrontComponent: true, + includeExampleView: true, + includeExampleNavigationMenuItem: true, + }; + } + + const { selectedExamples } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedExamples', + message: 'Select which example files to include:', + choices: [ + { + name: 'Example object (custom object definition)', + value: 'object', + checked: true, + }, + { + name: 'Example field (custom field on the example object)', + value: 'field', + checked: true, + }, + { + name: 'Example logic function (server-side handler)', + value: 'logicFunction', + checked: true, + }, + { + name: 'Example front component (React UI component)', + 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; + + if ((includeField || includeView) && !selectedExamples.includes('object')) { + console.log( + chalk.yellow( + 'Note: Example object auto-included because example field/view depends on it.', + ), + ); + } + + return { + includeExampleObject: includeObject, + includeExampleField: includeField, + includeExampleLogicFunction: selectedExamples.includes('logicFunction'), + includeExampleFrontComponent: selectedExamples.includes('frontComponent'), + includeExampleView: includeView, + includeExampleNavigationMenuItem: + selectedExamples.includes('navigationMenuItem'), + }; + } + private async validateDirectory(appDirectory: string): Promise { if (!(await fs.pathExists(appDirectory))) { return; diff --git a/packages/create-twenty-app/src/types/scaffolding-options.ts b/packages/create-twenty-app/src/types/scaffolding-options.ts new file mode 100644 index 0000000000..c4e2d2baac --- /dev/null +++ b/packages/create-twenty-app/src/types/scaffolding-options.ts @@ -0,0 +1,10 @@ +export type ScaffoldingMode = 'exhaustive' | 'minimal' | 'interactive'; + +export type ExampleOptions = { + includeExampleObject: boolean; + includeExampleField: boolean; + includeExampleLogicFunction: boolean; + includeExampleFrontComponent: boolean; + includeExampleView: boolean; + includeExampleNavigationMenuItem: boolean; +}; diff --git a/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts b/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts index 0f017a1ff8..377586d07e 100644 --- a/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts +++ b/packages/create-twenty-app/src/utils/__tests__/app-template.spec.ts @@ -2,6 +2,7 @@ import * as fs from 'fs-extra'; import { join } from 'path'; import { tmpdir } from 'os'; import { copyBaseApplicationProject } from '@/utils/app-template'; +import { type ExampleOptions } from '@/types/scaffolding-options'; // Mock fs-extra's copy function to skip copying base template (not available during tests) jest.mock('fs-extra', () => { @@ -15,6 +16,24 @@ jest.mock('fs-extra', () => { const APPLICATION_FILE_NAME = 'application-config.ts'; const DEFAULT_ROLE_FILE_NAME = 'default-role.ts'; +const ALL_EXAMPLES: ExampleOptions = { + includeExampleObject: true, + includeExampleField: true, + includeExampleLogicFunction: true, + includeExampleFrontComponent: true, + includeExampleView: true, + includeExampleNavigationMenuItem: true, +}; + +const NO_EXAMPLES: ExampleOptions = { + includeExampleObject: false, + includeExampleField: false, + includeExampleLogicFunction: false, + includeExampleFrontComponent: false, + includeExampleView: false, + includeExampleNavigationMenuItem: false, +}; + describe('copyBaseApplicationProject', () => { let testAppDirectory: string; @@ -41,6 +60,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'My Test App', appDescription: 'A test application', appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, }); // Verify src/ folder exists @@ -62,6 +82,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'My Test App', appDescription: 'A test application', appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, }); const packageJsonPath = join(testAppDirectory, 'package.json'); @@ -80,6 +101,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'My Test App', appDescription: 'A test application', appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, }); const gitignorePath = join(testAppDirectory, '.gitignore'); @@ -96,6 +118,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'My Test App', appDescription: 'A test application', appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, }); const yarnLockPath = join(testAppDirectory, 'yarn.lock'); @@ -111,6 +134,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'My Test App', appDescription: 'A test application', appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, }); const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME); @@ -148,6 +172,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'My Test App', appDescription: 'A test application', appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, }); const roleConfigPath = join( @@ -192,6 +217,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'My Test App', appDescription: 'A test application', appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, }); // Verify fs.copy was called with correct destination @@ -208,6 +234,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'My Test App', appDescription: '', appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, }); const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME); @@ -225,6 +252,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'App One', appDescription: 'First app', appDirectory: firstAppDir, + exampleOptions: ALL_EXAMPLES, }); // Create second app @@ -235,6 +263,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'App Two', appDescription: 'Second app', appDirectory: secondAppDir, + exampleOptions: ALL_EXAMPLES, }); // Read both app configs @@ -267,6 +296,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'App One', appDescription: 'First app', appDirectory: firstAppDir, + exampleOptions: ALL_EXAMPLES, }); // Create second app @@ -277,6 +307,7 @@ describe('copyBaseApplicationProject', () => { appDisplayName: 'App Two', appDescription: 'Second app', appDirectory: secondAppDir, + exampleOptions: ALL_EXAMPLES, }); const firstRoleConfig = await fs.readFile( @@ -299,4 +330,345 @@ describe('copyBaseApplicationProject', () => { expect(secondUuid).toBeDefined(); expect(firstUuid).not.toBe(secondUuid); }); + + describe('scaffolding modes', () => { + describe('exhaustive mode (all examples)', () => { + it('should create all example files when all options are enabled', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, + }); + + const srcPath = join(testAppDirectory, 'src'); + + expect( + await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')), + ).toBe(true); + expect( + await fs.pathExists(join(srcPath, 'fields', 'example-field.ts')), + ).toBe(true); + expect( + await fs.pathExists( + join(srcPath, 'logic-functions', 'hello-world.ts'), + ), + ).toBe(true); + expect( + await fs.pathExists( + join(srcPath, 'front-components', 'hello-world.tsx'), + ), + ).toBe(true); + expect( + await fs.pathExists(join(srcPath, 'views', 'example-view.ts')), + ).toBe(true); + expect( + await fs.pathExists( + join( + srcPath, + 'navigation-menu-items', + 'example-navigation-menu-item.ts', + ), + ), + ).toBe(true); + }); + }); + + describe('minimal mode (no examples)', () => { + it('should create only core files when no examples are enabled', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + exampleOptions: NO_EXAMPLES, + }); + + const srcPath = join(testAppDirectory, 'src'); + + // Core files should exist + expect(await fs.pathExists(join(srcPath, APPLICATION_FILE_NAME))).toBe( + true, + ); + expect( + await fs.pathExists(join(srcPath, 'roles', DEFAULT_ROLE_FILE_NAME)), + ).toBe(true); + + // Example files should not exist + expect( + await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')), + ).toBe(false); + expect( + await fs.pathExists(join(srcPath, 'fields', 'example-field.ts')), + ).toBe(false); + expect( + await fs.pathExists( + join(srcPath, 'logic-functions', 'hello-world.ts'), + ), + ).toBe(false); + expect( + await fs.pathExists( + join(srcPath, 'front-components', 'hello-world.tsx'), + ), + ).toBe(false); + expect( + await fs.pathExists(join(srcPath, 'views', 'example-view.ts')), + ).toBe(false); + expect( + await fs.pathExists( + join( + srcPath, + 'navigation-menu-items', + 'example-navigation-menu-item.ts', + ), + ), + ).toBe(false); + }); + }); + + describe('selective examples', () => { + it('should create only front component when only that option is enabled', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + exampleOptions: { + includeExampleObject: false, + includeExampleField: false, + includeExampleLogicFunction: false, + includeExampleFrontComponent: true, + includeExampleView: false, + includeExampleNavigationMenuItem: false, + }, + }); + + const srcPath = join(testAppDirectory, 'src'); + + expect( + await fs.pathExists( + join(srcPath, 'front-components', 'hello-world.tsx'), + ), + ).toBe(true); + expect( + await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')), + ).toBe(false); + expect( + await fs.pathExists(join(srcPath, 'fields', 'example-field.ts')), + ).toBe(false); + expect( + await fs.pathExists( + join(srcPath, 'logic-functions', 'hello-world.ts'), + ), + ).toBe(false); + }); + + it('should create only logic function when only that option is enabled', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + exampleOptions: { + includeExampleObject: false, + includeExampleField: false, + includeExampleLogicFunction: true, + includeExampleFrontComponent: false, + includeExampleView: false, + includeExampleNavigationMenuItem: false, + }, + }); + + const srcPath = join(testAppDirectory, 'src'); + + expect( + await fs.pathExists( + join(srcPath, 'logic-functions', 'hello-world.ts'), + ), + ).toBe(true); + expect( + await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')), + ).toBe(false); + }); + }); + }); + + describe('example object', () => { + it('should create example-object.ts with defineObject and correct structure', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, + }); + + const objectPath = join( + testAppDirectory, + 'src', + 'objects', + 'example-object.ts', + ); + + expect(await fs.pathExists(objectPath)).toBe(true); + + const content = await fs.readFile(objectPath, 'utf8'); + + expect(content).toContain( + "import { defineObject, FieldType } from 'twenty-sdk'", + ); + expect(content).toContain('export default defineObject({'); + expect(content).toContain( + 'export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER', + ); + expect(content).toContain('export const NAME_FIELD_UNIVERSAL_IDENTIFIER'); + expect(content).toContain("nameSingular: 'exampleItem'"); + expect(content).toContain("namePlural: 'exampleItems'"); + expect(content).toContain('FieldType.TEXT'); + expect(content).toContain( + 'labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER', + ); + }); + + it('should generate unique UUIDs for example objects across apps', async () => { + const firstAppDir = join(testAppDirectory, 'app1'); + await fs.ensureDir(firstAppDir); + await copyBaseApplicationProject({ + appName: 'app-one', + appDisplayName: 'App One', + appDescription: 'First app', + appDirectory: firstAppDir, + exampleOptions: ALL_EXAMPLES, + }); + + const secondAppDir = join(testAppDirectory, 'app2'); + await fs.ensureDir(secondAppDir); + await copyBaseApplicationProject({ + appName: 'app-two', + appDisplayName: 'App Two', + appDescription: 'Second app', + appDirectory: secondAppDir, + exampleOptions: ALL_EXAMPLES, + }); + + const firstContent = await fs.readFile( + join(firstAppDir, 'src', 'objects', 'example-object.ts'), + 'utf8', + ); + const secondContent = await fs.readFile( + join(secondAppDir, 'src', 'objects', 'example-object.ts'), + 'utf8', + ); + + const uuidRegex = + /EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/; + const firstUuid = firstContent.match(uuidRegex)?.[1]; + const secondUuid = secondContent.match(uuidRegex)?.[1]; + + expect(firstUuid).toBeDefined(); + expect(secondUuid).toBeDefined(); + expect(firstUuid).not.toBe(secondUuid); + }); + }); + + describe('example field', () => { + it('should create example-field.ts with defineField referencing the object', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, + }); + + const fieldPath = join( + testAppDirectory, + 'src', + 'fields', + 'example-field.ts', + ); + + expect(await fs.pathExists(fieldPath)).toBe(true); + + const content = await fs.readFile(fieldPath, 'utf8'); + + expect(content).toContain( + "import { defineField, FieldType } from 'twenty-sdk'", + ); + expect(content).toContain( + "import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'", + ); + expect(content).toContain('export default defineField({'); + expect(content).toContain( + 'objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER', + ); + expect(content).toContain('FieldType.NUMBER'); + expect(content).toContain("name: 'priority'"); + }); + }); + + describe('example view', () => { + it('should create example-view.ts with defineView referencing the object', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, + }); + + const viewPath = join( + testAppDirectory, + 'src', + 'views', + 'example-view.ts', + ); + + expect(await fs.pathExists(viewPath)).toBe(true); + + const content = await fs.readFile(viewPath, 'utf8'); + + expect(content).toContain("import { defineView } from 'twenty-sdk'"); + expect(content).toContain( + "import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'", + ); + expect(content).toContain('export default defineView({'); + expect(content).toContain( + 'objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER', + ); + expect(content).toContain("name: 'example-view'"); + }); + }); + + describe('example navigation menu item', () => { + it('should create example-navigation-menu-item.ts with defineNavigationMenuItem', async () => { + await copyBaseApplicationProject({ + appName: 'my-test-app', + appDisplayName: 'My Test App', + appDescription: 'A test application', + appDirectory: testAppDirectory, + exampleOptions: ALL_EXAMPLES, + }); + + const navPath = join( + testAppDirectory, + 'src', + 'navigation-menu-items', + 'example-navigation-menu-item.ts', + ); + + expect(await fs.pathExists(navPath)).toBe(true); + + const content = await fs.readFile(navPath, 'utf8'); + + expect(content).toContain( + "import { defineNavigationMenuItem } from 'twenty-sdk'", + ); + expect(content).toContain('export default defineNavigationMenuItem({'); + expect(content).toContain("name: 'example-navigation-menu-item'"); + expect(content).toContain("icon: 'IconList'"); + expect(content).toContain('position: 0'); + }); + }); }); diff --git a/packages/create-twenty-app/src/utils/app-template.ts b/packages/create-twenty-app/src/utils/app-template.ts index 1c2d28e62c..3cab26f1aa 100644 --- a/packages/create-twenty-app/src/utils/app-template.ts +++ b/packages/create-twenty-app/src/utils/app-template.ts @@ -3,6 +3,8 @@ import { join } from 'path'; import { v4 } from 'uuid'; import { ASSETS_DIR } from 'twenty-shared/application'; +import { type ExampleOptions } from '@/types/scaffolding-options'; + const SRC_FOLDER = 'src'; export const copyBaseApplicationProject = async ({ @@ -10,11 +12,13 @@ export const copyBaseApplicationProject = async ({ appDisplayName, appDescription, appDirectory, + exampleOptions, }: { appName: string; appDisplayName: string; appDescription: string; appDirectory: string; + exampleOptions: ExampleOptions; }) => { await fs.copy(join(__dirname, './constants/base-application'), appDirectory); @@ -37,17 +41,53 @@ export const copyBaseApplicationProject = async ({ fileName: 'default-role.ts', }); - await createDefaultFrontComponent({ - appDirectory: sourceFolderPath, - fileFolder: 'front-components', - fileName: 'hello-world.tsx', - }); + if (exampleOptions.includeExampleObject) { + await createExampleObject({ + appDirectory: sourceFolderPath, + fileFolder: 'objects', + fileName: 'example-object.ts', + }); + } - await createDefaultFunction({ - appDirectory: sourceFolderPath, - fileFolder: 'logic-functions', - fileName: 'hello-world.ts', - }); + if (exampleOptions.includeExampleField) { + await createExampleField({ + appDirectory: sourceFolderPath, + fileFolder: 'fields', + fileName: 'example-field.ts', + }); + } + + if (exampleOptions.includeExampleLogicFunction) { + await createDefaultFunction({ + appDirectory: sourceFolderPath, + fileFolder: 'logic-functions', + fileName: 'hello-world.ts', + }); + } + + if (exampleOptions.includeExampleFrontComponent) { + await createDefaultFrontComponent({ + appDirectory: sourceFolderPath, + fileFolder: 'front-components', + fileName: 'hello-world.tsx', + }); + } + + if (exampleOptions.includeExampleView) { + await createExampleView({ + appDirectory: sourceFolderPath, + fileFolder: 'views', + fileName: 'example-view.ts', + }); + } + + if (exampleOptions.includeExampleNavigationMenuItem) { + await createExampleNavigationMenuItem({ + appDirectory: sourceFolderPath, + fileFolder: 'navigation-menu-items', + fileName: 'example-navigation-menu-item.ts', + }); + } await createDefaultPostInstallFunction({ appDirectory: sourceFolderPath, @@ -252,6 +292,138 @@ export default defineLogicFunction({ await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); }; +const createExampleObject = async ({ + appDirectory, + fileFolder, + fileName, +}: { + appDirectory: string; + fileFolder?: string; + fileName: string; +}) => { + const objectUniversalIdentifier = v4(); + const nameFieldUniversalIdentifier = v4(); + + const content = `import { defineObject, FieldType } from 'twenty-sdk'; + +export const EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER = + '${objectUniversalIdentifier}'; + +export const NAME_FIELD_UNIVERSAL_IDENTIFIER = + '${nameFieldUniversalIdentifier}'; + +export default defineObject({ + universalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, + nameSingular: 'exampleItem', + namePlural: 'exampleItems', + labelSingular: 'Example item', + labelPlural: 'Example items', + description: 'A sample custom object', + icon: 'IconBox', + labelIdentifierFieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'name', + label: 'Name', + description: 'Name of the example item', + icon: 'IconAbc', + }, + ], +}); +`; + + await fs.ensureDir(join(appDirectory, fileFolder ?? '')); + await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); +}; + +const createExampleField = async ({ + appDirectory, + fileFolder, + fileName, +}: { + appDirectory: string; + fileFolder?: string; + fileName: string; +}) => { + const universalIdentifier = v4(); + + const content = `import { defineField, FieldType } from 'twenty-sdk'; +import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'; + +export default defineField({ + objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, + universalIdentifier: '${universalIdentifier}', + type: FieldType.NUMBER, + name: 'priority', + label: 'Priority', + description: 'Priority level for the example item (1-10)', +}); +`; + + await fs.ensureDir(join(appDirectory, fileFolder ?? '')); + await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); +}; + +const createExampleView = async ({ + appDirectory, + fileFolder, + fileName, +}: { + appDirectory: string; + fileFolder?: string; + fileName: string; +}) => { + const universalIdentifier = v4(); + + const content = `import { defineView } from 'twenty-sdk'; +import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/objects/example-object'; + +export default defineView({ + universalIdentifier: '${universalIdentifier}', + name: 'example-view', + objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER, + icon: 'IconList', + position: 0, +}); +`; + + await fs.ensureDir(join(appDirectory, fileFolder ?? '')); + await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); +}; + +const createExampleNavigationMenuItem = async ({ + appDirectory, + fileFolder, + fileName, +}: { + appDirectory: string; + fileFolder?: string; + fileName: string; +}) => { + const universalIdentifier = v4(); + + const content = `import { defineNavigationMenuItem } from 'twenty-sdk'; + +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', +}); +`; + + await fs.ensureDir(join(appDirectory, fileFolder ?? '')); + await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content); +}; + const createApplicationConfig = async ({ displayName, description, diff --git a/packages/twenty-docs/developers/extend/capabilities/apps.mdx b/packages/twenty-docs/developers/extend/capabilities/apps.mdx index 33575baeea..b6a9e883d8 100644 --- a/packages/twenty-docs/developers/extend/capabilities/apps.mdx +++ b/packages/twenty-docs/developers/extend/capabilities/apps.mdx @@ -26,7 +26,7 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf Create a new app using the official scaffolder, then authenticate and start developing: ```bash filename="Terminal" -# Scaffold a new app +# Scaffold a new app (includes all examples by default) npx create-twenty-app@latest my-twenty-app cd my-twenty-app @@ -41,6 +41,19 @@ yarn twenty auth:login yarn twenty app:dev ``` +The scaffolder supports three modes for controlling which example files are included: + +```bash filename="Terminal" +# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item) +npx create-twenty-app@latest my-app + +# Minimal: only core files (application-config.ts and default-role.ts) +npx create-twenty-app@latest my-app --minimal + +# Interactive: select which examples to include +npx create-twenty-app@latest my-app --interactive +``` + From here you can: ```bash filename="Terminal" @@ -72,9 +85,9 @@ When you run `npx create-twenty-app@latest my-twenty-app`, the scaffolder: - Copies a minimal base application into `my-twenty-app/` - Adds a local `twenty-sdk` dependency and Yarn 4 configuration - Creates config files and scripts wired to the `twenty` CLI -- Generates a default application config, a default function role, and a post-install function +- Generates core files (application config, default function role, post-install function) plus example files based on the scaffolding mode -A freshly scaffolded app looks like this: +A freshly scaffolded app with the default `--exhaustive` mode looks like this: ```text filename="my-twenty-app/" my-twenty-app/ @@ -93,13 +106,23 @@ my-twenty-app/ ├── application-config.ts # Required - main application configuration ├── roles/ │ └── default-role.ts # Default role for logic functions + ├── objects/ + │ └── example-object.ts # Example custom object definition + ├── fields/ + │ └── example-field.ts # Example standalone field definition ├── logic-functions/ │ ├── hello-world.ts # Example logic function │ └── post-install.ts # Post-install logic function - └── front-components/ - └── hello-world.tsx # Example front component + ├── front-components/ + │ └── hello-world.tsx # Example front component + ├── views/ + │ └── example-view.ts # Example saved view definition + └── navigation-menu-items/ + └── example-navigation-menu-item.ts # Example sidebar navigation link ``` +With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, and `logic-functions/post-install.ts`). With `--interactive`, you choose which example files to include. + At a high level: - **package.json**: Declares the app name, version, engines (Node 24+, Yarn 4), and adds `twenty-sdk` plus a `twenty` script that delegates to the local `twenty` CLI. Run `yarn twenty help` to list all available commands. @@ -122,6 +145,8 @@ The SDK detects entities by parsing your TypeScript files for **`export default | `defineFrontComponent()` | Front component definitions | | `defineRole()` | Role definitions | | `defineField()` | Field extensions for existing objects | +| `defineView()` | Saved view definitions | +| `defineNavigationMenuItem()` | Navigation menu item definitions | **File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define({...})` pattern. You can organize your files and folders however you like. Grouping by entity type (e.g., `logic-functions/`, `roles/`) is just a convention for code organization, not a requirement. @@ -193,6 +218,8 @@ The SDK provides helper functions for defining your app entities. As described i | `defineFrontComponent()` | Define front components for custom UI | | `defineRole()` | Configure role permissions and object access | | `defineField()` | Extend existing objects with additional fields | +| `defineView()` | Define saved views for objects | +| `defineNavigationMenuItem()` | Define sidebar navigation links | These functions validate your configuration at build time and provide IDE autocompletion and type safety. diff --git a/packages/twenty-sdk/README.md b/packages/twenty-sdk/README.md index a319d2c3f1..9068a1e6d9 100644 --- a/packages/twenty-sdk/README.md +++ b/packages/twenty-sdk/README.md @@ -130,14 +130,17 @@ Application development commands. - `twenty entity:add [entityType]` — Add a new entity to your application. - Arguments: - - `entityType`: one of `function`, `front-component`, `object`, or `role`. If omitted, an interactive prompt is shown. + - `entityType`: one of `object`, `field`, `function`, `front-component`, `role`, `view`, or `navigation-menu-item`. If omitted, an interactive prompt is shown. - Options: - `--path `: The path where the entity file should be created (relative to the current directory). - Behavior: - `object`: prompts for singular/plural names and labels, then creates a `*.object.ts` definition file. + - `field`: prompts for name, label, type, and target object, then creates a `*.field.ts` definition file. - `function`: prompts for a name and scaffolds a `*.function.ts` logic function file. - `front-component`: prompts for a name and scaffolds a `*.front-component.tsx` file. - `role`: prompts for a name and scaffolds a `*.role.ts` role definition file. + - `view`: prompts for a name and target object, then creates a `*.view.ts` definition file. + - `navigation-menu-item`: prompts for a name and scaffolds a `*.navigation-menu-item.ts` file. ### Function @@ -171,6 +174,12 @@ twenty entity:add function # Add a new front component twenty entity:add front-component +# Add a new view +twenty entity:add view + +# Add a new navigation menu item +twenty entity:add navigation-menu-item + # Uninstall the app from the workspace twenty app:uninstall diff --git a/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts b/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts index 8b89fa68d0..86d59380d6 100644 --- a/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts +++ b/packages/twenty-sdk/src/cli/commands/entity/entity-add.ts @@ -1,10 +1,12 @@ 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 { 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 chalk from 'chalk'; import * as fs from 'fs-extra'; import inquirer from 'inquirer'; @@ -114,13 +116,34 @@ export class EntityAddCommand { 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 }; } @@ -302,6 +325,39 @@ export class EntityAddCommand { ]); } + 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`; } diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts index 01dfc36962..99b56acb51 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts @@ -69,6 +69,8 @@ const ENTITY_TYPE_TO_SYNCABLE: Record = { logicFunctions: SyncableEntity.LogicFunction, frontComponents: SyncableEntity.FrontComponent, roles: SyncableEntity.Role, + views: SyncableEntity.View, + navigationMenuItems: SyncableEntity.NavigationMenuItem, pageLayouts: SyncableEntity.PageLayout, }; diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts index 29308bc275..57aa58c963 100644 --- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts +++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts @@ -97,6 +97,8 @@ export const ENTITY_LABELS: Record = { [SyncableEntity.LogicFunction]: 'Logic functions', [SyncableEntity.FrontComponent]: 'Front components', [SyncableEntity.Role]: 'Roles', + [SyncableEntity.View]: 'Views', + [SyncableEntity.NavigationMenuItem]: 'Navigation menu items', [SyncableEntity.PageLayout]: 'Page layouts', }; diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-navigation-menu-item-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-navigation-menu-item-template.ts new file mode 100644 index 0000000000..1ab1c9653b --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-navigation-menu-item-template.ts @@ -0,0 +1,28 @@ +import kebabCase from 'lodash.kebabcase'; +import { v4 } from 'uuid'; + +export const getNavigationMenuItemBaseFile = ({ + name, + universalIdentifier = v4(), +}: { + name: string; + universalIdentifier?: string; +}) => { + const kebabCaseName = kebabCase(name); + + return `import { defineNavigationMenuItem } from 'twenty-sdk'; + +export default defineNavigationMenuItem({ + universalIdentifier: '${universalIdentifier}', + name: '${kebabCaseName}', + 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', +}); +`; +}; 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 new file mode 100644 index 0000000000..7667d74ef3 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-view-template.ts @@ -0,0 +1,39 @@ +import kebabCase from 'lodash.kebabcase'; +import { v4 } from 'uuid'; + +export const getViewBaseFile = ({ + name, + universalIdentifier = v4(), +}: { + name: string; + universalIdentifier?: string; +}) => { + const kebabCaseName = kebabCase(name); + + return `import { defineView } from 'twenty-sdk'; + +export default defineView({ + universalIdentifier: '${universalIdentifier}', + name: '${kebabCaseName}', + objectUniversalIdentifier: 'fill-later', + icon: 'IconList', + position: 0, + // fields: [ + // { + // universalIdentifier: '...', + // fieldMetadataUniversalIdentifier: '...', + // position: 0, + // isVisible: true, + // }, + // ], + // filters: [ + // { + // universalIdentifier: '...', + // fieldMetadataUniversalIdentifier: '...', + // operand: 'Contains', + // value: '', + // }, + // ], +}); +`; +}; diff --git a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts index 9477b0898f..6af01e49e5 100644 --- a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts +++ b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts @@ -4,5 +4,7 @@ export enum SyncableEntity { LogicFunction = 'logicFunction', FrontComponent = 'frontComponent', Role = 'role', + View = 'view', + NavigationMenuItem = 'navigationMenuItem', PageLayout = 'pageLayout', }