Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9746b66c48 | ||
|
|
7ec4508d5f | ||
|
|
a05a9c8f79 | ||
|
|
ce1ffa8550 | ||
|
|
2cc3c75c7e | ||
|
|
9733ff1b8e | ||
|
|
e4075caa65 | ||
|
|
c3781e87cc | ||
|
|
e9b5cb830c | ||
|
|
e40c758aa6 | ||
|
|
53c314d0fa | ||
|
|
618df704e6 | ||
|
|
058489b5cc | ||
|
|
3bd431e95d | ||
|
|
f4a61f26c0 | ||
|
|
8e6b267ff3 | ||
|
|
88146c2170 | ||
|
|
3706da9bcb | ||
|
|
9bac8f15d4 | ||
|
|
7332379d26 | ||
|
|
2455c859b4 | ||
|
|
e3753bf822 |
@@ -54,23 +54,63 @@ yarn twenty function:logs
|
||||
# Execute a function with a JSON payload
|
||||
yarn twenty function:execute -n my-function -p '{"key": "value"}'
|
||||
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
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
|
||||
- `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`.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.6.0-alpha",
|
||||
"version": "0.6.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -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,16 +41,58 @@ 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({
|
||||
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,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'hello-world.ts',
|
||||
fileName: 'post-install.ts',
|
||||
});
|
||||
|
||||
await createApplicationConfig({
|
||||
@@ -196,7 +242,6 @@ const handler = async (): Promise<{ message: string }> => {
|
||||
return { message: 'Hello, World!' };
|
||||
};
|
||||
|
||||
// Logic function handler - rename and implement your logic
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'hello-world-logic-function',
|
||||
@@ -215,6 +260,170 @@ export default defineLogicFunction({
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createDefaultPostInstallFunction = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
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,
|
||||
@@ -230,12 +439,14 @@ const createApplicationConfig = async ({
|
||||
}) => {
|
||||
const content = `import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '${v4()}',
|
||||
displayName: '${displayName}',
|
||||
description: '${description ?? ''}',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
`;
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -53,6 +66,9 @@ yarn twenty function:logs
|
||||
# Execute a function by name
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
|
||||
@@ -69,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 and a default function role
|
||||
- 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/
|
||||
@@ -90,12 +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
|
||||
└── front-components/
|
||||
└── hello-world.tsx # Example front component
|
||||
│ ├── hello-world.ts # Example logic function
|
||||
│ └── post-install.ts # Post-install logic function
|
||||
├── 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.
|
||||
@@ -118,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.
|
||||
@@ -189,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.
|
||||
|
||||
@@ -289,6 +320,7 @@ Every app has a single `application-config.ts` file that describes:
|
||||
- **Who the app is**: identifiers, display name, and description.
|
||||
- **How its functions run**: which role they use for permissions.
|
||||
- **(Optional) variables**: key–value pairs exposed to your functions as environment variables.
|
||||
- **(Optional) post-install function**: a logic function that runs after the app is installed.
|
||||
|
||||
Use `defineApplication()` to define your application configuration:
|
||||
|
||||
@@ -296,6 +328,7 @@ Use `defineApplication()` to define your application configuration:
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -311,6 +344,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -318,6 +352,7 @@ Notes:
|
||||
- `universalIdentifier` fields are deterministic IDs you own; generate them once and keep them stable across syncs.
|
||||
- `applicationVariables` become environment variables for your functions (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
- `defaultRoleUniversalIdentifier` must match the role file (see below).
|
||||
- `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
|
||||
#### Roles and permissions
|
||||
|
||||
@@ -450,6 +485,54 @@ Notes:
|
||||
- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions.
|
||||
- You can mix multiple trigger types in a single function.
|
||||
|
||||
### Post-install functions
|
||||
|
||||
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
- The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### Route trigger payload
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -45,19 +45,22 @@ yarn twenty app:dev
|
||||
من هنا يمكنك:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# أضف كيانًا جديدًا إلى تطبيقك (موجّه)
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty entity:add
|
||||
|
||||
# راقب سجلات وظائف تطبيقك
|
||||
# Watch your application's function logs
|
||||
yarn twenty function:logs
|
||||
|
||||
# نفّذ وظيفة بالاسم
|
||||
# Execute a function by name
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# أزل تثبيت التطبيق من مساحة العمل الحالية
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
|
||||
# اعرض مساعدة الأوامر
|
||||
# Display commands' help
|
||||
yarn twenty help
|
||||
```
|
||||
|
||||
@@ -70,7 +73,7 @@ yarn twenty help
|
||||
* ينسخ تطبيقًا أساسيًا مصغّرًا إلى `my-twenty-app/`
|
||||
* يضيف اعتمادًا محليًا `twenty-sdk` وتهيئة Yarn 4
|
||||
* ينشئ ملفات ضبط ونصوصًا مرتبطة بـ `twenty` CLI
|
||||
* يُولّد ضبطًا افتراضيًا للتطبيق ودورًا افتراضيًا للوظيفة
|
||||
* Generates a default application config, a default function role, and a post-install function
|
||||
|
||||
يبدو التطبيق المُنشأ حديثًا بالقالب كما يلي:
|
||||
|
||||
@@ -86,15 +89,16 @@ my-twenty-app/
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
README.md
|
||||
public/ # مجلد الأصول العامة (صور، خطوط، إلخ)
|
||||
public/ # Public assets folder (images, fonts, etc.)
|
||||
src/
|
||||
├── application-config.ts # مطلوب - التكوين الرئيسي للتطبيق
|
||||
├── application-config.ts # Required - main application configuration
|
||||
├── roles/
|
||||
│ └── default-role.ts # الدور الافتراضي لوظائف المنطق
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # مثال لوظيفة منطقية
|
||||
│ ├── hello-world.ts # Example logic function
|
||||
│ └── post-install.ts # Post-install logic function
|
||||
└── front-components/
|
||||
└── hello-world.tsx # مثال لمكوّن الواجهة الأمامية
|
||||
└── hello-world.tsx # Example front component
|
||||
```
|
||||
|
||||
بشكل عام:
|
||||
@@ -290,6 +294,7 @@ export default defineObject({
|
||||
* **هوية التطبيق**: المعرفات، اسم العرض، والوصف.
|
||||
* **كيفية تشغيل وظائفه**: الدور الذي تستخدمه للأذونات.
|
||||
* **متغيرات (اختياري)**: أزواج مفتاح-قيمة تُعرض لوظائفك كمتغيرات بيئة.
|
||||
* **(Optional) post-install function**: a logic function that runs after the app is installed.
|
||||
|
||||
Use `defineApplication()` to define your application configuration:
|
||||
|
||||
@@ -297,6 +302,7 @@ Use `defineApplication()` to define your application configuration:
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -312,6 +318,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -320,6 +327,7 @@ export default defineApplication({
|
||||
* حقول `universalIdentifier` هي معرّفات حتمية تخصك؛ أنشئها مرة واحدة واحتفظ بها ثابتة عبر عمليات المزامنة.
|
||||
* `applicationVariables` تصبح متغيرات بيئة لوظائفك (على سبيل المثال، `DEFAULT_RECIPIENT_NAME` متاح كـ `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
* `defaultRoleUniversalIdentifier` يجب أن يطابق ملف الدور (انظر أدناه).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
|
||||
#### الأدوار والصلاحيات
|
||||
|
||||
@@ -458,6 +466,55 @@ export default defineLogicFunction({
|
||||
* المصفوفة `triggers` اختيارية. يمكن استخدام الوظائف بدون مشغلات كوظائف مساعدة تُستدعى بواسطة وظائف أخرى.
|
||||
* يمكنك مزج أنواع متعددة من المشغلات في وظيفة واحدة.
|
||||
|
||||
### Post-install functions
|
||||
|
||||
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
النقاط الرئيسية:
|
||||
|
||||
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### حمولة مشغل المسار
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -54,6 +54,9 @@ yarn twenty function:logs
|
||||
# Spusťte funkci podle názvu
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Spusťte postinstalační funkci
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Odinstalujte aplikaci z aktuálního pracovního prostoru
|
||||
yarn twenty app:uninstall
|
||||
|
||||
@@ -70,7 +73,7 @@ Když spustíte `npx create-twenty-app@latest my-twenty-app`, scaffolder:
|
||||
* Zkopíruje minimální základní aplikaci do `my-twenty-app/`
|
||||
* Přidá lokální závislost `twenty-sdk` a konfiguraci pro Yarn 4
|
||||
* Vytvoří konfigurační soubory a skripty napojené na `twenty` CLI
|
||||
* Vygeneruje výchozí konfiguraci aplikace a výchozí roli funkcí
|
||||
* Vygeneruje výchozí konfiguraci aplikace, výchozí roli funkcí a postinstalační funkci.
|
||||
|
||||
Čerstvě vytvořená aplikace vypadá takto:
|
||||
|
||||
@@ -92,7 +95,8 @@ my-twenty-app/
|
||||
├── roles/
|
||||
│ └── default-role.ts # Výchozí role pro logické funkce
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # Ukázková logická funkce
|
||||
│ ├── hello-world.ts # Ukázková logická funkce
|
||||
│ └── post-install.ts # Postinstalační logická funkce
|
||||
└── front-components/
|
||||
└── hello-world.tsx # Ukázková front-endová komponenta
|
||||
```
|
||||
@@ -290,6 +294,7 @@ Každá aplikace má jeden soubor `application-config.ts`, který popisuje:
|
||||
* **Identitu aplikace**: identifikátory, zobrazovaný název a popis.
|
||||
* **Jak běží její funkce**: kterou roli používají pro oprávnění.
|
||||
* **(Volitelné) proměnné**: dvojice klíč–hodnota zpřístupněné vašim funkcím jako proměnné prostředí.
|
||||
* **(Volitelná) postinstalační funkce**: logická funkce, která se spouští po instalaci aplikace.
|
||||
|
||||
Use `defineApplication()` to define your application configuration:
|
||||
|
||||
@@ -297,6 +302,7 @@ Use `defineApplication()` to define your application configuration:
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -312,6 +318,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -320,6 +327,7 @@ Poznámky:
|
||||
* Pole `universalIdentifier` jsou deterministická ID, která vlastníte; vygenerujte je jednou a udržujte je stabilní napříč synchronizacemi.
|
||||
* `applicationVariables` se stanou proměnnými prostředí pro vaše funkce (například `DEFAULT_RECIPIENT_NAME` je dostupné jako `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
* `defaultRoleUniversalIdentifier` se musí shodovat se souborem role (viz níže).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (volitelné) odkazuje na logickou funkci, která se automaticky spustí po instalaci aplikace. Viz [Postinstalační funkce](#post-install-functions).
|
||||
|
||||
#### Role a oprávnění
|
||||
|
||||
@@ -458,6 +466,55 @@ Poznámky:
|
||||
* Pole `triggers` je volitelné. Funkce bez spouštěčů lze použít jako pomocné funkce volané jinými funkcemi.
|
||||
* V jedné funkci můžete kombinovat více typů spouštěčů.
|
||||
|
||||
### Postinstalační funkce
|
||||
|
||||
Postinstalační funkce je logická funkce, která se automaticky spouští po instalaci vaší aplikace do pracovního prostoru. To je užitečné pro jednorázové úlohy nastavení, jako je naplnění výchozími daty, vytvoření počátečních záznamů nebo konfigurace nastavení pracovního prostoru.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Hlavní body:
|
||||
|
||||
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### Payload spouštěče trasy
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -54,6 +54,9 @@ yarn twenty function:logs
|
||||
# Eine Funktion anhand ihres Namens ausführen
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Die Post-Installationsfunktion ausführen
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Die Anwendung aus dem aktuellen Arbeitsbereich deinstallieren
|
||||
yarn twenty app:uninstall
|
||||
|
||||
@@ -70,7 +73,7 @@ Wenn Sie `npx create-twenty-app@latest my-twenty-app` ausführen, erledigt der S
|
||||
* Kopiert eine minimale Basisanwendung nach `my-twenty-app/`
|
||||
* Fügt eine lokale `twenty-sdk`-Abhängigkeit und die Yarn-4-Konfiguration hinzu
|
||||
* Erstellt Konfigurationsdateien und Skripte, die an die `twenty`-CLI angebunden sind
|
||||
* Generiert eine Standard-Anwendungskonfiguration und eine Standard-Funktionsrolle
|
||||
* Generiert eine Standard-Anwendungskonfiguration, eine Standard-Funktionsrolle und eine Post-Installationsfunktion
|
||||
|
||||
Eine frisch erzeugte App sieht so aus:
|
||||
|
||||
@@ -86,15 +89,16 @@ my-twenty-app/
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
README.md
|
||||
public/ # Public assets folder (images, fonts, etc.)
|
||||
public/ # Ordner für öffentliche Assets (Bilder, Schriftarten usw.)
|
||||
src/
|
||||
├── application-config.ts # Required - main application configuration
|
||||
├── application-config.ts # Erforderlich – Hauptkonfiguration der Anwendung
|
||||
├── roles/
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
│ └── default-role.ts # Standardrolle für Logikfunktionen
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # Example logic function
|
||||
│ ├── hello-world.ts # Beispiel für eine Logikfunktion
|
||||
│ └── post-install.ts # Post-Installations-Logikfunktion
|
||||
└── front-components/
|
||||
└── hello-world.tsx # Example front component
|
||||
└── hello-world.tsx # Beispiel für eine Frontend-Komponente
|
||||
```
|
||||
|
||||
Auf hoher Ebene:
|
||||
@@ -290,6 +294,7 @@ Jede App hat eine einzelne Datei `application-config.ts`, die Folgendes beschrei
|
||||
* **Was die App ist**: Bezeichner, Anzeigename und Beschreibung.
|
||||
* **Wie ihre Funktionen ausgeführt werden**: welche Rolle sie für Berechtigungen verwenden.
|
||||
* **(Optional) Variablen**: Schlüssel–Wert-Paare, die Ihren Funktionen als Umgebungsvariablen zur Verfügung gestellt werden.
|
||||
* **(Optional) Post-Installationsfunktion**: eine Logikfunktion, die nach der Installation der App ausgeführt wird.
|
||||
|
||||
Verwenden Sie `defineApplication()`, um Ihre Anwendungskonfiguration zu definieren:
|
||||
|
||||
@@ -297,6 +302,7 @@ Verwenden Sie `defineApplication()`, um Ihre Anwendungskonfiguration zu definier
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -312,6 +318,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -320,6 +327,7 @@ Notizen:
|
||||
* `universalIdentifier`-Felder sind deterministische IDs, die Sie besitzen; generieren Sie sie einmal und halten Sie sie über Synchronisierungen hinweg stabil.
|
||||
* `applicationVariables` werden zu Umgebungsvariablen für Ihre Funktionen (zum Beispiel ist `DEFAULT_RECIPIENT_NAME` als `process.env.DEFAULT_RECIPIENT_NAME` verfügbar).
|
||||
* `defaultRoleUniversalIdentifier` muss mit der Rollendatei übereinstimmen (siehe unten).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (optional) verweist auf eine Logikfunktion, die nach der Installation der App automatisch ausgeführt wird. Siehe [Post-Installationsfunktionen](#post-install-functions).
|
||||
|
||||
#### Rollen und Berechtigungen
|
||||
|
||||
@@ -458,6 +466,55 @@ Notizen:
|
||||
* Das Array `triggers` ist optional. Funktionen ohne Trigger können als von anderen Funktionen aufgerufene Utility-Funktionen verwendet werden.
|
||||
* Sie können mehrere Trigger-Typen in einer Funktion kombinieren.
|
||||
|
||||
### Post-Installationsfunktionen
|
||||
|
||||
Eine Post-Installationsfunktion ist eine Logikfunktion, die automatisch ausgeführt wird, nachdem Ihre App in einem Arbeitsbereich installiert wurde. Dies ist nützlich für einmalige Einrichtungsvorgänge wie das Befüllen mit Standarddaten, das Erstellen erster Datensätze oder das Konfigurieren von Arbeitsbereichseinstellungen.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Hauptpunkte:
|
||||
|
||||
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### Routen-Trigger-Payload
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -52,7 +52,10 @@ yarn twenty entity:add
|
||||
yarn twenty function:logs
|
||||
|
||||
# Esegui una funzione per nome
|
||||
yarn twenty function:execute -n my-function -p '{\"name\": \"test\"}'
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Esegui la funzione post-installazione
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Disinstalla l'applicazione dallo spazio di lavoro corrente
|
||||
yarn twenty app:uninstall
|
||||
@@ -70,7 +73,7 @@ Quando esegui `npx create-twenty-app@latest my-twenty-app`, lo scaffolder:
|
||||
* Copia un'applicazione base minimale in `my-twenty-app/`
|
||||
* Aggiunge una dipendenza locale `twenty-sdk` e la configurazione di Yarn 4
|
||||
* Crea file di configurazione e script collegati alla CLI `twenty`
|
||||
* Genera una configurazione applicativa predefinita e un ruolo funzione predefinito
|
||||
* Genera una configurazione applicativa predefinita, un ruolo funzione predefinito e una funzione post-installazione
|
||||
|
||||
Un'app appena generata dallo scaffolder si presenta così:
|
||||
|
||||
@@ -92,7 +95,8 @@ my-twenty-app/
|
||||
├── roles/
|
||||
│ └── default-role.ts # Ruolo predefinito per le funzioni logiche
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # Funzione logica di esempio
|
||||
│ ├── hello-world.ts # Funzione logica di esempio
|
||||
│ └── post-install.ts # Funzione logica post-installazione
|
||||
└── front-components/
|
||||
└── hello-world.tsx # Componente front-end di esempio
|
||||
```
|
||||
@@ -290,6 +294,7 @@ Ogni app ha un singolo file `application-config.ts` che descrive:
|
||||
* **Identità dell'app**: identificatori, nome visualizzato e descrizione.
|
||||
* **Come vengono eseguite le sue funzioni**: quale ruolo usano per i permessi.
|
||||
* **Variabili (opzionali)**: coppie chiave–valore esposte alle funzioni come variabili d'ambiente.
|
||||
* **(Opzionale) funzione post-installazione**: una funzione logica che viene eseguita dopo l'installazione dell'app.
|
||||
|
||||
Usa `defineApplication()` per definire la configurazione della tua applicazione:
|
||||
|
||||
@@ -297,6 +302,7 @@ Usa `defineApplication()` per definire la configurazione della tua applicazione:
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -312,6 +318,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -320,6 +327,7 @@ Note:
|
||||
* I campi `universalIdentifier` sono ID deterministici sotto il tuo controllo; generali una volta e mantienili stabili tra le sincronizzazioni.
|
||||
* `applicationVariables` diventano variabili d'ambiente per le tue funzioni (ad esempio, `DEFAULT_RECIPIENT_NAME` è disponibile come `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
* `defaultRoleUniversalIdentifier` deve corrispondere al file del ruolo (vedi sotto).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (opzionale) fa riferimento a una funzione logica che viene eseguita automaticamente dopo l'installazione dell'app. Vedi [Funzioni post-installazione](#post-install-functions).
|
||||
|
||||
#### Ruoli e permessi
|
||||
|
||||
@@ -458,6 +466,55 @@ Note:
|
||||
* L'array `triggers` è facoltativo. Le funzioni senza trigger possono essere utilizzate come funzioni di utilità richiamate da altre funzioni.
|
||||
* Puoi combinare più tipi di trigger in un'unica funzione.
|
||||
|
||||
### Funzioni post-installazione
|
||||
|
||||
Una funzione post-installazione è una funzione logica che viene eseguita automaticamente dopo che la tua app è stata installata in uno spazio di lavoro. Questo è utile per attività di configurazione una tantum come il popolamento di dati predefiniti, la creazione di record iniziali o la configurazione delle impostazioni dello spazio di lavoro.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Punti chiave:
|
||||
|
||||
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### Payload del trigger di route
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -45,19 +45,22 @@ yarn twenty app:dev
|
||||
A partir daqui você pode:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Adicionar uma nova entidade à sua aplicação (assistido)
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty entity:add
|
||||
|
||||
# Acompanhar os logs das funções da sua aplicação
|
||||
# Watch your application's function logs
|
||||
yarn twenty function:logs
|
||||
|
||||
# Executar uma função pelo nome
|
||||
# Execute a function by name
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Desinstalar a aplicação do espaço de trabalho atual
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
|
||||
# Exibir a ajuda dos comandos
|
||||
# Display commands' help
|
||||
yarn twenty help
|
||||
```
|
||||
|
||||
@@ -70,7 +73,7 @@ Ao executar `npx create-twenty-app@latest my-twenty-app`, o gerador:
|
||||
* Copia um aplicativo base mínimo para `my-twenty-app/`
|
||||
* Adiciona uma dependência local `twenty-sdk` e a configuração do Yarn 4
|
||||
* Cria arquivos de configuração e scripts conectados à CLI `twenty`
|
||||
* Gera uma configuração de aplicativo padrão e um papel padrão para as funções
|
||||
* Generates a default application config, a default function role, and a post-install function
|
||||
|
||||
Um aplicativo recém-criado pelo scaffold fica assim:
|
||||
|
||||
@@ -86,15 +89,16 @@ my-twenty-app/
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
README.md
|
||||
public/ # Pasta de recursos públicos (imagens, fontes, etc.)
|
||||
public/ # Public assets folder (images, fonts, etc.)
|
||||
src/
|
||||
├── application-config.ts # Obrigatório - configuração principal da aplicação
|
||||
├── application-config.ts # Required - main application configuration
|
||||
├── roles/
|
||||
│ └── default-role.ts # Papel padrão para funções de lógica
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # Exemplo de função de lógica
|
||||
│ ├── hello-world.ts # Example logic function
|
||||
│ └── post-install.ts # Post-install logic function
|
||||
└── front-components/
|
||||
└── hello-world.tsx # Exemplo de componente de front-end
|
||||
└── hello-world.tsx # Example front component
|
||||
```
|
||||
|
||||
Em alto nível:
|
||||
@@ -290,6 +294,7 @@ Todo aplicativo tem um único arquivo `application-config.ts` que descreve:
|
||||
* **O que é o aplicativo**: identificadores, nome de exibição e descrição.
|
||||
* **Como suas funções são executadas**: qual papel usam para permissões.
|
||||
* **Variáveis (opcional)**: pares chave–valor expostos às suas funções como variáveis de ambiente.
|
||||
* **(Optional) post-install function**: a logic function that runs after the app is installed.
|
||||
|
||||
Use `defineApplication()` to define your application configuration:
|
||||
|
||||
@@ -297,6 +302,7 @@ Use `defineApplication()` to define your application configuration:
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -312,6 +318,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -320,6 +327,7 @@ Notas:
|
||||
* `universalIdentifier` são IDs determinísticos que você controla; gere-os uma vez e mantenha-os estáveis entre sincronizações.
|
||||
* `applicationVariables` tornam-se variáveis de ambiente para suas funções (por exemplo, `DEFAULT_RECIPIENT_NAME` fica disponível como `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
* `defaultRoleUniversalIdentifier` deve corresponder ao arquivo do papel (veja abaixo).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
|
||||
#### Papéis e permissões
|
||||
|
||||
@@ -458,6 +466,55 @@ Notas:
|
||||
* O array `triggers` é opcional. Funções sem gatilhos podem ser usadas como funções utilitárias chamadas por outras funções.
|
||||
* Você pode misturar vários tipos de gatilho em uma única função.
|
||||
|
||||
### Post-install functions
|
||||
|
||||
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Pontos-chave:
|
||||
|
||||
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### Payload de gatilho de rota
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -54,6 +54,9 @@ yarn twenty function:logs
|
||||
# Execută o funcție după nume
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Execută funcția post-instalare
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Dezinstalează aplicația din spațiul de lucru curent
|
||||
yarn twenty app:uninstall
|
||||
|
||||
@@ -70,7 +73,7 @@ Când rulați `npx create-twenty-app@latest my-twenty-app`, generatorul:
|
||||
* Copiază o aplicație de bază minimală în `my-twenty-app/`
|
||||
* Adaugă o dependență locală `twenty-sdk` și configurația Yarn 4
|
||||
* Creează fișiere de configurare și scripturi conectate la CLI-ul `twenty`
|
||||
* Generează o configurație implicită a aplicației și un rol implicit pentru funcții
|
||||
* Generează o configurație implicită a aplicației, un rol implicit pentru funcții și o funcție post-instalare
|
||||
|
||||
O aplicație nou generată arată astfel:
|
||||
|
||||
@@ -86,15 +89,16 @@ my-twenty-app/
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
README.md
|
||||
public/ # Public assets folder (images, fonts, etc.)
|
||||
public/ # Director pentru resurse publice (imagini, fonturi etc.)
|
||||
src/
|
||||
├── application-config.ts # Required - main application configuration
|
||||
├── application-config.ts # Obligatoriu - configurația principală a aplicației
|
||||
├── roles/
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
│ └── default-role.ts # Rol implicit pentru funcțiile logice
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # Example logic function
|
||||
│ ├── hello-world.ts # Exemplu de funcție logică
|
||||
│ └── post-install.ts # Funcție logică post-instalare
|
||||
└── front-components/
|
||||
└── hello-world.tsx # Example front component
|
||||
└── hello-world.tsx # Exemplu de componentă de interfață
|
||||
```
|
||||
|
||||
Pe scurt:
|
||||
@@ -290,6 +294,7 @@ Fiecare aplicație are un singur fișier `application-config.ts` care descrie:
|
||||
* **Cine este aplicația**: identificatori, nume de afișare și descriere.
|
||||
* **Cum rulează funcțiile**: ce rol folosesc pentru permisiuni.
|
||||
* **(Opțional) variabile**: perechi cheie–valoare expuse funcțiilor ca variabile de mediu.
|
||||
* **(Opțional) funcție post-instalare**: o funcție logică care rulează după instalarea aplicației.
|
||||
|
||||
Folosiți `defineApplication()` pentru a defini configurația aplicației:
|
||||
|
||||
@@ -297,6 +302,7 @@ Folosiți `defineApplication()` pentru a defini configurația aplicației:
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -312,6 +318,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -320,6 +327,7 @@ Notițe:
|
||||
* Câmpurile `universalIdentifier` sunt ID-uri deterministe pe care le dețineți; generați-le o singură dată și păstrați-le stabile între sincronizări.
|
||||
* `applicationVariables` devin variabile de mediu pentru funcțiile dvs. (de exemplu, `DEFAULT_RECIPIENT_NAME` este disponibil ca `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
* `defaultRoleUniversalIdentifier` trebuie să corespundă fișierului de rol (vedeți mai jos).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (opțional) indică o funcție logică care rulează automat după instalarea aplicației. Vezi [Funcții post-instalare](#post-install-functions).
|
||||
|
||||
#### Roluri și permisiuni
|
||||
|
||||
@@ -458,6 +466,55 @@ Notițe:
|
||||
* Matricea `triggers` este opțională. Funcțiile fără declanșatoare pot fi folosite ca funcții utilitare apelate de alte funcții.
|
||||
* Puteți combina mai multe tipuri de declanșatoare într-o singură funcție.
|
||||
|
||||
### Funcții post-instalare
|
||||
|
||||
O funcție post-instalare este o funcție logică care rulează automat după instalarea aplicației într-un spațiu de lucru. Aceasta este utilă pentru sarcini de configurare unice, cum ar fi popularea cu date implicite, crearea înregistrărilor inițiale sau configurarea setărilor spațiului de lucru.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Puncte cheie:
|
||||
|
||||
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### Payload-ul declanșatorului de rută
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -45,19 +45,22 @@ yarn twenty app:dev
|
||||
Отсюда вы можете:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Добавить новую сущность в ваше приложение (с мастером)
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty entity:add
|
||||
|
||||
# Просматривать логи функций вашего приложения
|
||||
# Watch your application's function logs
|
||||
yarn twenty function:logs
|
||||
|
||||
# Выполнить функцию по имени
|
||||
yarn twenty function:execute -n my-function -p '{\"name\": \"test\"}'
|
||||
# Execute a function by name
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Удалить приложение из текущего рабочего пространства
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
|
||||
# Показать справку по командам
|
||||
# Display commands' help
|
||||
yarn twenty help
|
||||
```
|
||||
|
||||
@@ -70,7 +73,7 @@ yarn twenty help
|
||||
* Копирует минимальное базовое приложение в `my-twenty-app/`
|
||||
* Добавляет локальную зависимость `twenty-sdk` и конфигурацию Yarn 4
|
||||
* Создаёт файлы конфигурации и скрипты, подключённые к CLI `twenty`
|
||||
* Генерирует конфигурацию приложения по умолчанию и роль функции по умолчанию
|
||||
* Generates a default application config, a default function role, and a post-install function
|
||||
|
||||
Свежесгенерированное приложение выглядит так:
|
||||
|
||||
@@ -86,15 +89,16 @@ my-twenty-app/
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
README.md
|
||||
public/ # Папка общедоступных ресурсов (изображения, шрифты и т. п.)
|
||||
public/ # Public assets folder (images, fonts, etc.)
|
||||
src/
|
||||
├── application-config.ts # Обязательный — основная конфигурация приложения
|
||||
├── application-config.ts # Required - main application configuration
|
||||
├── roles/
|
||||
│ └── default-role.ts # Роль по умолчанию для логических функций
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # Пример логической функции
|
||||
│ ├── hello-world.ts # Example logic function
|
||||
│ └── post-install.ts # Post-install logic function
|
||||
└── front-components/
|
||||
└── hello-world.tsx # Пример фронтенд-компонента
|
||||
└── hello-world.tsx # Example front component
|
||||
```
|
||||
|
||||
В общих чертах:
|
||||
@@ -290,6 +294,7 @@ export default defineObject({
|
||||
* **Что это за приложение**: идентификаторы, отображаемое имя и описание.
|
||||
* **Как запускаются его функции**: какую роль они используют для прав доступа.
|
||||
* **(Необязательно) переменные**: пары ключ-значение, предоставляемые вашим функциям как переменные окружения.
|
||||
* **(Optional) post-install function**: a logic function that runs after the app is installed.
|
||||
|
||||
Используйте `defineApplication()` для определения конфигурации вашего приложения:
|
||||
|
||||
@@ -297,6 +302,7 @@ export default defineObject({
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -312,6 +318,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -320,6 +327,7 @@ export default defineApplication({
|
||||
* `universalIdentifier` — это детерминированные идентификаторы, которыми вы управляете; сгенерируйте их один раз и сохраняйте стабильными между синхронизациями.
|
||||
* `applicationVariables` становятся переменными окружения для ваших функций (например, `DEFAULT_RECIPIENT_NAME` доступна как `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
* `defaultRoleUniversalIdentifier` должен соответствовать файлу роли (см. ниже).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
|
||||
#### Роли и разрешения
|
||||
|
||||
@@ -458,6 +466,55 @@ export default defineLogicFunction({
|
||||
* Массив `triggers` необязателен. Функции без триггеров можно использовать как вспомогательные, вызываемые другими функциями.
|
||||
* Вы можете сочетать несколько типов триггеров в одной функции.
|
||||
|
||||
### Post-install functions
|
||||
|
||||
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Основные моменты:
|
||||
|
||||
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### Полезная нагрузка триггера маршрута
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -54,6 +54,9 @@ yarn twenty function:logs
|
||||
# Execute a function by name
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
|
||||
@@ -70,7 +73,7 @@ Ayrıca bkz.: [create-twenty-app](https://www.npmjs.com/package/create-twenty-ap
|
||||
* Minimal bir temel uygulamayı `my-twenty-app/` içine kopyalar
|
||||
* Yerel bir `twenty-sdk` bağımlılığı ve Yarn 4 yapılandırması ekler
|
||||
* `twenty` CLI ile bağlantılı yapılandırma dosyaları ve betikler oluşturur
|
||||
* Varsayılan bir uygulama yapılandırması ve varsayılan bir fonksiyon rolü üretir
|
||||
* Generates a default application config, a default function role, and a post-install function
|
||||
|
||||
Yeni şablondan oluşturulan bir uygulama şöyle görünür:
|
||||
|
||||
@@ -86,15 +89,16 @@ my-twenty-app/
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
README.md
|
||||
public/ # Genel varlıklar klasörü (görseller, yazı tipleri vb.)
|
||||
public/ # Public assets folder (images, fonts, etc.)
|
||||
src/
|
||||
├── application-config.ts # Gerekli - ana uygulama yapılandırması
|
||||
├── application-config.ts # Required - main application configuration
|
||||
├── roles/
|
||||
│ └── default-role.ts # Mantık işlevleri için varsayılan rol
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # Örnek mantık işlevi
|
||||
│ ├── hello-world.ts # Example logic function
|
||||
│ └── post-install.ts # Post-install logic function
|
||||
└── front-components/
|
||||
└── hello-world.tsx # Örnek ön uç bileşeni
|
||||
└── hello-world.tsx # Example front component
|
||||
```
|
||||
|
||||
Genel hatlarıyla:
|
||||
@@ -290,6 +294,7 @@ Her uygulamanın aşağıdakileri açıklayan tek bir `application-config.ts` do
|
||||
* **Uygulamanın kim olduğu**: tanımlayıcılar, görünen ad ve açıklama.
|
||||
* **Fonksiyonlarının nasıl çalıştığı**: izinler için hangi rolü kullandıkları.
|
||||
* **(İsteğe bağlı) değişkenler**: fonksiyonlarınıza ortam değişkenleri olarak sunulan anahtar–değer çiftleri.
|
||||
* **(Optional) post-install function**: a logic function that runs after the app is installed.
|
||||
|
||||
Uygulama yapılandırmanızı tanımlamak için `defineApplication()` kullanın:
|
||||
|
||||
@@ -297,6 +302,7 @@ Uygulama yapılandırmanızı tanımlamak için `defineApplication()` kullanın:
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -312,6 +318,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -320,6 +327,7 @@ Notlar:
|
||||
* `universalIdentifier` alanları size ait belirleyici kimliklerdir; bunları bir kez oluşturun ve eşitlemeler boyunca kararlı tutun.
|
||||
* `applicationVariables`, fonksiyonlarınız için ortam değişkenlerine dönüşür (örneğin, `DEFAULT_RECIPIENT_NAME` değeri `process.env.DEFAULT_RECIPIENT_NAME` olarak kullanılabilir).
|
||||
* `defaultRoleUniversalIdentifier`, rol dosyasıyla eşleşmelidir (aşağıya bakın).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
|
||||
#### Roller ve izinler
|
||||
|
||||
@@ -458,6 +466,55 @@ Notlar:
|
||||
* `triggers` dizisi isteğe bağlıdır. Tetikleyicisi olmayan fonksiyonlar, diğer fonksiyonlar tarafından çağrılan yardımcı fonksiyonlar olarak kullanılabilir.
|
||||
* Tek bir fonksiyonda birden çok tetikleyici türünü birleştirebilirsiniz.
|
||||
|
||||
### Post-install functions
|
||||
|
||||
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Önemli noktalar:
|
||||
|
||||
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### Rota tetikleyicisi yükü
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -45,19 +45,22 @@ yarn twenty app:dev
|
||||
从这里您可以:
|
||||
|
||||
```bash filename="Terminal"
|
||||
# 向你的应用添加一个新实体(引导式)
|
||||
# Add a new entity to your application (guided)
|
||||
yarn twenty entity:add
|
||||
|
||||
# 监听你的应用函数日志
|
||||
# Watch your application's function logs
|
||||
yarn twenty function:logs
|
||||
|
||||
# 按名称执行一个函数
|
||||
# Execute a function by name
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# 从当前工作区卸载该应用
|
||||
# Execute the post-install function
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
yarn twenty app:uninstall
|
||||
|
||||
# 显示命令帮助
|
||||
# Display commands' help
|
||||
yarn twenty help
|
||||
```
|
||||
|
||||
@@ -70,7 +73,7 @@ yarn twenty help
|
||||
* 将一个最小的基础应用复制到 `my-twenty-app/` 中
|
||||
* 添加本地 `twenty-sdk` 依赖和 Yarn 4 配置
|
||||
* 创建与 `twenty` CLI 关联的配置文件和脚本
|
||||
* 生成默认的应用配置和默认的函数角色
|
||||
* Generates a default application config, a default function role, and a post-install function
|
||||
|
||||
一个新生成的脚手架应用如下所示:
|
||||
|
||||
@@ -86,15 +89,16 @@ my-twenty-app/
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
README.md
|
||||
public/ # 公共资源文件夹(图像、字体等)
|
||||
public/ # Public assets folder (images, fonts, etc.)
|
||||
src/
|
||||
├── application-config.ts # 必需 - 主应用程序配置
|
||||
├── application-config.ts # Required - main application configuration
|
||||
├── roles/
|
||||
│ └── default-role.ts # 用于逻辑函数的默认角色
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
├── logic-functions/
|
||||
│ └── hello-world.ts # 示例逻辑函数
|
||||
│ ├── hello-world.ts # Example logic function
|
||||
│ └── post-install.ts # Post-install logic function
|
||||
└── front-components/
|
||||
└── hello-world.tsx # 示例前端组件
|
||||
└── hello-world.tsx # Example front component
|
||||
```
|
||||
|
||||
总体来说:
|
||||
@@ -290,6 +294,7 @@ export default defineObject({
|
||||
* **应用的身份**:标识符、显示名称和描述。
|
||||
* **函数如何运行**:它们用于权限的角色。
|
||||
* **(可选)变量**:以环境变量形式提供给函数的键值对。
|
||||
* **(Optional) post-install function**: a logic function that runs after the app is installed.
|
||||
|
||||
使用 `defineApplication()` 定义你的应用配置:
|
||||
|
||||
@@ -297,6 +302,7 @@ export default defineObject({
|
||||
// src/application-config.ts
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
@@ -312,6 +318,7 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -320,6 +327,7 @@ export default defineApplication({
|
||||
* `universalIdentifier` 字段是你拥有的确定性 ID;生成一次并在多次同步中保持稳定。
|
||||
* `applicationVariables` 会变成函数可用的环境变量(例如,`DEFAULT_RECIPIENT_NAME` 可作为 `process.env.DEFAULT_RECIPIENT_NAME` 使用)。
|
||||
* `defaultRoleUniversalIdentifier` 必须与角色文件一致(见下文)。
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
|
||||
#### 角色和权限
|
||||
|
||||
@@ -458,6 +466,55 @@ export default defineLogicFunction({
|
||||
* `triggers` 数组是可选的。 没有触发器的函数可作为实用函数,被其他函数调用。
|
||||
* 你可以在单个函数中混用多种触发器类型。
|
||||
|
||||
### Post-install functions
|
||||
|
||||
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
|
||||
|
||||
When you scaffold a new app with `create-twenty-app`, a post-install function is generated for you at `src/logic-functions/post-install.ts`:
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
handler,
|
||||
});
|
||||
```
|
||||
|
||||
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
You can also manually execute the post-install function at any time using the CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
* Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
|
||||
* The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
|
||||
* The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
|
||||
* Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
|
||||
|
||||
### 路由触发器负载
|
||||
|
||||
<Warning>
|
||||
|
||||
@@ -1448,13 +1448,8 @@ export enum EventLogTable {
|
||||
export type EventSubscription = {
|
||||
__typename?: 'EventSubscription';
|
||||
eventStreamId: Scalars['String'];
|
||||
eventWithQueryIdsList: Array<EventWithQueryIds>;
|
||||
};
|
||||
|
||||
export type EventWithQueryIds = {
|
||||
__typename?: 'EventWithQueryIds';
|
||||
event: ObjectRecordEvent;
|
||||
queryIds: Array<Scalars['String']>;
|
||||
metadataEventsWithQueryIds: Array<MetadataEventWithQueryIds>;
|
||||
objectRecordEventsWithQueryIds: Array<ObjectRecordEventWithQueryIds>;
|
||||
};
|
||||
|
||||
export type ExecuteOneLogicFunctionInput = {
|
||||
@@ -1485,6 +1480,7 @@ export enum FeatureFlagKey {
|
||||
IS_COMMAND_MENU_ITEM_ENABLED = 'IS_COMMAND_MENU_ITEM_ENABLED',
|
||||
IS_CORE_PICTURE_MIGRATED = 'IS_CORE_PICTURE_MIGRATED',
|
||||
IS_DASHBOARD_V2_ENABLED = 'IS_DASHBOARD_V2_ENABLED',
|
||||
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED = 'IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED',
|
||||
IS_DRAFT_EMAIL_ENABLED = 'IS_DRAFT_EMAIL_ENABLED',
|
||||
IS_EMAILING_DOMAIN_ENABLED = 'IS_EMAILING_DOMAIN_ENABLED',
|
||||
IS_FILES_FIELD_MIGRATED = 'IS_FILES_FIELD_MIGRATED',
|
||||
@@ -2138,6 +2134,27 @@ export type MarketplaceAppRoleObjectPermission = {
|
||||
objectUniversalIdentifier: Scalars['String'];
|
||||
};
|
||||
|
||||
export type MetadataEvent = {
|
||||
__typename?: 'MetadataEvent';
|
||||
metadataName: Scalars['String'];
|
||||
properties: ObjectRecordEventProperties;
|
||||
recordId: Scalars['String'];
|
||||
type: MetadataEventAction;
|
||||
};
|
||||
|
||||
/** Metadata Event Action */
|
||||
export enum MetadataEventAction {
|
||||
CREATED = 'CREATED',
|
||||
DELETED = 'DELETED',
|
||||
UPDATED = 'UPDATED'
|
||||
}
|
||||
|
||||
export type MetadataEventWithQueryIds = {
|
||||
__typename?: 'MetadataEventWithQueryIds';
|
||||
metadataEvent: MetadataEvent;
|
||||
queryIds: Array<Scalars['String']>;
|
||||
};
|
||||
|
||||
export enum ModelProvider {
|
||||
ANTHROPIC = 'ANTHROPIC',
|
||||
GROQ = 'GROQ',
|
||||
@@ -3379,6 +3396,12 @@ export type ObjectRecordEventProperties = {
|
||||
updatedFields?: Maybe<Array<Scalars['String']>>;
|
||||
};
|
||||
|
||||
export type ObjectRecordEventWithQueryIds = {
|
||||
__typename?: 'ObjectRecordEventWithQueryIds';
|
||||
objectRecordEvent: ObjectRecordEvent;
|
||||
queryIds: Array<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, WEEK, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR) */
|
||||
export enum ObjectRecordGroupByDateGranularity {
|
||||
DAY = 'DAY',
|
||||
@@ -5781,7 +5804,7 @@ export type FindOneFrontComponentQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null };
|
||||
export type FindOneFrontComponentQuery = { __typename?: 'Query', frontComponent?: { __typename?: 'FrontComponent', id: string, name: string, applicationId: string, builtComponentChecksum: string, applicationTokenPair?: { __typename?: 'ApplicationTokenPair', applicationAccessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, applicationRefreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } | null } | null };
|
||||
|
||||
export type LogicFunctionFieldsFragment = { __typename?: 'LogicFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, sourceHandlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string };
|
||||
|
||||
@@ -10569,6 +10592,7 @@ export const FindOneFrontComponentDocument = gql`
|
||||
id
|
||||
name
|
||||
applicationId
|
||||
builtComponentChecksum
|
||||
applicationTokenPair {
|
||||
applicationAccessToken {
|
||||
token
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const METADATA_OPERATION_BROWSER_EVENT_NAME =
|
||||
'metadata-operation-browser-event';
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { METADATA_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/MetadataOperationBrowserEventName';
|
||||
import { type MetadataOperation } from '@/browser-event/types/MetadataOperation';
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
import { useEffect } from 'react';
|
||||
import { type AllMetadataName } from 'twenty-shared/metadata';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
export const useListenToMetadataOperationBrowserEvent = <
|
||||
T extends Record<string, unknown>,
|
||||
>({
|
||||
onMetadataOperationBrowserEvent,
|
||||
metadataName,
|
||||
operationTypes,
|
||||
}: {
|
||||
onMetadataOperationBrowserEvent: (
|
||||
detail: MetadataOperationBrowserEventDetail<T>,
|
||||
) => void;
|
||||
metadataName?: AllMetadataName;
|
||||
operationTypes?: MetadataOperation<T>['type'][];
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const handleMetadataOperationEvent = (
|
||||
event: CustomEvent<MetadataOperationBrowserEventDetail<T>>,
|
||||
) => {
|
||||
const detail = event.detail;
|
||||
|
||||
if (isDefined(metadataName) && detail.metadataName !== metadataName) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isNonEmptyArray(operationTypes) &&
|
||||
!operationTypes.includes(detail.operation.type)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMetadataOperationBrowserEvent(detail);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
METADATA_OPERATION_BROWSER_EVENT_NAME,
|
||||
handleMetadataOperationEvent as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
METADATA_OPERATION_BROWSER_EVENT_NAME,
|
||||
handleMetadataOperationEvent as EventListener,
|
||||
);
|
||||
};
|
||||
}, [metadataName, onMetadataOperationBrowserEvent, operationTypes]);
|
||||
};
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName';
|
||||
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName';
|
||||
import { type ObjectRecordOperation } from '@/object-record/types/ObjectRecordOperation';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type MetadataOperation<T extends Record<string, unknown>> =
|
||||
| {
|
||||
type: 'create';
|
||||
createdRecord: T;
|
||||
}
|
||||
| {
|
||||
type: 'update';
|
||||
updatedRecord: T;
|
||||
updatedFields?: string[];
|
||||
}
|
||||
| {
|
||||
type: 'delete';
|
||||
deletedRecordId: string;
|
||||
};
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { type MetadataOperation } from '@/browser-event/types/MetadataOperation';
|
||||
import { type AllMetadataName } from 'twenty-shared/metadata';
|
||||
|
||||
export type MetadataOperationBrowserEventDetail<
|
||||
T extends Record<string, unknown>,
|
||||
> = {
|
||||
metadataName: AllMetadataName;
|
||||
operation: MetadataOperation<T>;
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { METADATA_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/MetadataOperationBrowserEventName';
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
|
||||
export const dispatchMetadataOperationBrowserEvent = <
|
||||
T extends Record<string, unknown>,
|
||||
>(
|
||||
detail: MetadataOperationBrowserEventDetail<T>,
|
||||
) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(METADATA_OPERATION_BROWSER_EVENT_NAME, {
|
||||
detail,
|
||||
}),
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/object-record/constants/ObjectRecordOperationBrowserEventName';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { OBJECT_RECORD_OPERATION_BROWSER_EVENT_NAME } from '@/browser-event/constants/ObjectRecordOperationBrowserEventName';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
|
||||
export const dispatchObjectRecordOperationBrowserEvent = (
|
||||
detail: ObjectRecordOperationBrowserEventDetail,
|
||||
+11
-3
@@ -1,5 +1,6 @@
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
|
||||
import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated';
|
||||
import { getFrontComponentUrl } from '@/front-components/utils/getFrontComponentUrl';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
@@ -21,8 +22,6 @@ export const FrontComponentRenderer = ({
|
||||
const { executionContext, frontComponentHostCommunicationApi } =
|
||||
useFrontComponentExecutionContext();
|
||||
|
||||
const componentUrl = `${REST_API_BASE_URL}/front-components/${frontComponentId}`;
|
||||
|
||||
const handleError = useCallback(
|
||||
(error?: Error) => {
|
||||
if (!isDefined(error)) {
|
||||
@@ -43,6 +42,15 @@ export const FrontComponentRenderer = ({
|
||||
onError: handleError,
|
||||
});
|
||||
|
||||
useOnFrontComponentUpdated({
|
||||
frontComponentId,
|
||||
});
|
||||
|
||||
const componentUrl = getFrontComponentUrl({
|
||||
frontComponentId,
|
||||
checksum: data?.frontComponent?.builtComponentChecksum,
|
||||
});
|
||||
|
||||
if (
|
||||
loading ||
|
||||
!isDefined(data?.frontComponent) ||
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql`
|
||||
id
|
||||
name
|
||||
applicationId
|
||||
builtComponentChecksum
|
||||
applicationTokenPair {
|
||||
applicationAccessToken {
|
||||
token
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { useUpdateFrontComponentApolloCache } from '@/front-components/hooks/useUpdateFrontComponentApolloCache';
|
||||
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
import {
|
||||
AllMetadataName,
|
||||
type FrontComponent,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type UseOnFrontComponentUpdatedArgs = {
|
||||
frontComponentId: string;
|
||||
};
|
||||
|
||||
export const useOnFrontComponentUpdated = ({
|
||||
frontComponentId,
|
||||
}: UseOnFrontComponentUpdatedArgs) => {
|
||||
const queryId = `front-component-updated-${frontComponentId}`;
|
||||
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
metadataName: AllMetadataName.frontComponent,
|
||||
variables: {
|
||||
filter: { id: { eq: frontComponentId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { updateFrontComponentApolloCache } =
|
||||
useUpdateFrontComponentApolloCache({
|
||||
frontComponentId,
|
||||
});
|
||||
|
||||
useListenToMetadataOperationBrowserEvent<FrontComponent>({
|
||||
metadataName: AllMetadataName.frontComponent,
|
||||
onMetadataOperationBrowserEvent: updateFrontComponentApolloCache,
|
||||
});
|
||||
};
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
FindOneFrontComponentDocument,
|
||||
type FindOneFrontComponentQuery,
|
||||
type FrontComponent,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type UseUpdateFrontComponentApolloCacheArgs = {
|
||||
frontComponentId: string;
|
||||
};
|
||||
|
||||
export const useUpdateFrontComponentApolloCache = ({
|
||||
frontComponentId,
|
||||
}: UseUpdateFrontComponentApolloCacheArgs) => {
|
||||
const apolloClient = useApolloClient();
|
||||
|
||||
const updateFrontComponentApolloCache = (
|
||||
detail: MetadataOperationBrowserEventDetail<FrontComponent>,
|
||||
) => {
|
||||
if (detail.operation.type !== 'update') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { updatedRecord } = detail.operation;
|
||||
|
||||
if (!isDefined(updatedRecord) || updatedRecord.id !== frontComponentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
apolloClient.cache.updateQuery<FindOneFrontComponentQuery>(
|
||||
{
|
||||
query: FindOneFrontComponentDocument,
|
||||
variables: { id: frontComponentId },
|
||||
},
|
||||
(existingData) => {
|
||||
if (!isDefined(existingData?.frontComponent)) {
|
||||
return existingData;
|
||||
}
|
||||
|
||||
return {
|
||||
...existingData,
|
||||
frontComponent: {
|
||||
...existingData.frontComponent,
|
||||
...updatedRecord,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return { updateFrontComponentApolloCache };
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const getFrontComponentUrl = ({
|
||||
frontComponentId,
|
||||
checksum,
|
||||
}: {
|
||||
frontComponentId: string;
|
||||
checksum?: string;
|
||||
}): string => {
|
||||
return isDefined(checksum)
|
||||
? `${REST_API_BASE_URL}/front-components/${frontComponentId}?checksum=${checksum}`
|
||||
: `${REST_API_BASE_URL}/front-components/${frontComponentId}`;
|
||||
};
|
||||
+11
-1
@@ -19,6 +19,7 @@ import {
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
|
||||
import { WORKFLOW_TIMEZONE } from '@/workflow/constants/WorkflowTimeZone';
|
||||
import { isObject, isString } from '@sniptt/guards';
|
||||
@@ -62,6 +63,10 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
|
||||
const { applyObjectFilterDropdownFilterValue } =
|
||||
useApplyObjectFilterDropdownFilterValue();
|
||||
|
||||
const featureFlags = useFeatureFlagsMap();
|
||||
const isWholeDayFilterEnabled =
|
||||
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
|
||||
|
||||
const handleChange = (newValue: JsonValue) => {
|
||||
if (isString(newValue)) {
|
||||
applyObjectFilterDropdownFilterValue(newValue);
|
||||
@@ -178,7 +183,12 @@ export const AdvancedFilterCommandMenuValueFormInput = ({
|
||||
}
|
||||
|
||||
const field = {
|
||||
type: recordFilter.type as FieldMetadataType,
|
||||
type:
|
||||
isWholeDayFilterEnabled === true &&
|
||||
recordFilter.type === FieldMetadataType.DATE_TIME &&
|
||||
recordFilter.operand === RecordFilterOperand.IS
|
||||
? FieldMetadataType.DATE
|
||||
: (recordFilter.type as FieldMetadataType),
|
||||
label: '',
|
||||
metadata: fieldDefinition?.metadata as FieldMetadata,
|
||||
};
|
||||
|
||||
+2
-2
@@ -2,11 +2,11 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata
|
||||
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
|
||||
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
|
||||
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
jest.mock('@/object-metadata/hooks/useObjectMetadataItem');
|
||||
jest.mock('@/object-record/utils/dispatchObjectRecordOperationBrowserEvent');
|
||||
jest.mock('@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent');
|
||||
jest.mock('@/object-record/hooks/useUpdateManyRecords', () => ({
|
||||
useUpdateManyRecords: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@/object-record/hooks/useCreateManyRecords';
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { ApolloError } from '@apollo/client';
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
@@ -19,7 +19,7 @@ import { type FieldActorForInputValue } from '@/object-record/record-field/ui/ty
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField';
|
||||
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { type BaseObjectRecord } from '@/object-record/types/BaseObjectRecord';
|
||||
import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput';
|
||||
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField';
|
||||
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField';
|
||||
import { isNull } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
|
||||
import { useRemoveNavigationMenuItemByTargetRecordId } from '@/navigation-menu-item/hooks/useRemoveNavigationMenuItemByTargetRecordId';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordF
|
||||
import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation';
|
||||
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDestroyOneRecordMutationResponseField } from '@/object-record/utils/getDestroyOneRecordMutationResponseField';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getDestroyManyRecordsMutationResponseField } from '@/object-record/utils/getDestroyManyRecordsMutationResponseField';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
import { sleep } from '~/utils/sleep';
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIn
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
|
||||
|
||||
const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQue
|
||||
import { useMergeManyRecordsMutation } from '@/object-record/hooks/useMergeManyRecordsMutation';
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
|
||||
import { getOperationName } from '@apollo/client/utilities';
|
||||
import { type RecordGqlOperationGqlRecordFields } from 'twenty-shared/types';
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions
|
||||
import { useRestoreManyRecordsMutation } from '@/object-record/hooks/useRestoreManyRecordsMutation';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getRestoreManyRecordsMutationResponseField } from '@/object-record/utils/getRestoreManyRecordsMutationResponseField';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
|
||||
import { useUpdateManyRecordsMutation } from '@/object-record/hooks/useUpdateManyRecordsMutation';
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
|
||||
import { getUpdateManyRecordsMutationResponseField } from '@/object-record/utils/getUpdateManyRecordsMutationResponseField';
|
||||
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggr
|
||||
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
|
||||
import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField';
|
||||
import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput';
|
||||
|
||||
+9
-3
@@ -38,7 +38,12 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
useApplyObjectFilterDropdownFilterValue();
|
||||
|
||||
const handleAbsoluteDateChange = (newPlainDate: string | null) => {
|
||||
const newFilterValue = newPlainDate ?? '';
|
||||
if (!isDefined(newPlainDate)) {
|
||||
applyObjectFilterDropdownFilterValue('', '');
|
||||
return;
|
||||
}
|
||||
|
||||
const newFilterValue = newPlainDate;
|
||||
|
||||
// TODO: remove this and use getDisplayValue instead
|
||||
const formattedDate = formatDateString({
|
||||
@@ -91,6 +96,7 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
? handleRelativeDateChange(null)
|
||||
: handleAbsoluteDateChange(null);
|
||||
};
|
||||
|
||||
const resolvedValue = objectFilterDropdownCurrentRecordFilter
|
||||
? resolveDateFilter(objectFilterDropdownCurrentRecordFilter)
|
||||
: null;
|
||||
@@ -100,7 +106,7 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
? resolvedValue
|
||||
: undefined;
|
||||
|
||||
const plainDateValue =
|
||||
const safePlainDateValue: string | undefined =
|
||||
resolvedValue && typeof resolvedValue === 'string'
|
||||
? resolvedValue
|
||||
: undefined;
|
||||
@@ -110,7 +116,7 @@ export const ObjectFilterDropdownDateInput = () => {
|
||||
instanceId={`object-filter-dropdown-date-input`}
|
||||
relativeDate={relativeDate}
|
||||
isRelative={isRelativeOperand}
|
||||
plainDateString={plainDateValue ?? null}
|
||||
plainDateString={safePlainDateValue ?? null}
|
||||
onChange={handleAbsoluteDateChange}
|
||||
onRelativeDateChange={handleRelativeDateChange}
|
||||
onClear={handleClear}
|
||||
|
||||
+17
@@ -5,6 +5,7 @@ import { ObjectFilterDropdownRatingInput } from '@/object-record/object-filter-d
|
||||
import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect';
|
||||
import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
|
||||
import { ViewFilterOperand } from 'twenty-shared/types';
|
||||
|
||||
@@ -28,6 +29,10 @@ export const ObjectFilterDropdownFilterInput = ({
|
||||
filterDropdownId,
|
||||
recordFilterId,
|
||||
}: ObjectFilterDropdownFilterInputProps) => {
|
||||
const featureFlags = useFeatureFlagsMap();
|
||||
const isWholeDayFilterEnabled =
|
||||
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
|
||||
|
||||
const fieldMetadataItemUsedInDropdown = useRecoilComponentValue(
|
||||
fieldMetadataItemUsedInDropdownComponentSelector,
|
||||
);
|
||||
@@ -76,6 +81,18 @@ export const ObjectFilterDropdownFilterInput = ({
|
||||
</>
|
||||
);
|
||||
} else if (filterType === 'DATE_TIME') {
|
||||
if (
|
||||
isWholeDayFilterEnabled &&
|
||||
selectedOperandInDropdown === ViewFilterOperand.IS
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<ObjectFilterDropdownInnerSelectOperandDropdown />
|
||||
<DropdownMenuSeparator />
|
||||
<ObjectFilterDropdownDateInput />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<ObjectFilterDropdownInnerSelectOperandDropdown />
|
||||
|
||||
+76
-10
@@ -1,3 +1,6 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { useUpsertObjectFilterDropdownCurrentFilter } from '@/object-record/object-filter-dropdown/hooks/useUpsertObjectFilterDropdownCurrentFilter';
|
||||
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
|
||||
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
|
||||
@@ -7,14 +10,12 @@ import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
|
||||
|
||||
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants';
|
||||
import {
|
||||
isDefined,
|
||||
relativeDateFilterStringifiedSchema,
|
||||
@@ -47,6 +48,10 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
const { getRelativeDateFilterWithUserTimezone } =
|
||||
useGetRelativeDateFilterWithUserTimezone();
|
||||
|
||||
const featureFlags = useFeatureFlagsMap();
|
||||
const isWholeDayFilterEnabled =
|
||||
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
|
||||
|
||||
const applyObjectFilterDropdownOperand = (
|
||||
newOperand: RecordFilterOperand,
|
||||
) => {
|
||||
@@ -106,7 +111,24 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
recordFilterToUpsert.value,
|
||||
);
|
||||
|
||||
if (filterValueIsEmpty || isStillRelativeFilterValue.success) {
|
||||
const previousOperand =
|
||||
objectFilterDropdownCurrentRecordFilter?.operand;
|
||||
|
||||
const isDateTimeOperandFormatChange =
|
||||
recordFilterToUpsert.type === 'DATE_TIME' &&
|
||||
!filterValueIsEmpty &&
|
||||
!isStillRelativeFilterValue.success &&
|
||||
(previousOperand === RecordFilterOperand.IS ||
|
||||
newOperand === RecordFilterOperand.IS);
|
||||
|
||||
if (isDateTimeOperandFormatChange) {
|
||||
recordFilterToUpsert.value = convertDateTimeFilterValue(
|
||||
recordFilterToUpsert.value,
|
||||
newOperand,
|
||||
userTimezone,
|
||||
isWholeDayFilterEnabled,
|
||||
);
|
||||
} else if (filterValueIsEmpty || isStillRelativeFilterValue.success) {
|
||||
const zonedDateToUse = Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
if (recordFilterToUpsert.type === 'DATE') {
|
||||
@@ -116,11 +138,18 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
|
||||
recordFilterToUpsert.value = initialNowDateFilterValue;
|
||||
} else {
|
||||
const initialNowDateTimeFilterValue = zonedDateToUse
|
||||
.toInstant()
|
||||
.toString();
|
||||
|
||||
recordFilterToUpsert.value = initialNowDateTimeFilterValue;
|
||||
if (
|
||||
newOperand === RecordFilterOperand.IS &&
|
||||
isWholeDayFilterEnabled
|
||||
) {
|
||||
recordFilterToUpsert.value = zonedDateToUse
|
||||
.toPlainDate()
|
||||
.toString();
|
||||
} else {
|
||||
recordFilterToUpsert.value = zonedDateToUse
|
||||
.toInstant()
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,3 +166,40 @@ export const useApplyObjectFilterDropdownOperand = () => {
|
||||
applyObjectFilterDropdownOperand,
|
||||
};
|
||||
};
|
||||
|
||||
const convertDateTimeFilterValue = (
|
||||
currentValue: string,
|
||||
targetOperand: RecordFilterOperand,
|
||||
userTimezone: string,
|
||||
isWholeDayFilterEnabled = false,
|
||||
): string => {
|
||||
const zonedDateToUse = Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
if (targetOperand === RecordFilterOperand.IS) {
|
||||
try {
|
||||
const existingZoned = currentValue.includes('T')
|
||||
? Temporal.Instant.from(currentValue).toZonedDateTimeISO(userTimezone)
|
||||
: Temporal.PlainDate.from(currentValue).toZonedDateTime(userTimezone);
|
||||
|
||||
if (isWholeDayFilterEnabled) {
|
||||
return existingZoned.toPlainDate().toString();
|
||||
} else {
|
||||
return existingZoned.toInstant().toString();
|
||||
}
|
||||
} catch {
|
||||
return zonedDateToUse.toPlainDate().toString();
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const existingPlainDate = Temporal.PlainDate.from(currentValue);
|
||||
const currentTime = zonedDateToUse.toPlainTime();
|
||||
const zonedFromPlain = existingPlainDate.toZonedDateTime({
|
||||
timeZone: userTimezone,
|
||||
plainTime: currentTime,
|
||||
});
|
||||
return zonedFromPlain.toInstant().toString();
|
||||
} catch {
|
||||
return zonedDateToUse.toInstant().toString();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+18
-1
@@ -1,9 +1,12 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue';
|
||||
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
|
||||
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap';
|
||||
|
||||
import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types';
|
||||
|
||||
const activeDatePickerOperands = [
|
||||
@@ -16,6 +19,9 @@ export const useGetInitialFilterValue = () => {
|
||||
const { userTimezone } = useUserTimezone();
|
||||
const { getDateFilterDisplayValue } = useGetDateFilterDisplayValue();
|
||||
const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue();
|
||||
const featureFlags = useFeatureFlagsMap();
|
||||
const isWholeDayFilterEnabled =
|
||||
featureFlags.IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED ?? false;
|
||||
|
||||
const getInitialFilterValue = (
|
||||
newType: FilterableAndTSVectorFieldType,
|
||||
@@ -44,6 +50,17 @@ export const useGetInitialFilterValue = () => {
|
||||
alreadyExistingZonedDateTime ??
|
||||
Temporal.Now.zonedDateTimeISO(userTimezone);
|
||||
|
||||
if (
|
||||
isWholeDayFilterEnabled === true &&
|
||||
newOperand === RecordFilterOperand.IS
|
||||
) {
|
||||
const value = referenceDate.toPlainDate().toString();
|
||||
|
||||
const { displayValue } = getDateFilterDisplayValue(referenceDate);
|
||||
|
||||
return { value, displayValue };
|
||||
}
|
||||
|
||||
const value = referenceDate.toInstant().toString();
|
||||
|
||||
const { displayValue } = getDateTimeFilterDisplayValue(referenceDate);
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useGetShouldInitializeRecordBoardForUpdateInputs } from '@/object-record/record-board/hooks/useGetShouldInitializeRecordBoardForUpdateInputs';
|
||||
import { useRemoveRecordsFromBoard } from '@/object-record/record-board/hooks/useRemoveRecordsFromBoard';
|
||||
import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery';
|
||||
@@ -7,7 +7,7 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
|
||||
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
|
||||
import { recordIndexRecordIdsByGroupComponentFamilyState } from '@/object-record/record-index/states/recordIndexRecordIdsByGroupComponentFamilyState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
|
||||
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { useContext } from 'react';
|
||||
import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext';
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
import { useRecordIndexGroupCommonQueryVariables } from '@/object-record/record-index/hooks/useRecordIndexGroupCommonQueryVariables';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
|
||||
export const RecordBoardSSESubscribeEffect = () => {
|
||||
const { recordBoardId } = useContext(RecordBoardContext);
|
||||
@@ -13,7 +13,7 @@ export const RecordBoardSSESubscribeEffect = () => {
|
||||
|
||||
const queryId = `record-board-${recordBoardId}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar
|
||||
import { useRecordCalendarQueryDateRangeFilter } from '@/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter';
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { type RecordGqlOperationOrderBy } from 'twenty-shared/types';
|
||||
@@ -32,7 +32,7 @@ export const RecordCalendarSSESubscribeEffect = () => {
|
||||
|
||||
const queryId = `record-calendar-${recordCalendarId}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
|
||||
+21
-3
@@ -1,3 +1,6 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
|
||||
import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue';
|
||||
import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue';
|
||||
@@ -7,8 +10,7 @@ import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordF
|
||||
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { type Nullable } from 'twenty-shared/types';
|
||||
import {
|
||||
isDefined,
|
||||
@@ -87,7 +89,23 @@ export const useGetRecordFilterDisplayValue = () => {
|
||||
}
|
||||
} else if (recordFilter.type === 'DATE_TIME') {
|
||||
switch (recordFilter.operand) {
|
||||
case RecordFilterOperand.IS:
|
||||
case RecordFilterOperand.IS: {
|
||||
if (!isNonEmptyString(recordFilter.value)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const zonedDateTime = recordFilter.value.includes('T')
|
||||
? Temporal.Instant.from(recordFilter.value).toZonedDateTimeISO(
|
||||
userTimezone,
|
||||
)
|
||||
: Temporal.PlainDate.from(recordFilter.value).toZonedDateTime(
|
||||
userTimezone,
|
||||
);
|
||||
|
||||
const { displayValue } = getDateFilterDisplayValue(zonedDateTime);
|
||||
|
||||
return `${displayValue}`;
|
||||
}
|
||||
case RecordFilterOperand.IS_AFTER:
|
||||
case RecordFilterOperand.IS_BEFORE: {
|
||||
if (!isNonEmptyString(recordFilter.value)) {
|
||||
|
||||
+4
-4
@@ -1024,9 +1024,9 @@ describe('should work as expected for the different field types', () => {
|
||||
|
||||
const dateFilterIs: RecordFilter = {
|
||||
id: 'company-date-filter-is',
|
||||
value: '2024-09-17T20:46:58.922Z',
|
||||
value: '2024-09-17',
|
||||
fieldMetadataId: companyMockDateFieldMetadataId?.id,
|
||||
displayValue: '2024-09-17T20:46:58.922Z',
|
||||
displayValue: '2024-09-17',
|
||||
operand: ViewFilterOperand.IS,
|
||||
label: 'Created At',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
@@ -1081,12 +1081,12 @@ describe('should work as expected for the different field types', () => {
|
||||
and: [
|
||||
{
|
||||
createdAt: {
|
||||
lt: '2024-09-17T20:47:00Z',
|
||||
gte: '2024-09-16T22:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
createdAt: {
|
||||
gte: '2024-09-17T20:46:00Z',
|
||||
lt: '2024-09-17T22:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
|
||||
type RecordShowPageSSESubscribeEffectProps = {
|
||||
objectNameSingular: string;
|
||||
@@ -11,7 +11,7 @@ export const RecordShowPageSSESubscribeEffect = ({
|
||||
}: RecordShowPageSSESubscribeEffectProps) => {
|
||||
const queryId = `record-show-${objectNameSingular}-${recordId}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular,
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
|
||||
import { SSE_TABLE_DEBOUNCE_TIME_IN_MS_TO_AVOID_SSE_OWN_EVENTS_RACE_CONDITION } from '@/object-record/record-table/virtualization/constants/SseTableDebounceTimeInMsToAvoidSseOwnEventsRaceCondition';
|
||||
import { useGetShouldResetTableVirtualizationForUpdateInputs } from '@/object-record/record-table/virtualization/hooks/useGetShouldResetTableVirtualizationForUpdateInputs';
|
||||
import { useResetVirtualizationBecauseDataChanged } from '@/object-record/record-table/virtualization/hooks/useResetVirtualizationBecauseDataChanged';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
export const RecordTableVirtualizedDataChangedEffect = () => {
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
|
||||
|
||||
@@ -26,7 +26,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
|
||||
|
||||
const queryId = `record-table-virtualized-${objectMetadataItem.nameSingular}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
|
||||
@@ -37,11 +37,11 @@ export const SSEEventStreamEffect = () => {
|
||||
const { triggerEventStreamDestroy } = useTriggerEventStreamDestroy();
|
||||
|
||||
useEffect(() => {
|
||||
const isSseClientAvailabble =
|
||||
const isSseClientAvailable =
|
||||
!isCreatingSseEventStream && !isDestroyingEventStream;
|
||||
|
||||
const willCreateEventStream =
|
||||
isSseClientAvailabble &&
|
||||
isSseClientAvailable &&
|
||||
isLoggedIn &&
|
||||
isSseDbEventsEnabled &&
|
||||
isDefined(currentUser) &&
|
||||
@@ -51,7 +51,7 @@ export const SSEEventStreamEffect = () => {
|
||||
isNonEmptyArray(objectMetadataItems);
|
||||
|
||||
const willDestroyEventStream =
|
||||
isSseClientAvailabble &&
|
||||
isSseClientAvailable &&
|
||||
isNonEmptyString(sseEventStreamId) &&
|
||||
shouldDestroyEventStream;
|
||||
|
||||
|
||||
+16
-2
@@ -4,8 +4,8 @@ export const ON_EVENT_SUBSCRIPTION = gql`
|
||||
subscription OnEventSubscription($eventStreamId: String!) {
|
||||
onEventSubscription(eventStreamId: $eventStreamId) {
|
||||
eventStreamId
|
||||
eventWithQueryIdsList {
|
||||
event {
|
||||
objectRecordEventsWithQueryIds {
|
||||
objectRecordEvent {
|
||||
action
|
||||
objectNameSingular
|
||||
recordId
|
||||
@@ -20,6 +20,20 @@ export const ON_EVENT_SUBSCRIPTION = gql`
|
||||
}
|
||||
queryIds
|
||||
}
|
||||
metadataEventsWithQueryIds {
|
||||
metadataEvent {
|
||||
type
|
||||
metadataName
|
||||
recordId
|
||||
properties {
|
||||
updatedFields
|
||||
before
|
||||
after
|
||||
diff
|
||||
}
|
||||
}
|
||||
queryIds
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { dispatchMetadataOperationBrowserEvent } from '@/browser-event/utils/dispatchMetadataOperationBrowserEvent';
|
||||
import { turnSseMetadataEventsToMetadataOperationBrowserEvents } from '@/sse-db-event/utils/turnSseMetadataEventsToMetadataOperationBrowserEvents';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
type AllMetadataName,
|
||||
type MetadataEvent,
|
||||
type MetadataEventWithQueryIds,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const groupSseMetadataEventsByMetadataName = (
|
||||
sseMetadataEvents: MetadataEvent[],
|
||||
): Map<AllMetadataName, MetadataEvent[]> => {
|
||||
const eventsByMetadataName = new Map<AllMetadataName, MetadataEvent[]>();
|
||||
|
||||
for (const event of sseMetadataEvents) {
|
||||
const metadataName = event.metadataName as AllMetadataName;
|
||||
|
||||
const existing = eventsByMetadataName.get(metadataName) ?? [];
|
||||
|
||||
eventsByMetadataName.set(metadataName, [...existing, event]);
|
||||
}
|
||||
|
||||
return eventsByMetadataName;
|
||||
};
|
||||
|
||||
export const useDispatchMetadataEventsFromSseToBrowserEvents = <
|
||||
T extends Record<string, unknown>,
|
||||
>() => {
|
||||
const dispatchMetadataEventsFromSseToBrowserEvents = useCallback(
|
||||
(metadataEventsWithQueryIds: MetadataEventWithQueryIds[]) => {
|
||||
const sseMetadataEvents = metadataEventsWithQueryIds.map(
|
||||
(item) => item.metadataEvent,
|
||||
);
|
||||
|
||||
const eventsByMetadataName =
|
||||
groupSseMetadataEventsByMetadataName(sseMetadataEvents);
|
||||
|
||||
for (const [metadataName, events] of eventsByMetadataName) {
|
||||
const metadataOperationBrowserEvents =
|
||||
turnSseMetadataEventsToMetadataOperationBrowserEvents<T>({
|
||||
metadataName,
|
||||
sseMetadataEvents: events,
|
||||
});
|
||||
|
||||
for (const browserEvent of metadataOperationBrowserEvents) {
|
||||
dispatchMetadataOperationBrowserEvent(browserEvent);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { dispatchMetadataEventsFromSseToBrowserEvents };
|
||||
};
|
||||
+6
-6
@@ -1,19 +1,19 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { groupObjectRecordSseEventsByObjectMetadataItemNameSingular } from '@/sse-db-event/utils/groupObjectRecordSseEventsByObjectMetadataItemNameSingular';
|
||||
import { turnSseObjectRecordEventsToObjectRecordOperationBrowserEvents } from '@/sse-db-event/utils/turnSseObjectRecordEventToObjectRecordOperationBrowserEvent';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type EventWithQueryIds } from '~/generated-metadata/graphql';
|
||||
import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql';
|
||||
|
||||
export const useDispatchObjectRecordEventsFromSseToBrowserEvents = () => {
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const dispatchObjectRecordEventsFromSseToBrowserEvents = useCallback(
|
||||
(eventsWithQueryIds: EventWithQueryIds[]) => {
|
||||
const objectRecordEvents = eventsWithQueryIds.map((eventWithQueryIds) => {
|
||||
return eventWithQueryIds.event;
|
||||
});
|
||||
(objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[]) => {
|
||||
const objectRecordEvents = objectRecordEventsWithQueryIds.map(
|
||||
(item) => item.objectRecordEvent,
|
||||
);
|
||||
|
||||
const objectRecordEventsByObjectMetadataItemNameSingular =
|
||||
groupObjectRecordSseEventsByObjectMetadataItemNameSingular({
|
||||
|
||||
+8
-3
@@ -2,14 +2,19 @@ import { requiredQueryListenersState } from '@/sse-db-event/states/requiredQuery
|
||||
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
|
||||
import {
|
||||
type MetadataGqlOperationSignature,
|
||||
type RecordGqlOperationSignature,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export const useListenToObjectRecordEventsForQuery = ({
|
||||
export const useListenToEventsForQuery = ({
|
||||
queryId,
|
||||
operationSignature,
|
||||
}: {
|
||||
queryId: string;
|
||||
operationSignature: RecordGqlOperationSignature;
|
||||
operationSignature:
|
||||
| RecordGqlOperationSignature
|
||||
| MetadataGqlOperationSignature;
|
||||
}) => {
|
||||
const changeQueryIdListenState = useRecoilCallback(
|
||||
({ set, snapshot }) =>
|
||||
+56
-6
@@ -1,4 +1,5 @@
|
||||
import { ON_EVENT_SUBSCRIPTION } from '@/sse-db-event/graphql/subscriptions/OnEventSubscription';
|
||||
import { useDispatchMetadataEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchMetadataEventsFromSseToBrowserEvents';
|
||||
import { useDispatchObjectRecordEventsFromSseToBrowserEvents } from '@/sse-db-event/hooks/useDispatchObjectRecordEventsFromSseToBrowserEvents';
|
||||
import { useTriggerOptimisticEffectFromSseEvents } from '@/sse-db-event/hooks/useTriggerOptimisticEffectFromSseEvents';
|
||||
import { disposeFunctionForEventStreamState } from '@/sse-db-event/states/disposeFunctionByEventStreamMapState';
|
||||
@@ -23,6 +24,9 @@ export const useTriggerEventStreamCreation = () => {
|
||||
isCreatingSseEventStreamState,
|
||||
);
|
||||
|
||||
const { dispatchMetadataEventsFromSseToBrowserEvents } =
|
||||
useDispatchMetadataEventsFromSseToBrowserEvents();
|
||||
|
||||
const { dispatchObjectRecordEventsFromSseToBrowserEvents } =
|
||||
useDispatchObjectRecordEventsFromSseToBrowserEvents();
|
||||
|
||||
@@ -73,9 +77,55 @@ export const useTriggerEventStreamCreation = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
next: (
|
||||
value: ExecutionResult<{
|
||||
onEventSubscription: EventSubscription;
|
||||
}>,
|
||||
) => {
|
||||
if (isDefined(value?.errors)) {
|
||||
captureException(
|
||||
new Error(
|
||||
`SSE subscription error: ${value.errors[0]?.message}`,
|
||||
),
|
||||
);
|
||||
set(shouldDestroyEventStreamState, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasReceivedFirstEvent) {
|
||||
hasReceivedFirstEvent = true;
|
||||
set(sseEventStreamReadyState, true);
|
||||
}
|
||||
|
||||
const eventSubscription = value?.data?.onEventSubscription;
|
||||
|
||||
const objectRecordEventsWithQueryIds =
|
||||
eventSubscription?.objectRecordEventsWithQueryIds ?? [];
|
||||
|
||||
const metadataEventsWithQueryIds =
|
||||
eventSubscription?.metadataEventsWithQueryIds ?? [];
|
||||
|
||||
const objectRecordEvents = objectRecordEventsWithQueryIds.map(
|
||||
(item) => item.objectRecordEvent,
|
||||
);
|
||||
|
||||
triggerOptimisticEffectFromSseEvents({
|
||||
objectRecordEvents,
|
||||
});
|
||||
|
||||
dispatchObjectRecordEventsFromSseToBrowserEvents(
|
||||
objectRecordEventsWithQueryIds,
|
||||
);
|
||||
|
||||
dispatchMetadataEventsFromSseToBrowserEvents(
|
||||
metadataEventsWithQueryIds,
|
||||
);
|
||||
},
|
||||
error: (error) => {
|
||||
captureException(error);
|
||||
},
|
||||
complete: () => {},
|
||||
error: () => {},
|
||||
next: () => {},
|
||||
},
|
||||
{
|
||||
message: ({ data, event }) => {
|
||||
@@ -109,12 +159,12 @@ export const useTriggerEventStreamCreation = () => {
|
||||
|
||||
const objectRecordEventsWithQueryIds =
|
||||
result?.data?.onEventSubscription
|
||||
?.eventWithQueryIdsList ?? [];
|
||||
?.objectRecordEventsWithQueryIds ?? [];
|
||||
|
||||
const objectRecordEvents =
|
||||
objectRecordEventsWithQueryIds.map(
|
||||
(eventWithQueryIds) => {
|
||||
return eventWithQueryIds.event;
|
||||
(objectRecordEventWithQueryIds) => {
|
||||
return objectRecordEventWithQueryIds.objectRecordEvent;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -144,9 +194,9 @@ export const useTriggerEventStreamCreation = () => {
|
||||
setIsCreatingSseEventStream(false);
|
||||
},
|
||||
[
|
||||
dispatchMetadataEventsFromSseToBrowserEvents,
|
||||
dispatchObjectRecordEventsFromSseToBrowserEvents,
|
||||
setIsCreatingSseEventStream,
|
||||
|
||||
triggerOptimisticEffectFromSseEvents,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
|
||||
import { createState } from '@/ui/utilities/state/utils/createState';
|
||||
import {
|
||||
type MetadataGqlOperationSignature,
|
||||
type RecordGqlOperationSignature,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export const activeQueryListenersState = createState<
|
||||
{ queryId: string; operationSignature: RecordGqlOperationSignature }[]
|
||||
{
|
||||
queryId: string;
|
||||
operationSignature:
|
||||
| RecordGqlOperationSignature
|
||||
| MetadataGqlOperationSignature;
|
||||
}[]
|
||||
>({
|
||||
key: 'activeQueryListenersState',
|
||||
defaultValue: [],
|
||||
|
||||
+10
-2
@@ -1,8 +1,16 @@
|
||||
import { type RecordGqlOperationSignature } from 'twenty-shared/types';
|
||||
import { createState } from '@/ui/utilities/state/utils/createState';
|
||||
import {
|
||||
type MetadataGqlOperationSignature,
|
||||
type RecordGqlOperationSignature,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export const requiredQueryListenersState = createState<
|
||||
{ queryId: string; operationSignature: RecordGqlOperationSignature }[]
|
||||
{
|
||||
queryId: string;
|
||||
operationSignature:
|
||||
| RecordGqlOperationSignature
|
||||
| MetadataGqlOperationSignature;
|
||||
}[]
|
||||
>({
|
||||
key: 'requiredQueryListenersState',
|
||||
defaultValue: [],
|
||||
|
||||
+5
-7
@@ -1,22 +1,20 @@
|
||||
import { type ObjectRecordEventsByQueryId } from '@/sse-db-event/types/ObjectRecordEventsByQueryId';
|
||||
import { getObjectRecordEventsForQueryEventName } from '@/sse-db-event/utils/getObjectRecordEventsForQueryEventName';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type EventWithQueryIds } from '~/generated-metadata/graphql';
|
||||
import { type ObjectRecordEventWithQueryIds } from '~/generated-metadata/graphql';
|
||||
|
||||
export const dispatchObjectRecordEventsWithQueryIds = (
|
||||
objectRecordEventsWithQueryIds: EventWithQueryIds[],
|
||||
objectRecordEventsWithQueryIds: ObjectRecordEventWithQueryIds[],
|
||||
) => {
|
||||
const objectRecordEventsByQueryId: ObjectRecordEventsByQueryId = {};
|
||||
|
||||
for (const objectRecordEventWithQueryIds of objectRecordEventsWithQueryIds) {
|
||||
for (const queryId of objectRecordEventWithQueryIds.queryIds) {
|
||||
for (const item of objectRecordEventsWithQueryIds) {
|
||||
for (const queryId of item.queryIds) {
|
||||
if (!isDefined(objectRecordEventsByQueryId[queryId])) {
|
||||
objectRecordEventsByQueryId[queryId] = [];
|
||||
}
|
||||
|
||||
objectRecordEventsByQueryId[queryId].push(
|
||||
objectRecordEventWithQueryIds.event,
|
||||
);
|
||||
objectRecordEventsByQueryId[queryId].push(item.objectRecordEvent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
MetadataEventAction,
|
||||
type AllMetadataName,
|
||||
type MetadataEvent,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const turnSseMetadataEventsToMetadataOperationBrowserEvents = <
|
||||
T extends Record<string, unknown>,
|
||||
>({
|
||||
metadataName,
|
||||
sseMetadataEvents,
|
||||
}: {
|
||||
metadataName: AllMetadataName;
|
||||
sseMetadataEvents: MetadataEvent[];
|
||||
}): MetadataOperationBrowserEventDetail<T>[] => {
|
||||
return sseMetadataEvents
|
||||
.map((event): MetadataOperationBrowserEventDetail<T> | null => {
|
||||
switch (event.type) {
|
||||
case MetadataEventAction.CREATED: {
|
||||
const createdRecord = event.properties.after;
|
||||
|
||||
if (!isDefined(createdRecord)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
metadataName,
|
||||
operation: {
|
||||
type: 'create',
|
||||
createdRecord,
|
||||
},
|
||||
};
|
||||
}
|
||||
case MetadataEventAction.UPDATED: {
|
||||
const updatedRecord = event.properties.after;
|
||||
|
||||
if (!isDefined(updatedRecord)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
metadataName,
|
||||
operation: {
|
||||
type: 'update',
|
||||
updatedRecord,
|
||||
updatedFields: event.properties.updatedFields ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
case MetadataEventAction.DELETED: {
|
||||
return {
|
||||
metadataName,
|
||||
operation: {
|
||||
type: 'delete',
|
||||
deletedRecordId: event.recordId,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(isDefined);
|
||||
};
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/object-record/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { getObjectRecordOperationUpdateInputs } from '@/sse-db-event/utils/getObjectRecordOperationUpdateInputs';
|
||||
import { groupObjectRecordSseEventsByEventType } from '@/sse-db-event/utils/groupObjectRecordSseEventsByEventType';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { useId, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { AppTooltip, TooltipDelay, TooltipPosition } from 'twenty-ui/display';
|
||||
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
|
||||
@@ -22,10 +25,14 @@ export const CurrencyDisplay = ({
|
||||
fieldDefinition,
|
||||
}: CurrencyDisplayProps) => {
|
||||
const theme = useTheme();
|
||||
const instanceId = useId();
|
||||
const [shouldRenderTooltip, setShouldRenderTooltip] = useState(false);
|
||||
|
||||
const CurrencyIcon = isDefined(currencyValue?.currencyCode)
|
||||
? SETTINGS_FIELD_CURRENCY_CODES[currencyValue?.currencyCode]?.Icon
|
||||
const currencyCode = currencyValue?.currencyCode;
|
||||
const currencyMetadata = isDefined(currencyCode)
|
||||
? SETTINGS_FIELD_CURRENCY_CODES[currencyCode]
|
||||
: null;
|
||||
const CurrencyIcon = currencyMetadata?.Icon ?? null;
|
||||
|
||||
const amountToDisplay = isUndefinedOrNull(currencyValue?.amountMicros)
|
||||
? null
|
||||
@@ -36,23 +43,51 @@ export const CurrencyDisplay = ({
|
||||
const decimalsToUse = decimals ?? DEFAULT_DECIMAL_VALUE;
|
||||
|
||||
const { formatNumber } = useNumberFormat();
|
||||
const tooltipAnchorId = `currency-icon-${instanceId.replace(/[^a-zA-Z0-9-_]/g, '-')}`;
|
||||
const currencyTooltipContent = isDefined(currencyCode)
|
||||
? `${currencyCode}${currencyMetadata?.label ? ` - ${currencyMetadata.label}` : ''}`
|
||||
: undefined;
|
||||
const shouldShowCurrencyTooltip =
|
||||
isDefined(CurrencyIcon) &&
|
||||
amountToDisplay !== null &&
|
||||
isDefined(currencyTooltipContent);
|
||||
|
||||
return (
|
||||
<EllipsisDisplay>
|
||||
{isDefined(CurrencyIcon) && amountToDisplay !== null && (
|
||||
<>
|
||||
<CurrencyIcon
|
||||
color={theme.font.color.primary}
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>{' '}
|
||||
</>
|
||||
)}
|
||||
{amountToDisplay !== null
|
||||
? !isDefined(format) || format === 'short'
|
||||
? formatToShortNumber(amountToDisplay)
|
||||
: formatNumber(amountToDisplay, { decimals: decimalsToUse })
|
||||
: null}
|
||||
</EllipsisDisplay>
|
||||
<>
|
||||
<EllipsisDisplay>
|
||||
{shouldShowCurrencyTooltip && (
|
||||
<>
|
||||
<span
|
||||
id={tooltipAnchorId}
|
||||
onMouseEnter={() => setShouldRenderTooltip(true)}
|
||||
onMouseLeave={() => setShouldRenderTooltip(false)}
|
||||
>
|
||||
<CurrencyIcon
|
||||
color={theme.font.color.primary}
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</span>{' '}
|
||||
</>
|
||||
)}
|
||||
{amountToDisplay !== null
|
||||
? !isDefined(format) || format === 'short'
|
||||
? formatToShortNumber(amountToDisplay)
|
||||
: formatNumber(amountToDisplay, { decimals: decimalsToUse })
|
||||
: null}
|
||||
</EllipsisDisplay>
|
||||
{shouldRenderTooltip &&
|
||||
shouldShowCurrencyTooltip &&
|
||||
createPortal(
|
||||
<AppTooltip
|
||||
anchorSelect={`#${tooltipAnchorId}`}
|
||||
content={currencyTooltipContent}
|
||||
delay={TooltipDelay.shortDelay}
|
||||
place={TooltipPosition.Top}
|
||||
positionStrategy="fixed"
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/object-record/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { useStopWorkflowRunMutation } from '~/generated/graphql';
|
||||
|
||||
export const useStopWorkflowRun = () => {
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
|
||||
export const WorkflowRunSSESubscribeEffect = ({
|
||||
workflowRunId,
|
||||
@@ -8,7 +8,7 @@ export const WorkflowRunSSESubscribeEffect = ({
|
||||
}) => {
|
||||
const queryId = `workflow-run-${workflowRunId}`;
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
|
||||
|
||||
+3
-3
@@ -3,8 +3,8 @@ import { useSetRecoilState } from 'recoil';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/object-record/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useListenToObjectRecordEventsForQuery } from '@/sse-db-event/hooks/useListenToObjectRecordEventsForQuery';
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
|
||||
import { shouldWorkflowRefetchRequestFamilyState } from '@/workflow/states/shouldWorkflowRefetchRequestFamilyState';
|
||||
|
||||
export const WorkflowSSESubscribeEffect = ({
|
||||
@@ -23,7 +23,7 @@ export const WorkflowSSESubscribeEffect = ({
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
|
||||
});
|
||||
|
||||
useListenToObjectRecordEventsForQuery({
|
||||
useListenToEventsForQuery({
|
||||
queryId,
|
||||
operationSignature: {
|
||||
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
|
||||
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
|
||||
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
const StyledMessageContainer = styled.div`
|
||||
padding-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
padding-inline: ${({ theme }) => theme.spacing(7)};
|
||||
padding-top: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledMessageContentContainer = styled.div`
|
||||
flex-direction: column;
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(4)};
|
||||
width: 100%;
|
||||
padding: ${({ theme }) => theme.spacing(4)};
|
||||
line-height: normal;
|
||||
`;
|
||||
|
||||
const StyledMessageTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
line-height: 13px;
|
||||
`;
|
||||
|
||||
const StyledMessageDescription = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
`;
|
||||
|
||||
const StyledFieldContainer = styled.div`
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const WorkflowMessage = ({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
}) => {
|
||||
return (
|
||||
<StyledMessageContainer>
|
||||
<FormFieldInputContainer>
|
||||
<FormFieldInputRowContainer multiline maxHeight={145}>
|
||||
<FormFieldInputInnerContainer
|
||||
formFieldInputInstanceId="workflow-message"
|
||||
hasRightElement={false}
|
||||
>
|
||||
<StyledFieldContainer>
|
||||
<StyledMessageContentContainer>
|
||||
<StyledMessageTitle data-testid="workflow-message-title">
|
||||
{title}
|
||||
</StyledMessageTitle>
|
||||
<StyledMessageDescription data-testid="workflow-message-description">
|
||||
{description}
|
||||
</StyledMessageDescription>
|
||||
</StyledMessageContentContainer>
|
||||
</StyledFieldContainer>
|
||||
</FormFieldInputInnerContainer>
|
||||
</FormFieldInputRowContainer>
|
||||
</FormFieldInputContainer>
|
||||
</StyledMessageContainer>
|
||||
);
|
||||
};
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { WorkflowMessage } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowMessage';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
|
||||
const meta: Meta<typeof WorkflowMessage> = {
|
||||
title: 'Modules/Workflow/Actions/Form/WorkflowMessage',
|
||||
component: WorkflowMessage,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
decorators: [],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkflowMessage>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const messageTitleContainer = await canvas.findByTestId(
|
||||
'workflow-message-title',
|
||||
);
|
||||
const messageDescriptionContainer = await canvas.findByTestId(
|
||||
'workflow-message-description',
|
||||
);
|
||||
|
||||
expect(messageTitleContainer).toBeVisible();
|
||||
expect(messageDescriptionContainer).toBeVisible();
|
||||
},
|
||||
};
|
||||
+45
-23
@@ -11,7 +11,6 @@ import {
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
|
||||
import { WorkflowMessage } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowMessage';
|
||||
import { WorkflowEditActionFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings';
|
||||
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
|
||||
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
|
||||
@@ -25,6 +24,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
Callout,
|
||||
IconAlertTriangle,
|
||||
IconChevronDown,
|
||||
IconGripVertical,
|
||||
IconPlus,
|
||||
@@ -51,7 +51,8 @@ type FormData = WorkflowFormActionField[];
|
||||
|
||||
const StyledWorkflowStepBody = styled(WorkflowStepBody)`
|
||||
display: block;
|
||||
padding-inline: ${({ theme }) => theme.spacing(2)};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledFormFieldContainer = styled.div`
|
||||
@@ -98,7 +99,8 @@ const StyledFieldContainer = styled.div<{
|
||||
border: none;
|
||||
display: flex;
|
||||
font-family: inherit;
|
||||
padding-inline: ${({ theme }) => theme.spacing(2)};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
padding-right: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100%;
|
||||
|
||||
cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')};
|
||||
@@ -118,7 +120,8 @@ const StyledPlaceholder = styled(FormFieldPlaceholder)`
|
||||
`;
|
||||
|
||||
const StyledAddFieldButtonContainer = styled.div`
|
||||
padding-inline: ${({ theme }) => theme.spacing(7)};
|
||||
padding-left: ${({ theme }) => theme.spacing(7)};
|
||||
padding-right: ${({ theme }) => theme.spacing(7)};
|
||||
padding-top: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
@@ -132,6 +135,17 @@ const StyledAddFieldButtonContentContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledCalloutContainer = styled.div`
|
||||
padding-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
padding-left: ${({ theme }) => theme.spacing(7)};
|
||||
padding-right: ${({ theme }) => theme.spacing(7)};
|
||||
padding-top: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledNotClosableCalloutContainer = styled(StyledCalloutContainer)`
|
||||
padding-bottom: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
export const WorkflowEditActionFormBuilder = ({
|
||||
triggerType,
|
||||
action,
|
||||
@@ -221,27 +235,35 @@ export const WorkflowEditActionFormBuilder = ({
|
||||
<>
|
||||
<StyledWorkflowStepBody>
|
||||
{triggerType && triggerType !== 'MANUAL' && isCalloutVisible && (
|
||||
<Callout
|
||||
variant={'warning'}
|
||||
title={t`This form will appear in workflow runs.`}
|
||||
description={t`Because this workflow is not using a manual trigger, the form will not open on top of the interface. To fill it, open the corresponding workflow run and complete the form there.`}
|
||||
onClose={() => setIsCalloutVisible(false)}
|
||||
action={{
|
||||
label: t`Learn more`,
|
||||
onClick: () =>
|
||||
window.open(
|
||||
'https://docs.twenty.com/user-guide/workflows/capabilities/workflow-actions#form',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<StyledCalloutContainer>
|
||||
<Callout
|
||||
variant={'warning'}
|
||||
Icon={IconAlertTriangle}
|
||||
title={t`This form will appear in workflow runs.`}
|
||||
description={t`Because this workflow is not using a manual trigger, the form will not open on top of the interface. To fill it, open the corresponding workflow run and complete the form there.`}
|
||||
isClosable
|
||||
onClose={() => setIsCalloutVisible(false)}
|
||||
action={{
|
||||
label: t`Learn more`,
|
||||
onClick: () =>
|
||||
window.open(
|
||||
'https://docs.twenty.com/user-guide/workflows/capabilities/workflow-actions#form',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</StyledCalloutContainer>
|
||||
)}
|
||||
{formData.length === 0 && (
|
||||
<WorkflowMessage
|
||||
title={t`Add inputs to your form`}
|
||||
description={t`Click on "Add Field" below to add the first input to your form. The form will pop up on the user's screen when the workflow is launched from a manual trigger. For other types of triggers, it will be displayed in the Workflow run record page.`}
|
||||
/>
|
||||
<StyledNotClosableCalloutContainer>
|
||||
<Callout
|
||||
variant={'neutral'}
|
||||
isClosable={false}
|
||||
title={t`Add inputs to your form`}
|
||||
description={t`Click on "Add Field" below to add the first input to your form. The form will pop up on the user's screen when the workflow is launched from a manual trigger. For other types of triggers, it will be displayed in the Workflow run record page.`}
|
||||
/>
|
||||
</StyledNotClosableCalloutContainer>
|
||||
)}
|
||||
<DraggableList
|
||||
onDragEnd={handleDragEnd}
|
||||
|
||||
+1
-3
@@ -179,9 +179,7 @@ export const EmptyForm: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const messageContainer = await canvas.findByTestId(
|
||||
'workflow-message-title',
|
||||
);
|
||||
const messageContainer = await canvas.findByText('Add inputs to your form');
|
||||
|
||||
expect(messageContainer).toBeVisible();
|
||||
|
||||
|
||||
+3
-2
@@ -6,13 +6,13 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
|
||||
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
|
||||
import { WorkflowEditActionCodeFields } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionCodeFields';
|
||||
import { setNestedValue } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/setNestedValue';
|
||||
import { WorkflowMessage } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowMessage';
|
||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isObject } from '@sniptt/guards';
|
||||
import { useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Callout } from 'twenty-ui/display';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@@ -118,7 +118,8 @@ export const WorkflowEditActionLogicFunction = ({
|
||||
fullWidth
|
||||
/>
|
||||
) : (
|
||||
<WorkflowMessage
|
||||
<Callout
|
||||
variant={'neutral'}
|
||||
title={t`No input fields for this action`}
|
||||
description={t`You can see the function logic in your application settings.`}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -148,8 +151,9 @@ Application development commands.
|
||||
|
||||
- `twenty function:execute [appPath]` — Execute a logic function with a JSON payload.
|
||||
- Options:
|
||||
- `-n, --functionName <name>`: Name of the function to execute (required if `-u` not provided).
|
||||
- `-u, --functionUniversalIdentifier <id>`: Universal ID of the function to execute (required if `-n` not provided).
|
||||
- `--postInstall`: Execute the post-install logic function defined in the application config (required if `-n` and `-u` not provided).
|
||||
- `-n, --functionName <name>`: Name of the function to execute (required if `--postInstall` and `-u` not provided).
|
||||
- `-u, --functionUniversalIdentifier <id>`: Universal ID of the function to execute (required if `--postInstall` and `-n` not provided).
|
||||
- `-p, --payload <payload>`: JSON payload to send to the function (default: `{}`).
|
||||
|
||||
Examples:
|
||||
@@ -170,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
|
||||
|
||||
@@ -187,6 +197,9 @@ twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Execute a function by universal identifier
|
||||
twenty function:execute -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf -p '{"key": "value"}'
|
||||
|
||||
# Execute the post-install function
|
||||
twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-sdk",
|
||||
"version": "0.6.0-alpha",
|
||||
"version": "0.6.0",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/sdk/index.d.ts",
|
||||
@@ -63,7 +63,6 @@
|
||||
"commander": "^12.0.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-svelte": "^0.9.4",
|
||||
"fast-glob": "^3.3.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"graphql": "^16.8.1",
|
||||
@@ -96,7 +95,6 @@
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"playwright": "^1.56.1",
|
||||
"storybook": "^10.1.11",
|
||||
"svelte": "^4.2.19",
|
||||
"ts-morph": "^25.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"twenty-shared": "workspace:*",
|
||||
|
||||
@@ -54,21 +54,9 @@ const twentySharedAliases = Object.fromEntries(
|
||||
]),
|
||||
);
|
||||
|
||||
const svelteRoot = path.join(rootNodeModules, 'svelte');
|
||||
|
||||
const storyAlias = {
|
||||
react: path.join(rootNodeModules, 'react'),
|
||||
'react-dom': path.join(rootNodeModules, 'react-dom'),
|
||||
vue: path.join(rootNodeModules, 'vue'),
|
||||
svelte: svelteRoot,
|
||||
'svelte/internal': path.join(
|
||||
svelteRoot,
|
||||
'src/runtime/internal/index.js',
|
||||
),
|
||||
'svelte/internal/disclose-version': path.join(
|
||||
svelteRoot,
|
||||
'src/runtime/internal/disclose-version/index.js',
|
||||
),
|
||||
'@/sdk': sdkIndividualIndex,
|
||||
'twenty-sdk/ui': twentyUiIndividualIndex,
|
||||
...twentySharedAliases,
|
||||
@@ -86,8 +74,6 @@ const STORY_COMPONENTS = [
|
||||
'mui-example.front-component',
|
||||
'twenty-ui-example.front-component',
|
||||
'sdk-context-example.front-component',
|
||||
'vue-example.front-component',
|
||||
'svelte-example.front-component',
|
||||
];
|
||||
|
||||
const resolveEntryPoints = (): Record<string, string> => {
|
||||
|
||||
+390
-3
@@ -1,9 +1,15 @@
|
||||
import { FieldType } from '@/sdk';
|
||||
import type { Manifest } from 'twenty-shared/application';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import {
|
||||
FieldMetadataType,
|
||||
RelationOnDeleteAction,
|
||||
RelationType,
|
||||
ViewType,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
export const EXPECTED_MANIFEST: Manifest = {
|
||||
pageLayouts: [],
|
||||
publicAssets: [
|
||||
{
|
||||
checksum: '99496069dcc2a1488e1cae9f826d2707',
|
||||
@@ -28,6 +34,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
|
||||
packageJsonChecksum: '2851d0e2c3621a57e1fd103a245b6fde',
|
||||
apiClientChecksum: null,
|
||||
},
|
||||
frontComponents: [
|
||||
{
|
||||
@@ -69,6 +76,50 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
],
|
||||
|
||||
fields: [
|
||||
{
|
||||
label: 'Post Card',
|
||||
name: 'postCard',
|
||||
objectUniversalIdentifier: 'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
'a1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
'54b589ca-eeed-4950-a176-358418b85c05',
|
||||
type: FieldType.RELATION,
|
||||
universalIdentifier: 'a1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
universalSettings: {
|
||||
joinColumnName: 'postCardId',
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Post Card Recipients',
|
||||
name: 'postCardRecipients',
|
||||
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
'a1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
|
||||
type: FieldType.RELATION,
|
||||
universalIdentifier: 'a1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Post Card Recipients',
|
||||
name: 'postCardRecipients',
|
||||
objectUniversalIdentifier: 'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
'a1a2b3c4-0004-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
|
||||
type: FieldType.RELATION,
|
||||
universalIdentifier: 'a1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
description: 'Post card category',
|
||||
@@ -77,18 +128,21 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
options: [
|
||||
{
|
||||
color: 'blue',
|
||||
id: 'c1d2e3f4-0001-4000-8000-000000000001',
|
||||
label: 'Personal',
|
||||
position: 0,
|
||||
value: 'PERSONAL',
|
||||
},
|
||||
{
|
||||
color: 'green',
|
||||
id: 'c1d2e3f4-0002-4000-8000-000000000002',
|
||||
label: 'Business',
|
||||
position: 1,
|
||||
value: 'BUSINESS',
|
||||
},
|
||||
{
|
||||
color: 'orange',
|
||||
id: 'c1d2e3f4-0003-4000-8000-000000000003',
|
||||
label: 'Promotional',
|
||||
position: 2,
|
||||
value: 'PROMOTIONAL',
|
||||
@@ -105,6 +159,22 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
type: FieldType.NUMBER,
|
||||
universalIdentifier: '7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d',
|
||||
},
|
||||
{
|
||||
label: 'Recipient',
|
||||
name: 'recipient',
|
||||
objectUniversalIdentifier: 'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
'a1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
type: FieldType.RELATION,
|
||||
universalIdentifier: 'a1a2b3c4-0004-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
universalSettings: {
|
||||
joinColumnName: 'recipientId',
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
},
|
||||
},
|
||||
],
|
||||
objects: [
|
||||
{
|
||||
@@ -208,6 +278,104 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
nameSingular: 'rootNote',
|
||||
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000001',
|
||||
},
|
||||
{
|
||||
description: 'Junction object linking post cards to their recipients',
|
||||
fields: [
|
||||
{
|
||||
defaultValue: null,
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
label: 'Sent at',
|
||||
name: 'sentAt',
|
||||
type: FieldType.DATE_TIME,
|
||||
universalIdentifier: 'e2a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
|
||||
},
|
||||
{
|
||||
defaultValue: 'uuid',
|
||||
description: 'Id',
|
||||
icon: 'Icon123',
|
||||
isNullable: false,
|
||||
label: 'Id',
|
||||
name: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
universalIdentifier: '2809d745-deb3-5ac0-8328-ea6256ec22b3',
|
||||
},
|
||||
{
|
||||
defaultValue: null,
|
||||
description: 'Name',
|
||||
icon: 'IconAbc',
|
||||
isNullable: true,
|
||||
label: 'Name',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
universalIdentifier: 'a6ee6cc8-b9a0-5e23-a39e-bc3fee2aa3d3',
|
||||
},
|
||||
{
|
||||
defaultValue: 'now',
|
||||
description: 'Creation date',
|
||||
icon: 'IconCalendar',
|
||||
isNullable: false,
|
||||
label: 'Creation date',
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
universalIdentifier: '87ad23b6-b04e-5dc1-9907-c1eca54ea6d6',
|
||||
},
|
||||
{
|
||||
defaultValue: 'now',
|
||||
description: 'Last time the record was changed',
|
||||
icon: 'IconCalendarClock',
|
||||
isNullable: false,
|
||||
label: 'Last update',
|
||||
name: 'updatedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
universalIdentifier: '6d7523dc-c097-5b75-96eb-37502d09cc19',
|
||||
},
|
||||
{
|
||||
defaultValue: null,
|
||||
description: 'Deletion date',
|
||||
icon: 'IconCalendarClock',
|
||||
isNullable: true,
|
||||
label: 'Deleted at',
|
||||
name: 'deletedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
universalIdentifier: 'bd4fe2fa-373a-5fdd-ba09-cb5435781e4c',
|
||||
},
|
||||
{
|
||||
defaultValue: {
|
||||
name: "''",
|
||||
source: "'MANUAL'",
|
||||
},
|
||||
description: 'The creator of the record',
|
||||
icon: 'IconCreativeCommonsSa',
|
||||
isNullable: false,
|
||||
label: 'Created by',
|
||||
name: 'createdBy',
|
||||
type: FieldMetadataType.ACTOR,
|
||||
universalIdentifier: 'c5026f20-ab7a-54e0-900e-37a2d5381aee',
|
||||
},
|
||||
{
|
||||
defaultValue: {
|
||||
name: "''",
|
||||
source: "'MANUAL'",
|
||||
},
|
||||
description: 'The workspace member who last updated the record',
|
||||
icon: 'IconUserCircle',
|
||||
isNullable: false,
|
||||
label: 'Updated by',
|
||||
name: 'updatedBy',
|
||||
type: FieldMetadataType.ACTOR,
|
||||
universalIdentifier: '1dd75ff5-5fd0-51df-b3a3-a12e3aff30df',
|
||||
},
|
||||
],
|
||||
icon: 'IconLink',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
'a6ee6cc8-b9a0-5e23-a39e-bc3fee2aa3d3',
|
||||
labelPlural: 'Post Card Recipients',
|
||||
labelSingular: 'Post Card Recipient',
|
||||
namePlural: 'postCardRecipients',
|
||||
nameSingular: 'postCardRecipient',
|
||||
universalIdentifier: 'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
|
||||
},
|
||||
{
|
||||
description: 'A post card object',
|
||||
fields: [
|
||||
@@ -241,24 +409,28 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
options: [
|
||||
{
|
||||
color: 'gray',
|
||||
id: 'a1b2c3d4-0001-4000-8000-000000000001',
|
||||
label: 'Draft',
|
||||
position: 0,
|
||||
value: 'DRAFT',
|
||||
},
|
||||
{
|
||||
color: 'orange',
|
||||
id: 'a1b2c3d4-0002-4000-8000-000000000002',
|
||||
label: 'Sent',
|
||||
position: 1,
|
||||
value: 'SENT',
|
||||
},
|
||||
{
|
||||
color: 'green',
|
||||
id: 'a1b2c3d4-0003-4000-8000-000000000003',
|
||||
label: 'Delivered',
|
||||
position: 2,
|
||||
value: 'DELIVERED',
|
||||
},
|
||||
{
|
||||
color: 'orange',
|
||||
id: 'a1b2c3d4-0004-4000-8000-000000000004',
|
||||
label: 'Returned',
|
||||
position: 3,
|
||||
value: 'RETURNED',
|
||||
@@ -362,6 +534,109 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
nameSingular: 'postCard',
|
||||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
},
|
||||
{
|
||||
description: 'A person or organization that receives post cards',
|
||||
fields: [
|
||||
{
|
||||
icon: 'IconMail',
|
||||
label: 'Email',
|
||||
name: 'email',
|
||||
type: FieldType.EMAILS,
|
||||
universalIdentifier: 'd2a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
{
|
||||
icon: 'IconHome',
|
||||
label: 'Address',
|
||||
name: 'address',
|
||||
type: FieldType.ADDRESS,
|
||||
universalIdentifier: 'd3a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
{
|
||||
defaultValue: 'uuid',
|
||||
description: 'Id',
|
||||
icon: 'Icon123',
|
||||
isNullable: false,
|
||||
label: 'Id',
|
||||
name: 'id',
|
||||
type: FieldMetadataType.UUID,
|
||||
universalIdentifier: '71aa41d7-54d0-5447-bf98-f37435b62bc4',
|
||||
},
|
||||
{
|
||||
defaultValue: null,
|
||||
description: 'Name',
|
||||
icon: 'IconAbc',
|
||||
isNullable: true,
|
||||
label: 'Name',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
universalIdentifier: 'ff07c707-df79-58fa-91a4-c60c31f90e6e',
|
||||
},
|
||||
{
|
||||
defaultValue: 'now',
|
||||
description: 'Creation date',
|
||||
icon: 'IconCalendar',
|
||||
isNullable: false,
|
||||
label: 'Creation date',
|
||||
name: 'createdAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
universalIdentifier: '8dd42a57-7aca-5e60-89c1-6e0e430dad43',
|
||||
},
|
||||
{
|
||||
defaultValue: 'now',
|
||||
description: 'Last time the record was changed',
|
||||
icon: 'IconCalendarClock',
|
||||
isNullable: false,
|
||||
label: 'Last update',
|
||||
name: 'updatedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
universalIdentifier: 'a81e915c-f21d-5163-a547-f04029655bf0',
|
||||
},
|
||||
{
|
||||
defaultValue: null,
|
||||
description: 'Deletion date',
|
||||
icon: 'IconCalendarClock',
|
||||
isNullable: true,
|
||||
label: 'Deleted at',
|
||||
name: 'deletedAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
universalIdentifier: '33ba5fa8-dbc3-5a08-b0e0-d85b18ff15e0',
|
||||
},
|
||||
{
|
||||
defaultValue: {
|
||||
name: "''",
|
||||
source: "'MANUAL'",
|
||||
},
|
||||
description: 'The creator of the record',
|
||||
icon: 'IconCreativeCommonsSa',
|
||||
isNullable: false,
|
||||
label: 'Created by',
|
||||
name: 'createdBy',
|
||||
type: FieldMetadataType.ACTOR,
|
||||
universalIdentifier: '7c377262-63b7-5a79-ba54-f7b8acd804dc',
|
||||
},
|
||||
{
|
||||
defaultValue: {
|
||||
name: "''",
|
||||
source: "'MANUAL'",
|
||||
},
|
||||
description: 'The workspace member who last updated the record',
|
||||
icon: 'IconUserCircle',
|
||||
isNullable: false,
|
||||
label: 'Updated by',
|
||||
name: 'updatedBy',
|
||||
type: FieldMetadataType.ACTOR,
|
||||
universalIdentifier: '8da8e149-b60e-52e2-878d-73d05c8312b2',
|
||||
},
|
||||
],
|
||||
icon: 'IconUser',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
'ff07c707-df79-58fa-91a4-c60c31f90e6e',
|
||||
labelPlural: 'Recipients',
|
||||
labelSingular: 'Recipient',
|
||||
namePlural: 'recipients',
|
||||
nameSingular: 'recipient',
|
||||
universalIdentifier: 'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
],
|
||||
roles: [
|
||||
{
|
||||
@@ -409,8 +684,87 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
},
|
||||
],
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
views: [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
fieldMetadataUniversalIdentifier:
|
||||
'e2a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
size: 200,
|
||||
universalIdentifier: 'bf1a2b3c-0004-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
],
|
||||
icon: 'IconLink',
|
||||
name: 'All Post Card Recipients',
|
||||
objectUniversalIdentifier: 'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e',
|
||||
position: 2,
|
||||
type: ViewType.TABLE,
|
||||
universalIdentifier: 'b1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
fieldMetadataUniversalIdentifier:
|
||||
'58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
size: 200,
|
||||
universalIdentifier: 'bf1a2b3c-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
{
|
||||
fieldMetadataUniversalIdentifier:
|
||||
'87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||||
isVisible: true,
|
||||
position: 1,
|
||||
size: 150,
|
||||
universalIdentifier: 'bf1a2b3c-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
],
|
||||
icon: 'IconMail',
|
||||
name: 'All Post Cards',
|
||||
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
position: 0,
|
||||
type: ViewType.TABLE,
|
||||
universalIdentifier: 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
fieldMetadataUniversalIdentifier:
|
||||
'd2a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
isVisible: true,
|
||||
position: 0,
|
||||
size: 200,
|
||||
universalIdentifier: 'bf1a2b3c-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
],
|
||||
icon: 'IconUser',
|
||||
name: 'All Recipients',
|
||||
objectUniversalIdentifier: 'd1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
position: 1,
|
||||
type: ViewType.TABLE,
|
||||
universalIdentifier: 'b1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
],
|
||||
navigationMenuItems: [
|
||||
{
|
||||
position: 2,
|
||||
universalIdentifier: 'c1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
viewUniversalIdentifier: 'b1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
{
|
||||
position: 0,
|
||||
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
viewUniversalIdentifier: 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
{
|
||||
position: 1,
|
||||
universalIdentifier: 'c1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
viewUniversalIdentifier: 'b1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
],
|
||||
logicFunctions: [
|
||||
{
|
||||
builtHandlerChecksum: '[checksum]',
|
||||
@@ -442,6 +796,39 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
},
|
||||
universalIdentifier: '9d412d9e-2caf-487c-8b66-d1585883dd4e',
|
||||
},
|
||||
{
|
||||
builtHandlerChecksum: '[checksum]',
|
||||
builtHandlerPath: 'src/logic-functions/lookup-recipient.function.mjs',
|
||||
description: 'Look up a recipient by name to find their details',
|
||||
handlerName: 'default.config.handler',
|
||||
isTool: true,
|
||||
name: 'lookup-recipient',
|
||||
sourceHandlerPath: 'src/logic-functions/lookup-recipient.function.ts',
|
||||
timeoutSeconds: 5,
|
||||
toolInputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
recipientName: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
universalIdentifier: 'a1b2c3d4-1001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
{
|
||||
builtHandlerChecksum: '[checksum]',
|
||||
builtHandlerPath: 'src/logic-functions/on-post-card-created.function.mjs',
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'postCard.created',
|
||||
},
|
||||
description: 'Triggered when a new post card is created',
|
||||
handlerName: 'default.config.handler',
|
||||
name: 'on-post-card-created',
|
||||
sourceHandlerPath: 'src/logic-functions/on-post-card-created.function.ts',
|
||||
timeoutSeconds: 5,
|
||||
toolInputSchema: { type: 'object', properties: {} },
|
||||
universalIdentifier: 'a1b2c3d4-db01-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
},
|
||||
{
|
||||
builtHandlerChecksum: '[checksum]',
|
||||
builtHandlerPath: 'src/logic-functions/test-function-2.function.mjs',
|
||||
|
||||
+4
@@ -25,6 +25,10 @@ export const defineEntitiesTests = (appPath: string): void => {
|
||||
'src/logic-functions',
|
||||
'src/logic-functions/greeting.function.mjs',
|
||||
'src/logic-functions/greeting.function.mjs.map',
|
||||
'src/logic-functions/lookup-recipient.function.mjs',
|
||||
'src/logic-functions/lookup-recipient.function.mjs.map',
|
||||
'src/logic-functions/on-post-card-created.function.mjs',
|
||||
'src/logic-functions/on-post-card-created.function.mjs.map',
|
||||
'src/logic-functions/test-function-2.function.mjs',
|
||||
'src/logic-functions/test-function-2.function.mjs.map',
|
||||
'src/logic-functions/test-function.function.mjs',
|
||||
|
||||
+5
-3
@@ -42,11 +42,13 @@ export const defineManifestTests = (appPath: string): void => {
|
||||
it('should load all entity types', async () => {
|
||||
const manifest = await fs.readJson(manifestOutputPath);
|
||||
|
||||
expect(manifest.objects).toHaveLength(2);
|
||||
expect(manifest.logicFunctions).toHaveLength(4);
|
||||
expect(manifest.objects).toHaveLength(4);
|
||||
expect(manifest.logicFunctions).toHaveLength(6);
|
||||
expect(manifest.frontComponents).toHaveLength(4);
|
||||
expect(manifest.roles).toHaveLength(2);
|
||||
expect(manifest.fields).toHaveLength(2);
|
||||
expect(manifest.fields).toHaveLength(6);
|
||||
expect(manifest.views).toHaveLength(3);
|
||||
expect(manifest.navigationMenuItems).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
POST_CARD_ON_POST_CARD_RECIPIENT_ID,
|
||||
POST_CARD_RECIPIENTS_ON_POST_CARD_ID,
|
||||
} from '@/cli/__tests__/apps/rich-app/src/fields/post-card-recipients-on-post-card.field';
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card-recipient.object';
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from '@/sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: POST_CARD_ON_POST_CARD_RECIPIENT_ID,
|
||||
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCard',
|
||||
label: 'Post Card',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
POST_CARD_RECIPIENTS_ON_POST_CARD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'postCardId',
|
||||
},
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card-recipient.object';
|
||||
import { defineField, FieldType, RelationType } from '@/sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
|
||||
|
||||
export const POST_CARD_RECIPIENTS_ON_POST_CARD_ID =
|
||||
'a1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d';
|
||||
export const POST_CARD_ON_POST_CARD_RECIPIENT_ID =
|
||||
'a1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: POST_CARD_RECIPIENTS_ON_POST_CARD_ID,
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCardRecipients',
|
||||
label: 'Post Card Recipients',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
POST_CARD_ON_POST_CARD_RECIPIENT_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { defineField, FieldType, RelationType } from '@/sdk';
|
||||
import { RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/recipient.object';
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card-recipient.object';
|
||||
|
||||
export const POST_CARD_RECIPIENTS_ON_RECIPIENT_ID =
|
||||
'a1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d';
|
||||
export const RECIPIENT_ON_POST_CARD_RECIPIENT_ID =
|
||||
'a1a2b3c4-0004-4a7b-8c9d-0e1f2a3b4c5d';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: POST_CARD_RECIPIENTS_ON_RECIPIENT_ID,
|
||||
objectUniversalIdentifier: RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'postCardRecipients',
|
||||
label: 'Post Card Recipients',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
RECIPIENT_ON_POST_CARD_RECIPIENT_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+16
-3
@@ -1,5 +1,5 @@
|
||||
import { defineField, FieldType } from '@/sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/postCard.object';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
|
||||
|
||||
export default defineField({
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
@@ -9,9 +9,22 @@ export default defineField({
|
||||
label: 'Category',
|
||||
description: 'Post card category',
|
||||
options: [
|
||||
{ value: 'PERSONAL', label: 'Personal', color: 'blue', position: 0 },
|
||||
{ value: 'BUSINESS', label: 'Business', color: 'green', position: 1 },
|
||||
{
|
||||
id: 'c1d2e3f4-0001-4000-8000-000000000001',
|
||||
value: 'PERSONAL',
|
||||
label: 'Personal',
|
||||
color: 'blue',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: 'c1d2e3f4-0002-4000-8000-000000000002',
|
||||
value: 'BUSINESS',
|
||||
label: 'Business',
|
||||
color: 'green',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: 'c1d2e3f4-0003-4000-8000-000000000003',
|
||||
value: 'PROMOTIONAL',
|
||||
label: 'Promotional',
|
||||
color: 'orange',
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { defineField, FieldType } from '@/sdk';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/postCard.object';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card.object';
|
||||
|
||||
export default defineField({
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { defineField, FieldType, RelationType, OnDeleteAction } from '@/sdk';
|
||||
import { RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/recipient.object';
|
||||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '@/cli/__tests__/apps/rich-app/src/objects/post-card-recipient.object';
|
||||
import {
|
||||
POST_CARD_RECIPIENTS_ON_RECIPIENT_ID,
|
||||
RECIPIENT_ON_POST_CARD_RECIPIENT_ID,
|
||||
} from '@/cli/__tests__/apps/rich-app/src/fields/post-card-recipients-on-recipient.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: RECIPIENT_ON_POST_CARD_RECIPIENT_ID,
|
||||
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'recipient',
|
||||
label: 'Recipient',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
POST_CARD_RECIPIENTS_ON_RECIPIENT_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
joinColumnName: 'recipientId',
|
||||
},
|
||||
});
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { defineLogicFunction } from '@/sdk';
|
||||
|
||||
const handler = async (params: { recipientName: string }) => {
|
||||
return { found: true, name: params.recipientName };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'a1b2c3d4-1001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
name: 'lookup-recipient',
|
||||
description: 'Look up a recipient by name to find their details',
|
||||
timeoutSeconds: 5,
|
||||
handler,
|
||||
isTool: true,
|
||||
});
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { defineLogicFunction } from '@/sdk';
|
||||
|
||||
const handler = async () => {
|
||||
return { processed: true };
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'a1b2c3d4-db01-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
name: 'on-post-card-created',
|
||||
description: 'Triggered when a new post card is created',
|
||||
timeoutSeconds: 5,
|
||||
handler,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'postCard.created',
|
||||
},
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { defineNavigationMenuItem } from '@/sdk';
|
||||
import { ALL_POST_CARD_RECIPIENTS_VIEW_ID } from '@/cli/__tests__/apps/rich-app/src/views/all-post-card-recipients.view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'c1a2b3c4-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
position: 2,
|
||||
viewUniversalIdentifier: ALL_POST_CARD_RECIPIENTS_VIEW_ID,
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { defineNavigationMenuItem } from '@/sdk';
|
||||
import { ALL_POST_CARDS_VIEW_ID } from '@/cli/__tests__/apps/rich-app/src/views/all-post-cards.view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
position: 0,
|
||||
viewUniversalIdentifier: ALL_POST_CARDS_VIEW_ID,
|
||||
});
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { defineNavigationMenuItem } from '@/sdk';
|
||||
import { ALL_RECIPIENTS_VIEW_ID } from '@/cli/__tests__/apps/rich-app/src/views/all-recipients.view';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: 'c1a2b3c4-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
position: 1,
|
||||
viewUniversalIdentifier: ALL_RECIPIENTS_VIEW_ID,
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { defineObject, FieldType } from '@/sdk';
|
||||
|
||||
export const POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER =
|
||||
'e1a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e';
|
||||
|
||||
export const SENT_AT_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'e2a2b3c4-5e6f-4a7b-8c9d-0e1f2a3b4c5e';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'postCardRecipient',
|
||||
namePlural: 'postCardRecipients',
|
||||
labelSingular: 'Post Card Recipient',
|
||||
labelPlural: 'Post Card Recipients',
|
||||
description: 'Junction object linking post cards to their recipients',
|
||||
icon: 'IconLink',
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: SENT_AT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.DATE_TIME,
|
||||
label: 'Sent at',
|
||||
name: 'sentAt',
|
||||
icon: 'IconClock',
|
||||
isNullable: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
+4
@@ -53,24 +53,28 @@ export default defineObject({
|
||||
defaultValue: `'${PostCardStatus.DRAFT}'`,
|
||||
options: [
|
||||
{
|
||||
id: 'a1b2c3d4-0001-4000-8000-000000000001',
|
||||
value: PostCardStatus.DRAFT,
|
||||
label: 'Draft',
|
||||
position: 0,
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0002-4000-8000-000000000002',
|
||||
value: PostCardStatus.SENT,
|
||||
label: 'Sent',
|
||||
position: 1,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0003-4000-8000-000000000003',
|
||||
value: PostCardStatus.DELIVERED,
|
||||
label: 'Delivered',
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0004-4000-8000-000000000004',
|
||||
value: PostCardStatus.RETURNED,
|
||||
label: 'Returned',
|
||||
position: 3,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user