Add example in create-twenty-app (#18043)
- add interactive mode to create-twenty-app - by default create an example for each entities <img width="1181" height="168" alt="image" src="https://github.com/user-attachments/assets/a2490d8f-66a1-4cd5-bf41-57166cc20a1e" />
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
async execute(
|
||||
directory?: string,
|
||||
mode: ScaffoldingMode = 'exhaustive',
|
||||
): Promise<void> {
|
||||
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<ExampleOptions> {
|
||||
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<void> {
|
||||
if (!(await fs.pathExists(appDirectory))) {
|
||||
return;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
<Note>
|
||||
**File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define<Entity>({...})` 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.
|
||||
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,8 @@ const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
|
||||
logicFunctions: SyncableEntity.LogicFunction,
|
||||
frontComponents: SyncableEntity.FrontComponent,
|
||||
roles: SyncableEntity.Role,
|
||||
views: SyncableEntity.View,
|
||||
navigationMenuItems: SyncableEntity.NavigationMenuItem,
|
||||
pageLayouts: SyncableEntity.PageLayout,
|
||||
};
|
||||
|
||||
|
||||
@@ -97,6 +97,8 @@ export const ENTITY_LABELS: Record<SyncableEntity, string> = {
|
||||
[SyncableEntity.LogicFunction]: 'Logic functions',
|
||||
[SyncableEntity.FrontComponent]: 'Front components',
|
||||
[SyncableEntity.Role]: 'Roles',
|
||||
[SyncableEntity.View]: 'Views',
|
||||
[SyncableEntity.NavigationMenuItem]: 'Navigation menu items',
|
||||
[SyncableEntity.PageLayout]: 'Page layouts',
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
`;
|
||||
};
|
||||
@@ -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: '',
|
||||
// },
|
||||
// ],
|
||||
});
|
||||
`;
|
||||
};
|
||||
@@ -4,5 +4,7 @@ export enum SyncableEntity {
|
||||
LogicFunction = 'logicFunction',
|
||||
FrontComponent = 'frontComponent',
|
||||
Role = 'role',
|
||||
View = 'view',
|
||||
NavigationMenuItem = 'navigationMenuItem',
|
||||
PageLayout = 'pageLayout',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user