Compare commits

..

2 Commits

Author SHA1 Message Date
Sonarly Claude Code e82aa6d7a6 chore: improve monitoring for fix(server): add MemorySize to Lambda executor fun
Changed the HTTP status code for `LOGIC_FUNCTION_EXECUTION_ERROR` from 500 to 422 in the route-trigger REST API exception filter.

Logic function execution errors are user code failures — a user's webhook handler crashing, running out of memory, or throwing an unhandled exception. These are expected operational behavior for a platform that runs user-provided code, not platform infrastructure failures.

Returning 500 caused `shouldCaptureException()` (which filters out errors with statusCode < 500) to report every user code failure to Sentry, creating monitoring noise that obscures actual platform bugs. With 422 (Unprocessable Entity), the error is still returned to the caller with the full error details and `LOGIC_FUNCTION_EXECUTION_ERROR` code in the response body, but it will no longer be captured by Sentry's exception handler.

The 422 status is semantically correct: the server understood the webhook request but the user's logic function could not process it successfully.
2026-04-10 12:25:02 +00:00
Sonarly Claude Code cb92f6ea42 fix(server): add MemorySize to Lambda executor function creation
https://sonarly.com/issue/23736?type=bug

User-deployed logic functions running as AWS Lambda executors are created without a `MemorySize` parameter, defaulting to 128 MB — causing OOM kills ("signal: killed") when functions process non-trivial payloads like webhook callbacks.

Fix: Added `EXECUTOR_LAMBDA_MEMORY_MB = 512` constant and applied it to the `createLambdaExecutor()` Lambda creation call in `lambda.driver.ts`.

The root cause was that the `CreateFunctionCommandInput` for executor Lambdas (which run user logic functions) omitted the `MemorySize` parameter, causing AWS to use the default 128 MB. This is insufficient for Node.js 22 functions that need to load the twenty-client-sdk layer, user dependencies, and process webhook payloads. The "signal: killed" error is the Lambda runtime being OOM-killed by the kernel.

The fix adds `MemorySize: EXECUTOR_LAMBDA_MEMORY_MB` (512 MB) to match the builder Lambda allocation and follow the same constant pattern used throughout the file. Both the yarn-install (1024 MB) and builder (512 MB) Lambdas already had explicit memory configured.

**Important deployment note:** Existing Lambda executor functions already provisioned at 128 MB will NOT be automatically recreated. They will continue using 128 MB until the logic function's code changes or the SDK layer becomes stale, which triggers a rebuild via `canSkipBuild()`. For the affected customer, the fastest resolution is to trigger a re-save of their `on-recall-webhook` logic function, which will force a Lambda rebuild with the new 512 MB allocation.
2026-04-10 12:25:02 +00:00
1617 changed files with 41972 additions and 80530 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ This main guide provides a high-level overview and navigation hub.
A syncable entity is a metadata entity that:
- Has a **`universalIdentifier`**: A unique identifier used for syncing entities across workspaces/applications
- Has an **`applicationId`**: Links the entity to an application (Standard or Custom applications)
- Has an **`applicationId`**: Links the entity to an application (Twenty Standard or Custom applications)
- Participates in the **workspace migration system**: Can be created, updated, and deleted through the migration pipeline
- Is **cached as a flat entity**: Denormalized representation for efficient validation and change detection
+1 -8
View File
@@ -8,10 +8,6 @@ inputs:
api-key:
description: API key or access token for the target instance
required: true
app-path:
description: Path to the app directory (relative to repo root). Defaults to repo root.
required: false
default: '.'
runs:
using: composite
@@ -23,13 +19,11 @@ runs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
node-version-file: '.nvmrc'
cache: yarn
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn install --immutable
- name: Configure remote
@@ -49,5 +43,4 @@ runs:
- name: Deploy
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty deploy --remote target
@@ -8,10 +8,6 @@ inputs:
api-key:
description: API key or access token for the target workspace
required: true
app-path:
description: Path to the app directory (relative to repo root). Defaults to repo root.
required: false
default: '.'
runs:
using: composite
@@ -23,13 +19,11 @@ runs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
node-version-file: '.nvmrc'
cache: yarn
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn install --immutable
- name: Configure remote
@@ -49,5 +43,4 @@ runs:
- name: Install
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty install --remote target
@@ -15,8 +15,8 @@ outputs:
description: 'URL where the Twenty test server can be reached'
value: http://localhost:2021
api-key:
description: 'API key (type: API_KEY) for the seeded Twenty dev workspace'
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc
description: 'API key for the Twenty test instance'
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4
runs:
using: 'composite'
+7 -17
View File
@@ -19,7 +19,6 @@ jobs:
files: |
packages/twenty-sdk/**
packages/twenty-server/**
.github/workflows/ci-sdk.yaml
!packages/twenty-sdk/package.json
sdk-test:
needs: changed-files-check
@@ -70,6 +69,7 @@ jobs:
ports:
- 6379:6379
env:
NODE_ENV: test
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
@@ -79,26 +79,16 @@ jobs:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK
- name: Build
run: npx nx build twenty-sdk
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server > /tmp/twenty-server.log 2>&1 &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: SDK / Run e2e Tests
run: NODE_ENV=test npx vitest run --config ./vitest.e2e.config.ts
working-directory: packages/twenty-sdk
- name: Server / Dump logs on failure
if: failure()
run: tail -100 /tmp/twenty-server.log || echo "No server log file found"
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: test:e2e
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
+6 -9
View File
@@ -144,18 +144,15 @@ jobs:
exit 1
- name: Server / Check for Pending Migrations
run: |
npx nx database:migrate:generate twenty-server -- --name pending-migration-check || true
CORE_MIGRATION_OUTPUT=$(npx nx database:migrate:generate twenty-server -- --name core-migration-check || true)
if ! git diff --quiet; then
CORE_MIGRATION_FILE=$(ls packages/twenty-server/src/database/typeorm/core/migrations/common/*core-migration-check.ts 2>/dev/null || echo "")
if [ -n "$CORE_MIGRATION_FILE" ]; then
echo "::error::Unexpected migration files were generated. Please run 'npx nx database:migrate:generate twenty-server -- --name <migration-name>' and commit the result."
echo ""
echo "The following migration changes were detected:"
echo "==================================================="
git diff
echo "==================================================="
echo ""
echo "$CORE_MIGRATION_OUTPUT"
git checkout -- .
rm -f packages/twenty-server/src/database/typeorm/core/migrations/common/*core-migration-check.ts
exit 1
fi
+3 -4
View File
@@ -69,14 +69,13 @@ jobs:
- name: Server / Start
run: |
npx nx run twenty-server:start:ci &
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -sf http://localhost:3000/healthz; do sleep 2; done'
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start worker
working-directory: packages/twenty-server
run: |
NODE_ENV=development node dist/queue-worker/queue-worker.js &
npx nx run twenty-server:worker &
echo "Worker started"
- name: Zapier / Build
-1
View File
@@ -53,4 +53,3 @@ mcp.json
TRANSLATION_QA_REPORT.md
.playwright-mcp/
.playwright-cli/
output/playwright/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "1.22.0",
"version": "0.9.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -7,7 +7,9 @@
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
@@ -0,0 +1,70 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(
`Build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
});
afterAll(async () => {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
});
it('should find the installed app in the applications list', async () => {
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const installedApp = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(installedApp).toBeDefined();
});
});
@@ -1,87 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,46 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});
@@ -1,15 +1,6 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY =
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
@@ -20,12 +11,14 @@ export default defineConfig({
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
process.env.TWENTY_API_KEY ??
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});
@@ -17,7 +17,8 @@ export default defineConfig({
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc',
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});
@@ -1,7 +1,7 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
const APP_PATH = process.cwd();
@@ -1,87 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const { apiUrl, apiKey } = validateEnv();
await checkServer(apiUrl);
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,63 +0,0 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('PostCard object', () => {
it('should exist with expected fields and relations', async () => {
const client = new MetadataApiClient();
const { objects } = await client.query({
objects: {
__args: {
filter: { isCustom: { is: true } },
paging: { first: 50 },
},
edges: {
node: {
nameSingular: true,
fields: {
__args: { paging: { first: 500 } },
edges: { node: { name: true } },
},
},
},
},
});
const obj = objects.edges
.map((e: { node: { nameSingular: string } }) => e.node)
.find((n: { nameSingular: string }) => n.nameSingular === 'postCard');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map(
(e: { node: { name: string } }) => e.node.name,
);
expect(names).toContain('name');
expect(names).toContain('content');
expect(names).toContain('status');
expect(names).toContain('deliveredAt');
expect(names).toContain('recipient');
});
});
@@ -0,0 +1,53 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { beforeAll } from 'vitest';
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.test.json');
beforeAll(async () => {
const apiUrl = process.env.TWENTY_API_URL!;
const token = process.env.TWENTY_API_KEY!;
if (!apiUrl || !token) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(
CONFIG_PATH,
JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey: token },
},
defaultRemote: 'local',
},
null,
2,
),
);
process.env.TWENTY_APP_ACCESS_TOKEN ??= token;
});
@@ -1,15 +1,6 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY =
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
@@ -20,12 +11,14 @@ export default defineConfig({
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
process.env.TWENTY_API_KEY ??
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});
@@ -1,42 +0,0 @@
name: CD
on:
push:
branches:
- main
pull_request:
types: [labeled]
permissions:
contents: read
env:
TWENTY_DEPLOY_URL: http://localhost:3000
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy-and-install:
if: >-
github.event_name == 'push' ||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Deploy
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
- name: Install
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
with:
api-url: ${{ env.TWENTY_DEPLOY_URL }}
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
@@ -1,48 +0,0 @@
name: CI
on:
push:
branches:
- main
pull_request: {}
permissions:
contents: read
env:
TWENTY_VERSION: latest
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Spawn Twenty test instance
id: twenty
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
with:
twenty-version: ${{ env.TWENTY_VERSION }}
- name: Enable Corepack
run: corepack enable
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Run integration tests
run: yarn test
env:
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
@@ -35,4 +35,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
*.d.ts
@@ -1 +0,0 @@
24.5.0
@@ -1,19 +1,40 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"plugins": ["typescript", "import", "unicorn"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"ignorePatterns": ["node_modules"],
"rules": {
"func-style": ["error", "declaration", { "allowArrowFunctions": true }],
"no-console": ["warn", { "allow": ["group", "groupCollapsed", "groupEnd"] }],
"no-control-regex": "off",
"no-debugger": "error",
"no-duplicate-imports": "error",
"no-undef": "off",
"no-unused-vars": "off",
"no-redeclare": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
"import/no-duplicates": "error",
"typescript/no-redeclare": "error",
"typescript/ban-ts-comment": "error",
"typescript/consistent-type-imports": ["error", {
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}],
"typescript/explicit-function-return-type": "off",
"typescript/explicit-module-boundary-types": "off",
"typescript/no-empty-object-type": ["error", {
"allowInterfaces": "with-single-extends"
}],
"typescript/no-empty-function": "off",
"typescript/no-explicit-any": "off",
"typescript/no-unused-vars": ["warn", {
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_"
}]
}
}
@@ -1 +1,5 @@
yarnPath: .yarn/releases/yarn-4.9.2.cjs
nodeLinker: node-modules
enableTransparentWorkspaces: false
@@ -1,14 +0,0 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -1,14 +0,0 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -1,14 +0,0 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/apps/getting-started
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/postcard
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
- Creating a front-end component that has a scroll instead of being responsive to its fixed widget height and width, unless it is specifically meant to be used in a canvas tab.
@@ -1,11 +1,44 @@
This is a [Twenty](https://twenty.com) application bootstrapped with [`create-twenty-app`](https://www.npmjs.com/package/create-twenty-app).
# Self Hosting
## Getting Started
Used to manage billing and telemetry of self-hosted instances
Run `yarn twenty help` to list all available commands.
## Features
## Learn More
### Telemetry Webhook
- [Twenty Apps documentation](https://docs.twenty.com/developers/extend/apps/getting-started)
- [twenty-sdk CLI reference](https://www.npmjs.com/package/twenty-sdk)
- [Discord](https://discord.gg/cx5n4Jzs57)
Receives user signup telemetry events from self-hosted Twenty instances and creates/updates selfHostingUser records.
**Endpoint:** `POST /webhook/telemetry`
**Payload Structure:**
```json
{
"action": "user_signup",
"timestamp": "2025-11-21T...",
"version": "1",
"payload": {
"userId": "uuid",
"workspaceId": "uuid",
"payload": {
"events": [
{
"userEmail": "user@example.com",
"userId": "uuid",
"userFirstName": "John",
"userLastName": "Doe",
"locale": "en",
"serverUrl": "https://self-hosted.example.com"
}
]
}
}
}
```
**Response:**
```json
{
"success": true,
"message": "Self hosting user created/updated: uuid"
}
```
@@ -1,6 +1,6 @@
{
"name": "self-hosting",
"version": "1.0.0",
"version": "0.0.1",
"license": "MIT",
"engines": {
"node": "^24.5.0",
@@ -11,22 +11,13 @@
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "1.22.0-canary.6",
"twenty-sdk": "1.22.0-canary.6"
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
"twenty-sdk": "0.6.2"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "a7070f46-3158-4b40-828f-8e6b1febc233"
}
@@ -1,9 +1,6 @@
import { defineApplication } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'94f7db30-59e5-4b09-a5fe-64cd3d4a65b0';
export default defineApplication({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
@@ -5,7 +5,7 @@ import {
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { SELF_HOSTING_USER_NAME_SINGULAR } from 'src/objects/selfHostingUser.object';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CoreApiClient } from 'twenty-sdk/clients';
type SelfHostingUser = {
id: string;
@@ -1,5 +1,5 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { CoreApiClient } from 'twenty-sdk/clients';
import { type TelemetryEvent } from 'src/logic-functions/types/telemetry-event.type';
export const main = async (
@@ -4,7 +4,7 @@ import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.const
export default defineRole({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.roles.defaultRole.universalIdentifier,
label: 'Self hosting default role',
label: 'default role',
description: 'Add a description for your role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
@@ -27,16 +27,5 @@
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}
@@ -1,24 +0,0 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
tsconfigPaths({
projects: ['tsconfig.spec.json'],
ignoreConfigErrors: true,
}),
],
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
include: ['src/**/*.integration-test.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
process.env.TWENTY_API_KEY ??
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "twenty-client-sdk",
"version": "1.22.0",
"version": "0.9.0",
"sideEffects": false,
"license": "AGPL-3.0",
"scripts": {
@@ -583,7 +583,6 @@ type ViewField {
workspaceId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
isActive: Boolean!
deletedAt: DateTime
isOverridden: Boolean @deprecated(reason: "isOverridden is deprecated")
}
@@ -692,7 +691,6 @@ type ViewFieldGroup {
workspaceId: UUID!
createdAt: DateTime!
updatedAt: DateTime!
isActive: Boolean!
deletedAt: DateTime
viewFields: [ViewField!]!
isOverridden: Boolean! @deprecated(reason: "isOverridden is deprecated")
@@ -735,7 +733,6 @@ enum ViewType {
KANBAN
CALENDAR
FIELDS_WIDGET
TABLE_WIDGET
}
enum ViewKey {
@@ -793,6 +790,7 @@ type Workspace {
isCustomDomainEnabled: Boolean!
editableProfileFields: [String!]
defaultRole: Role
version: String
fastModel: String!
smartModel: String!
aiAdditionalInstructions: String
@@ -901,7 +899,6 @@ type PageLayoutWidget {
conditionalAvailabilityExpression: String
createdAt: DateTime!
updatedAt: DateTime!
isActive: Boolean!
deletedAt: DateTime
isOverridden: Boolean @deprecated(reason: "isOverridden is deprecated")
}
@@ -1237,7 +1234,6 @@ type PageLayoutTab {
layoutMode: PageLayoutTabLayoutMode
createdAt: DateTime!
updatedAt: DateTime!
isActive: Boolean!
deletedAt: DateTime
isOverridden: Boolean @deprecated(reason: "isOverridden is deprecated")
}
@@ -1258,7 +1254,6 @@ enum PageLayoutType {
RECORD_INDEX
RECORD_PAGE
DASHBOARD
STANDALONE_PAGE
}
type Analytics {
@@ -1487,7 +1482,6 @@ type NavigationMenuItem {
icon: String
color: String
folderId: UUID
pageLayoutId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
@@ -1501,7 +1495,6 @@ enum NavigationMenuItemType {
LINK
OBJECT
RECORD
PAGE_LAYOUT
}
type ObjectRecordEventProperties {
@@ -1580,26 +1573,9 @@ enum LogicFunctionExecutionStatus {
ERROR
}
type FeatureFlag {
key: FeatureFlagKey!
value: Boolean!
}
enum FeatureFlagKey {
IS_UNIQUE_INDEXES_ENABLED
IS_JSON_FILTER_ENABLED
IS_AI_ENABLED
IS_COMMAND_MENU_ITEM_ENABLED
IS_MARKETPLACE_SETTING_TAB_VISIBLE
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED
IS_PUBLIC_DOMAIN_ENABLED
IS_EMAILING_DOMAIN_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_DRAFT_EMAIL_ENABLED
IS_CONNECTED_ACCOUNT_MIGRATED
IS_RICH_TEXT_V1_MIGRATED
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED
IS_DATASOURCE_MIGRATED
type BillingTrialPeriod {
duration: Float!
isCreditCardRequired: Boolean!
}
type WorkspaceUrls {
@@ -1607,89 +1583,6 @@ type WorkspaceUrls {
subdomainUrl: String!
}
type UserInfo {
id: UUID!
email: String!
firstName: String
lastName: String
createdAt: DateTime!
}
type WorkspaceInfo {
id: UUID!
name: String!
allowImpersonation: Boolean!
logo: String
totalUsers: Float!
activationStatus: WorkspaceActivationStatus!
createdAt: DateTime!
workspaceUrls: WorkspaceUrls!
users: [UserInfo!]!
featureFlags: [FeatureFlag!]!
}
type UserLookup {
user: UserInfo!
workspaces: [WorkspaceInfo!]!
}
type AdminPanelRecentUser {
id: UUID!
email: String!
firstName: String
lastName: String
createdAt: DateTime!
workspaceName: String
workspaceId: UUID
}
type AdminPanelTopWorkspace {
id: UUID!
name: String!
totalUsers: Int!
subdomain: String!
}
type AdminWorkspaceChatThread {
id: UUID!
title: String
totalInputTokens: Int!
totalOutputTokens: Int!
conversationSize: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
type AdminChatMessagePart {
type: String!
textContent: String
toolName: String
}
type AdminChatMessage {
id: UUID!
role: AgentMessageRole!
parts: [AdminChatMessagePart!]!
createdAt: DateTime!
}
"""Role of a message in a chat thread"""
enum AgentMessageRole {
SYSTEM
USER
ASSISTANT
}
type AdminChatThreadMessages {
thread: AdminWorkspaceChatThread!
messages: [AdminChatMessage!]!
}
type BillingTrialPeriod {
duration: Float!
isCreditCardRequired: Boolean!
}
type SSOIdentityProvider {
id: UUID!
name: String!
@@ -1732,12 +1625,6 @@ type PublicWorkspaceData {
workspaceUrls: WorkspaceUrls!
}
type PublicWorkspaceDataSummary {
id: UUID!
logo: String
displayName: String
}
type NativeModelCapabilities {
webSearch: Boolean
twitterSearch: Boolean
@@ -1842,6 +1729,25 @@ type PublicFeatureFlag {
metadata: PublicFeatureFlagMetadata!
}
enum FeatureFlagKey {
IS_UNIQUE_INDEXES_ENABLED
IS_JSON_FILTER_ENABLED
IS_AI_ENABLED
IS_COMMAND_MENU_ITEM_ENABLED
IS_MARKETPLACE_SETTING_TAB_VISIBLE
IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED
IS_PUBLIC_DOMAIN_ENABLED
IS_EMAILING_DOMAIN_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_DRAFT_EMAIL_ENABLED
IS_CONNECTED_ACCOUNT_MIGRATED
IS_USAGE_ANALYTICS_ENABLED
IS_RICH_TEXT_V1_MIGRATED
IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED
IS_RECORD_TABLE_WIDGET_ENABLED
IS_DATASOURCE_MIGRATED
}
type ClientConfigMaintenanceMode {
startAt: DateTime!
endAt: DateTime!
@@ -2025,6 +1931,34 @@ type SystemHealth {
services: [SystemHealthService!]!
}
type FeatureFlag {
key: FeatureFlagKey!
value: Boolean!
}
type UserInfo {
id: UUID!
email: String!
firstName: String
lastName: String
}
type WorkspaceInfo {
id: UUID!
name: String!
allowImpersonation: Boolean!
logo: String
totalUsers: Float!
workspaceUrls: WorkspaceUrls!
users: [UserInfo!]!
featureFlags: [FeatureFlag!]!
}
type UserLookup {
user: UserInfo!
workspaces: [WorkspaceInfo!]!
}
type VersionInfo {
currentVersion: String
latestVersion: String!
@@ -2206,18 +2140,30 @@ type DeletedWorkspaceMember {
userWorkspaceId: UUID
}
type Relation {
type: RelationType!
sourceObjectMetadata: Object!
targetObjectMetadata: Object!
sourceFieldMetadata: Field!
targetFieldMetadata: Field!
type BillingEntitlement {
key: BillingEntitlementKey!
value: Boolean!
}
"""Relation type"""
enum RelationType {
ONE_TO_MANY
MANY_TO_ONE
enum BillingEntitlementKey {
SSO
CUSTOM_DOMAIN
RLS
AUDIT_LOGS
}
type DomainRecord {
validationType: String!
type: String!
status: String!
key: String!
value: String!
}
type DomainValidRecords {
id: UUID!
domain: String!
records: [DomainRecord!]!
}
type IndexEdge {
@@ -2319,6 +2265,25 @@ type ObjectFieldsConnection {
edges: [FieldEdge!]!
}
type UpsertRowLevelPermissionPredicatesResult {
predicates: [RowLevelPermissionPredicate!]!
predicateGroups: [RowLevelPermissionPredicateGroup!]!
}
type Relation {
type: RelationType!
sourceObjectMetadata: Object!
targetObjectMetadata: Object!
sourceFieldMetadata: Field!
targetFieldMetadata: Field!
}
"""Relation type"""
enum RelationType {
ONE_TO_MANY
MANY_TO_ONE
}
type FieldConnection {
"""Paging information"""
pageInfo: PageInfo!
@@ -2327,37 +2292,6 @@ type FieldConnection {
edges: [FieldEdge!]!
}
type BillingEntitlement {
key: BillingEntitlementKey!
value: Boolean!
}
enum BillingEntitlementKey {
SSO
CUSTOM_DOMAIN
RLS
AUDIT_LOGS
}
type DomainRecord {
validationType: String!
type: String!
status: String!
key: String!
value: String!
}
type DomainValidRecords {
id: UUID!
domain: String!
records: [DomainRecord!]!
}
type UpsertRowLevelPermissionPredicatesResult {
predicates: [RowLevelPermissionPredicate!]!
predicateGroups: [RowLevelPermissionPredicateGroup!]!
}
type AuthToken {
token: String!
expiresAt: DateTime!
@@ -2593,7 +2527,6 @@ type Location {
}
type PlaceDetailsResult {
street: String
state: String
postcode: String
city: String
@@ -2651,7 +2584,6 @@ type CommandMenuItem {
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
pageLayoutId: UUID
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
@@ -2728,7 +2660,6 @@ enum EngineComponentKey {
enum CommandMenuItemAvailabilityType {
GLOBAL
GLOBAL_OBJECT_CONTEXT
RECORD_SELECTION
FALLBACK
}
@@ -3351,14 +3282,10 @@ type Query {
currentUser: User!
currentWorkspace: Workspace!
getPublicWorkspaceDataByDomain(origin: String): PublicWorkspaceData!
getPublicWorkspaceDataById(id: UUID!): PublicWorkspaceDataSummary!
getSSOIdentityProviders: [FindAvailableSSOIDP!]!
getConnectedImapSmtpCaldavAccount(id: UUID!): ConnectedImapSmtpCaldavAccount!
getAutoCompleteAddress(address: String!, token: String!, country: String, isFieldCity: Boolean): [AutocompleteResult!]!
getAddressDetails(placeId: String!, token: String!): PlaceDetailsResult!
userLookupAdminPanel(userIdentifier: String!): UserLookup!
adminPanelRecentUsers(searchTerm: String = ""): [AdminPanelRecentUser!]!
adminPanelTopWorkspaces(searchTerm: String = ""): [AdminPanelTopWorkspace!]!
getConfigVariablesGrouped: ConfigVariables!
getSystemHealthStatus: SystemHealth!
getIndicatorHealthStatus(indicatorId: HealthIndicatorId!): AdminPanelHealthServiceData!
@@ -3373,10 +3300,6 @@ type Query {
getModelsDevSuggestions(providerType: String!): [ModelsDevModelSuggestion!]!
getAdminAiUsageByWorkspace(periodStart: DateTime, periodEnd: DateTime): [UsageBreakdownItem!]!
getMaintenanceMode: MaintenanceMode
workspaceLookupAdminPanel(workspaceId: UUID!): UserLookup!
getAdminWorkspaceChatThreads(workspaceId: UUID!): [AdminWorkspaceChatThread!]!
getAdminChatThreadMessages(threadId: UUID!): AdminChatThreadMessages!
findOneAdminApplicationRegistration(id: String!): ApplicationRegistration!
getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics!
getPostgresCredentials: PostgresCredentials
findManyPublicDomains: [PublicDomain!]!
@@ -3583,7 +3506,6 @@ type Mutation {
updatePageLayout(id: String!, input: UpdatePageLayoutInput!): PageLayout!
destroyPageLayout(id: String!): Boolean!
updatePageLayoutWithTabsAndWidgets(id: String!, input: UpdatePageLayoutWithTabsInput!): PageLayout!
resetPageLayoutToDefault(id: String!): PageLayout!
resetPageLayoutWidgetToDefault(id: String!): PageLayoutWidget!
resetPageLayoutTabToDefault(id: String!): PageLayoutTab!
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
@@ -3675,7 +3597,6 @@ type Mutation {
verifyTwoFactorAuthenticationMethodForAuthenticatedUser(otp: String!): VerifyTwoFactorAuthenticationMethod!
deleteUser: User!
deleteUserFromWorkspace(workspaceMemberIdToDelete: String!): UserWorkspace!
updateWorkspaceMemberSettings(input: UpdateWorkspaceMemberSettingsInput!): Boolean!
updateUserEmail(newEmail: String!, verifyEmailRedirectPath: String): Boolean!
resendEmailVerificationToken(email: String!, origin: String!): ResendEmailVerificationToken!
activateWorkspace(data: ActivateWorkspaceInput!): Workspace!
@@ -3691,6 +3612,7 @@ type Mutation {
startChannelSync(connectedAccountId: UUID!): ChannelSyncSuccess!
saveImapSmtpCaldavAccount(accountOwnerId: UUID!, handle: String!, connectionParameters: EmailAccountConnectionParameters!, id: UUID): ImapSmtpCaldavConnectionSuccess!
updateLabPublicFeatureFlag(input: UpdateLabPublicFeatureFlagInput!): FeatureFlag!
userLookupAdminPanel(userIdentifier: String!): UserLookup!
updateWorkspaceFeatureFlag(workspaceId: UUID!, featureFlag: String!, value: Boolean!): Boolean!
setAdminAiModelEnabled(modelId: String!, enabled: Boolean!): Boolean!
setAdminAiModelsEnabled(modelIds: [String!]!, enabled: Boolean!): Boolean!
@@ -3754,7 +3676,6 @@ input CreateNavigationMenuItemInput {
icon: String
color: String
folderId: UUID
pageLayoutId: UUID
position: Float
}
@@ -3773,7 +3694,6 @@ input UpdateNavigationMenuItemInput {
link: String
icon: String
color: String
pageLayoutId: UUID
}
"""The `Upload` scalar type represents a file upload."""
@@ -4184,7 +4104,6 @@ input CreateCommandMenuItemInput {
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
payload: JSON
pageLayoutId: UUID
}
input UpdateCommandMenuItemInput {
@@ -4198,7 +4117,6 @@ input UpdateCommandMenuItemInput {
availabilityObjectMetadataId: UUID
engineComponentKey: EngineComponentKey
hotKeys: [String!]
pageLayoutId: UUID
}
input CreateFrontComponentInput {
@@ -4609,15 +4527,9 @@ input UpdateApplicationRegistrationVariableInput {
input UpdateApplicationRegistrationVariablePayload {
value: String
resetValue: Boolean
description: String
}
input UpdateWorkspaceMemberSettingsInput {
workspaceMemberId: UUID!
update: JSON!
}
input ActivateWorkspaceInput {
displayName: String
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -215,8 +215,7 @@ ENV PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default \
DISABLE_DB_MIGRATIONS=true \
DISABLE_CRON_JOBS_REGISTRATION=true \
IS_BILLING_ENABLED=false \
SIGN_IN_PREFILLED=true \
APPLICATION_LOG_DRIVER=CONSOLE
SIGN_IN_PREFILLED=true
EXPOSE 2020
VOLUME ["/data/postgres", "/app/packages/twenty-server/.local-storage"]
@@ -476,7 +476,7 @@ export default defineLogicFunction({
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: true,
isAuthRequired: false,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
@@ -220,7 +220,6 @@ The scaffolder already started a local Twenty server for you. To manage it later
|---------|-------------|
| `yarn twenty server start` | Start the local server (pulls image if needed) |
| `yarn twenty server start --port 3030` | Start on a custom port |
| `yarn twenty server start --test` | Start a separate test instance on port 2021 |
| `yarn twenty server stop` | Stop the server (preserves data) |
| `yarn twenty server status` | Show server status, URL, and credentials |
| `yarn twenty server logs` | Stream server logs |
@@ -229,20 +228,6 @@ The scaffolder already started a local Twenty server for you. To manage it later
Data is persisted across restarts in two Docker volumes (`twenty-app-dev-data` for PostgreSQL, `twenty-app-dev-storage` for files). Use `reset` to wipe everything and start fresh.
### Running a test instance
Pass `--test` to any `server` command to manage a second, fully isolated instance — useful for running integration tests or experimenting without touching your main dev data.
| Command | Description |
|---------|-------------|
| `yarn twenty server start --test` | Start the test instance (defaults to port 2021) |
| `yarn twenty server stop --test` | Stop the test instance |
| `yarn twenty server status --test` | Show test instance status, URL, and credentials |
| `yarn twenty server logs --test` | Stream test instance logs |
| `yarn twenty server reset --test` | Wipe test data and start fresh |
The test instance runs in its own Docker container (`twenty-app-dev-test`) with dedicated volumes (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) and config, so it can run in parallel with your main instance without conflicts. Combine `--test` with `--port` to override the default 2021.
<Note>
The server requires **Docker** to be running. If you see a "Docker not running" error, make sure Docker Desktop (or the Docker daemon) is started.
</Note>
@@ -80,57 +80,6 @@ Pre-release tags work as expected: bumping `1.0.0-rc.1` → `1.0.0-rc.2` is allo
{/* TODO: add screenshot of the Upgrade button */}
## Automated CI/CD (scaffolded workflows)
Apps generated with `create-twenty-app` ship with two GitHub Actions workflows out of the box, under `.github/workflows/`. They are ready to run as soon as you push the repo to GitHub — no extra setup is needed for CI, and CD only requires a single secret.
### CI — `ci.yml`
Runs integration tests on every push to `main` and every pull request.
**What it does:**
1. Checks out your app's source.
2. Spawns an isolated Twenty test instance using the `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` composite action (the CI equivalent of `yarn twenty server start --test`).
3. Enables Corepack, sets up Node.js from your `.nvmrc`, and installs dependencies with `yarn install --immutable`.
4. Runs `yarn test`, passing `TWENTY_API_URL` and `TWENTY_API_KEY` from the spawned instance so your tests can talk to a real server.
**Config knobs:**
- `TWENTY_VERSION` (env, defaults to `latest`) — pin the Twenty server version used in CI by editing this in `ci.yml`.
- Concurrency is grouped by `github.ref` and cancels in-progress runs on new pushes.
No secrets are required — the test instance is ephemeral and lives only for the duration of the job.
### CD — `cd.yml`
Deploys your app to a configured Twenty server on every push to `main`, and optionally from a pull request when the `deploy` label is applied.
**What it does:**
1. Checks out the PR head (for labeled PRs) or the pushed commit.
2. Runs `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — the CI equivalent of `yarn twenty deploy`.
3. Runs `twentyhq/twenty/.github/actions/install-twenty-app@main` so the newly deployed version is installed into the target workspace.
**Required configuration:**
| Setting | Where | Purpose |
|---------|-------|---------|
| `TWENTY_DEPLOY_URL` | `env` in `cd.yml` (defaults to `http://localhost:3000`) | The Twenty server to deploy to. Change this to your real server URL before first use. |
| `TWENTY_DEPLOY_API_KEY` | GitHub repo **Settings → Secrets and variables → Actions** | API key with deploy permission on the target server. |
<Note>
The default `TWENTY_DEPLOY_URL` of `http://localhost:3000` is a placeholder — it will not reach anything from a GitHub-hosted runner. Update it to your server's public URL (or use a self-hosted runner with network access) before enabling CD.
</Note>
**Triggering a preview deploy from a PR:**
Add the `deploy` label to a pull request. The `if:` guard in `cd.yml` will run the job for that PR using the PR's head commit, letting you validate a change on the target server before merging.
### Pinning the reusable actions
Both workflows reference reusable actions at `@main`, so action updates in the `twentyhq/twenty` repo are picked up automatically. If you want deterministic builds, replace `@main` with a commit SHA or release tag on each `uses:` line.
## Publishing to npm
Publishing to npm makes your app discoverable in the Twenty marketplace. Any Twenty workspace can browse, install, and upgrade marketplace apps directly from the UI.
@@ -138,7 +87,7 @@ Publishing to npm makes your app discoverable in the Twenty marketplace. Any Twe
### Requirements
- An [npm](https://www.npmjs.com) account
- The `twenty-app` keyword in your `package.json` `keywords` array (add it manually — it is not included by default in the `create-twenty-app` template)
- The `twenty-app` keyword in your `package.json` `keywords` array (already included when you scaffold with `create-twenty-app`)
```json filename="package.json"
{
@@ -476,7 +476,7 @@ export default defineLogicFunction({
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: true,
isAuthRequired: false,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
@@ -220,7 +220,6 @@ npx create-twenty-app@latest my-twenty-app --example postcard
| -------------------------------------- | --------------------------------------------- |
| `yarn twenty server start` | بدء الخادم المحلي (يسحب الصورة إذا لزم الأمر) |
| `yarn twenty server start --port 3030` | ابدأ على منفذ مخصّص |
| `yarn twenty server start --test` | ابدأ مثيل اختبار منفصل على المنفذ 2021 |
| `yarn twenty server stop` | إيقاف الخادم (مع الحفاظ على البيانات) |
| `yarn twenty server status` | عرض حالة الخادم، وعنوان URL، وبيانات الاعتماد |
| `yarn twenty server logs` | بث سجلات الخادم |
@@ -229,20 +228,6 @@ npx create-twenty-app@latest my-twenty-app --example postcard
يتم الاحتفاظ بالبيانات عبر عمليات إعادة التشغيل في وحدتي تخزين Docker (`twenty-app-dev-data` لـ PostgreSQL، و`twenty-app-dev-storage` للملفات). استخدم `reset` لمسح كل شيء والبدء من جديد.
### تشغيل مثيل الاختبار
مرر `--test` إلى أي أمر `server` لإدارة مثيل ثانٍ معزول تمامًا — مفيد لتشغيل اختبارات التكامل أو للتجربة من دون لمس بيانات التطوير الرئيسية لديك.
| أمر | الوصف |
| ---------------------------------- | ---------------------------------------------------- |
| `yarn twenty server start --test` | بدء مثيل الاختبار (المنفذ الافتراضي 2021) |
| `yarn twenty server stop --test` | إيقاف مثيل الاختبار |
| `yarn twenty server status --test` | عرض حالة مثيل الاختبار، وعنوان URL، وبيانات الاعتماد |
| `yarn twenty server logs --test` | بث سجلات مثيل الاختبار |
| `yarn twenty server reset --test` | محو بيانات الاختبار والبدء من جديد |
يعمل مثيل الاختبار في حاوية Docker خاصة به (`twenty-app-dev-test`) مع وحدات تخزين مخصصة (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) وتهيئة مستقلة، بحيث يمكنه العمل بالتوازي مع مثيلك الرئيسي من دون تعارضات. اجمع `--test` مع `--port` لتجاوز القيمة الافتراضية 2021.
<Note>
يتطلّب الخادم أن يكون **Docker** قيد التشغيل. إذا ظهرت لك رسالة خطأ "Docker not running"، فتأكّد من تشغيل Docker Desktop (أو خادوم Docker).
</Note>
@@ -80,57 +80,6 @@ yarn twenty deploy
{/* TODO: add screenshot of the Upgrade button */}
## CI/CD المؤتمتة (مهام سير عمل مُولَّدة بالقوالب)
التطبيقات المُولَّدة باستخدام `create-twenty-app` تأتي افتراضيًا مع مهمَّتي سير عمل من GitHub Actions ضمن `.github/workflows/`. هي جاهزة للتشغيل بمجرد دفع المستودع إلى GitHub — لا حاجة لأي إعداد إضافي لـ CI، وCD يتطلّب سرًّا واحدًا فقط.
### CI — `ci.yml`
يشغّل اختبارات التكامل عند كل دفع إلى `main` وعند كل طلب سحب.
**ماذا يفعل:**
1. يجلب مصدر تطبيقك.
2. ينشئ مثيلاً اختبارياً معزولاً من Twenty باستخدام الإجراء المركّب `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` (المكافئ في CI للأمر `yarn twenty server start --test`).
3. يُفعِّل Corepack، ويُعدّ Node.js من ملف `.nvmrc` لديك، ويثبّت التبعيات بواسطة `yarn install --immutable`.
4. يشغّل `yarn test`، ويمرّر `TWENTY_API_URL` و`TWENTY_API_KEY` من المثيل الذي تم إنشاؤه بحيث تتمكّن اختباراتك من التواصل مع خادم حقيقي.
**خيارات التكوين:**
* `TWENTY_VERSION` (متغيّر بيئة، القيمة الافتراضية `latest`) — ثبّت نسخة خادم Twenty المستخدمة في CI عبر تعديل هذا في `ci.yml`.
* يتم تجميع التشغيل المتزامن حسب `github.ref` ويلغي التشغيلات قيد التقدّم عند أي دفع جديد.
لا تتطلّب أي أسرار — مثيل الاختبار مؤقّت ويستمر فقط طوال مدّة المهمّة.
### CD — `cd.yml`
ينشر تطبيقك إلى خادم Twenty مُهيّأ عند كل دفع إلى `main`، وبشكل اختياري من طلب سحب عند تطبيق الوسم `deploy`.
**ماذا يفعل:**
1. يجلب رأس طلب السحب (للطلبات الموسومة) أو الالتزام المدفوع.
2. يشغّل `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — وهو المكافئ في CI للأمر `yarn twenty deploy`.
3. يشغّل `twentyhq/twenty/.github/actions/install-twenty-app@main` بحيث تُثبَّت النسخة المُنشَرة حديثًا في مساحة العمل المستهدفة.
**التكوين المطلوب:**
| الإعداد | حيث | الغرض |
| ----------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `TWENTY_DEPLOY_URL` | `env` في `cd.yml` (القيمة الافتراضية `http://localhost:3000`) | خادم Twenty الذي سيتم النشر إليه. غيّر هذا إلى عنوان URL لخادمك الحقيقي قبل أول استخدام. |
| `TWENTY_DEPLOY_API_KEY` | في مستودع GitHub **Settings → Secrets and variables → Actions** | مفتاح API يمتلك إذن النشر على الخادم المستهدف. |
<Note>
القيمة الافتراضية لـ `TWENTY_DEPLOY_URL` وهي `http://localhost:3000` مجرد عنصر نائب — لن تصل إلى أي شيء من مُشغِّل مستضاف لدى GitHub. حدّثها إلى عنوان URL العام لخادمك (أو استخدم مُشغِّلًا مستضافًا ذاتيًا مع وصول شبكي) قبل تمكين CD.
</Note>
**تشغيل نشر معاينة من طلب سحب:**
أضِف الوسم `deploy` إلى طلب سحب. الشرط `if:` في `cd.yml` سيشغّل المهمّة لذلك الطلب مستخدمًا التزام رأس الطلب، مما يتيح لك التحقّق من التغيير على الخادم المستهدف قبل الدمج.
### تثبيت الإجراءات القابلة لإعادة الاستخدام
يشير كلا سيرَي العمل إلى إجراءات قابلة لإعادة الاستخدام عند `@main`، لذا تُلتقط تحديثات الإجراءات في مستودع `twentyhq/twenty` تلقائيًا. إذا كنت تريد بناءات حتمية، فاستبدِل `@main` بقيمة SHA لالتزام أو بوسم إصدار في كل سطر `uses:`.
## النشر على npm
يُتيح النشر على npm إمكانية العثور على تطبيقك في سوق Twenty. يمكن لأي مساحة عمل في Twenty استعراض تطبيقات السوق وتثبيتها وترقيتها مباشرةً من واجهة المستخدم.
@@ -138,7 +87,7 @@ yarn twenty deploy
### المتطلبات
* حساب على [npm](https://www.npmjs.com)
* الكلمة المفتاحية `twenty-app` في مصفوفة `keywords` في `package.json` (أضفها يدويًا — فهي غير مضمنة افتراضيًا في قالب `create-twenty-app`)
* الكلمة المفتاحية `twenty-app` في مصفوفة `keywords` في `package.json` (موجودة مسبقًا عند تهيئة المشروع باستخدام `create-twenty-app`)
```json filename="package.json"
{
@@ -476,7 +476,7 @@ export default defineLogicFunction({
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: true,
isAuthRequired: false,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
@@ -220,7 +220,6 @@ Nástroj pro vytvoření kostry vám již spustil lokální server Twenty. Pro j
| -------------------------------------- | ------------------------------------------------------ |
| `yarn twenty server start` | Spustí lokální server (v případě potřeby stáhne image) |
| `yarn twenty server start --port 3030` | Spustí na vlastním portu |
| `yarn twenty server start --test` | Spusťte samostatnou testovací instanci na portu 2021 |
| `yarn twenty server stop` | Zastaví server (zachová data) |
| `yarn twenty server status` | Zobrazí stav serveru, URL a přihlašovací údaje |
| `yarn twenty server logs` | Streamuje protokoly serveru |
@@ -229,20 +228,6 @@ Nástroj pro vytvoření kostry vám již spustil lokální server Twenty. Pro j
Data přetrvávají při restartech ve dvou svazcích Dockeru (`twenty-app-dev-data` pro PostgreSQL, `twenty-app-dev-storage` pro soubory). Pomocí `reset` vymažte vše a začněte znovu.
### Spuštění testovací instance
Předejte volbu `--test` libovolnému příkazu `server` pro správu druhé, plně izolované instance — užitečné pro spouštění integračních testů nebo experimentování, aniž byste se dotkli svých hlavních vývojových dat.
| Příkaz | Popis |
| ---------------------------------- | --------------------------------------------------------- |
| `yarn twenty server start --test` | Spustí testovací instanci (výchozí port je 2021) |
| `yarn twenty server stop --test` | Zastaví testovací instanci |
| `yarn twenty server status --test` | Zobrazí stav testovací instance, URL a přihlašovací údaje |
| `yarn twenty server logs --test` | Streamuje protokoly testovací instance |
| `yarn twenty server reset --test` | Vymaže testovací data a začne znovu |
Testovací instance běží ve vlastním kontejneru Docker (`twenty-app-dev-test`) s vyhrazenými svazky (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) a konfigurací, takže může běžet paralelně s vaší hlavní instancí bez konfliktů. Zkombinujte `--test` s `--port` pro změnu výchozího portu 2021.
<Note>
Server vyžaduje, aby **Docker** běžel. Pokud vidíte chybu "Docker not running", ujistěte se, že je spuštěný Docker Desktop (nebo démon Dockeru).
</Note>
@@ -80,57 +80,6 @@ Předběžné tagy fungují podle očekávání: zvýšení z `1.0.0-rc.1` → `
{/* TODO: add screenshot of the Upgrade button */}
## Automatizované CI/CD (předpřipravené workflowy)
Aplikace vygenerované pomocí `create-twenty-app` jsou hned připravené se dvěma workflowy GitHub Actions ve složce `.github/workflows/`. Jsou připravené ke spuštění hned, jakmile repozitář pushnete na GitHub — pro CI není potřeba žádné další nastavení a CD vyžaduje pouze jeden secret.
### CI — `ci.yml`
Automaticky spouští integrační testy při každém pushi do `main` a u pull requestů.
**K čemu slouží:**
1. Provede checkout zdrojového kódu vaší aplikace.
2. Spustí izolovanou testovací instanci Twenty pomocí složené akce `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` (ekvivalent v CI k `yarn twenty server start --test`).
3. Povolí Corepack, nastaví Node.js podle vašeho `.nvmrc` a nainstaluje závislosti pomocí `yarn install --immutable`.
4. Spustí `yarn test` a předá `TWENTY_API_URL` a `TWENTY_API_KEY` ze spuštěné instance, aby vaše testy mohly komunikovat se skutečným serverem.
**Konfigurační volby:**
* `TWENTY_VERSION` (env, výchozí hodnota `latest`) — uzamkněte v CI používanou verzi serveru Twenty úpravou této hodnoty v `ci.yml`.
* Souběžné běhy jsou seskupeny podle `github.ref` a při nových pushích ruší právě probíhající běhy.
Nejsou potřeba žádné secrety — testovací instance je efemérní a existuje pouze po dobu běhu úlohy.
### CD — `cd.yml`
Nasazuje vaši aplikaci na nakonfigurovaný server Twenty při každém pushi do `main` a volitelně také z pull requestu, pokud je přidán štítek `deploy`.
**K čemu slouží:**
1. Provede checkout headu PR (u označených PR) nebo pushnutého commitu.
2. Spustí `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — ekvivalent v CI k `yarn twenty deploy`.
3. Spustí `twentyhq/twenty/.github/actions/install-twenty-app@main`, aby se nově nasazená verze nainstalovala do cílového workspace.
**Požadovaná konfigurace:**
| Nastavení | Kde | Účel |
| ----------------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `TWENTY_DEPLOY_URL` | `env` v `cd.yml` (výchozí `http://localhost:3000`) | Server Twenty, na který se nasazuje. Před prvním použitím to změňte na skutečnou URL vašeho serveru. |
| `TWENTY_DEPLOY_API_KEY` | GitHub repozitář **Settings → Secrets and variables → Actions** | API klíč s oprávněním k nasazení na cílovém serveru. |
<Note>
Výchozí `TWENTY_DEPLOY_URL` `http://localhost:3000` je pouze zástupná hodnota — z runneru hostovaného GitHubem tato adresa nebude dosažitelná. Před povolením CD ji aktualizujte na veřejnou URL vašeho serveru (nebo použijte self-hosted runner s přístupem do sítě).
</Note>
**Spuštění náhledového nasazení z PR:**
Přidejte k pull requestu štítek `deploy`. Podmínka `if:` v `cd.yml` spustí úlohu pro dané PR s použitím head commitu PR, což vám umožní ověřit změnu na cílovém serveru před sloučením.
### Připnutí verzí znovupoužitelných akcí
Obě workflowy odkazují na znovupoužitelné akce na `@main`, takže aktualizace akcí v repozitáři `twentyhq/twenty` se přeberou automaticky. Pokud chcete deterministická sestavení, nahraďte `@main` v každém řádku `uses:` za commit SHA nebo tag vydání.
## Publikování na npm
Publikování na npm zajistí, že bude vaše aplikace dohledatelná v Marketplace Twenty. Jakýkoli pracovní prostor Twenty může procházet, instalovat a aktualizovat aplikace z Marketplace přímo z UI.
@@ -138,7 +87,7 @@ Publikování na npm zajistí, že bude vaše aplikace dohledatelná v Marketpla
### Požadavky
* Účet na [npm](https://www.npmjs.com)
* Klíčové slovo `twenty-app` ve vašem poli `keywords` v souboru `package.json` (přidejte je ručně — ve výchozím nastavení není zahrnuto v šabloně `create-twenty-app`)
* Klíčové slovo `twenty-app` ve vašem poli `keywords` v souboru `package.json` (již je zahrnuto, když založíte projekt pomocí `create-twenty-app`)
```json filename="package.json"
{
@@ -476,7 +476,7 @@ export default defineLogicFunction({
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: true,
isAuthRequired: false,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
@@ -220,7 +220,6 @@ Der Scaffolder hat bereits einen lokalen Twenty-Server für Sie gestartet. Um ih
| -------------------------------------- | ----------------------------------------------------------- |
| `yarn twenty server start` | Lokalen Server starten (lädt das Image bei Bedarf herunter) |
| `yarn twenty server start --port 3030` | Auf einem benutzerdefinierten Port starten |
| `yarn twenty server start --test` | Starten Sie eine separate Testinstanz auf Port 2021 |
| `yarn twenty server stop` | Server stoppen (Daten bleiben erhalten) |
| `yarn twenty server status` | Serverstatus, URL und Anmeldedaten anzeigen |
| `yarn twenty server logs` | Serverprotokolle streamen |
@@ -229,20 +228,6 @@ Der Scaffolder hat bereits einen lokalen Twenty-Server für Sie gestartet. Um ih
Daten bleiben über Neustarts hinweg in zwei Docker-Volumes bestehen (`twenty-app-dev-data` für PostgreSQL, `twenty-app-dev-storage` für Dateien). Verwenden Sie `reset`, um alles zu löschen und neu zu beginnen.
### Eine Testinstanz ausführen
Übergeben Sie `--test` an jeden `server`-Befehl, um eine zweite, vollständig isolierte Instanz zu verwalten — nützlich, um Integrationstests auszuführen oder zu experimentieren, ohne Ihre Hauptentwicklungsdaten anzutasten.
| Befehl | Beschreibung |
| ---------------------------------- | ----------------------------------------------------- |
| `yarn twenty server start --test` | Die Testinstanz starten (standardmäßig Port 2021) |
| `yarn twenty server stop --test` | Die Testinstanz stoppen |
| `yarn twenty server status --test` | Status, URL und Anmeldedaten der Testinstanz anzeigen |
| `yarn twenty server logs --test` | Protokolle der Testinstanz streamen |
| `yarn twenty server reset --test` | Alle Testdaten löschen und neu starten |
Die Testinstanz läuft in einem eigenen Docker-Container (`twenty-app-dev-test`) mit dedizierten Volumes (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) und eigener Konfiguration, sodass sie parallel zu Ihrer Hauptinstanz ohne Konflikte ausgeführt werden kann. Kombinieren Sie `--test` mit `--port`, um den Standardport 2021 zu überschreiben.
<Note>
Der Server erfordert, dass **Docker** läuft. Wenn der Fehler "Docker not running" angezeigt wird, stellen Sie sicher, dass Docker Desktop (oder der Docker-Daemon) gestartet ist.
</Note>
@@ -80,57 +80,6 @@ Pre-Release-Tags funktionieren wie erwartet: Das Erhöhen von `1.0.0-rc.1` → `
{/* TODO: add screenshot of the Upgrade button */}
## Automatisiertes CI/CD (vorgefertigte Workflows)
Apps, die mit `create-twenty-app` erzeugt wurden, enthalten von Haus aus zwei GitHub-Actions-Workflows unter `.github/workflows/`. Sie sind einsatzbereit, sobald Sie das Repository zu GitHub pushen — für CI ist keine zusätzliche Einrichtung erforderlich, und für CD ist nur ein einziges Secret nötig.
### CI — `ci.yml`
Führt Ihre Integrationstests bei jedem Push auf `main` und bei Pull Requests aus.
**Was sie macht:**
1. Checkt den Quellcode Ihrer App aus.
2. Startet eine isolierte Twenty-Testinstanz mithilfe der Composite-Action `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` (das CI-Äquivalent zu `yarn twenty server start --test`).
3. Aktiviert Corepack, richtet Node.js anhand Ihrer `.nvmrc` ein und installiert Abhängigkeiten mit `yarn install --immutable`.
4. Führt `yarn test` aus und übergibt `TWENTY_API_URL` und `TWENTY_API_KEY` aus der gestarteten Instanz, damit Ihre Tests mit einem echten Server kommunizieren können.
**Konfigurationsoptionen:**
* `TWENTY_VERSION` (env, standardmäßig `latest`) — fixieren Sie die in CI verwendete Twenty-Server-Version, indem Sie dies in `ci.yml` anpassen.
* Die Parallelität wird nach `github.ref` gruppiert und bricht laufende Ausführungen bei neuen Pushes ab.
Es sind keine Secrets erforderlich — die Testinstanz ist flüchtig und existiert nur für die Dauer des Jobs.
### CD — `cd.yml`
Stellt Ihre App bei jedem Push auf `main` auf einem konfigurierten Twenty-Server bereit und optional aus einem Pull Request, wenn das Label `deploy` gesetzt ist.
**Was sie macht:**
1. Checkt den PR-Head (bei PRs mit Label) oder den gepushten Commit aus.
2. Führt `twentyhq/twenty/.github/actions/deploy-twenty-app@main` aus — das CI-Äquivalent zu `yarn twenty deploy`.
3. Führt `twentyhq/twenty/.github/actions/install-twenty-app@main` aus, damit die neu bereitgestellte Version in den Ziel-Workspace installiert wird.
**Erforderliche Konfiguration:**
| Einstellung | Wo | Zweck |
| ----------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `TWENTY_DEPLOY_URL` | `env` in `cd.yml` (standardmäßig `http://localhost:3000`) | Der Twenty-Server, auf den bereitgestellt werden soll. Ändern Sie dies vor der ersten Verwendung auf die echte Server-URL. |
| `TWENTY_DEPLOY_API_KEY` | GitHub-Repository **Settings → Secrets and variables → Actions** | API-Schlüssel mit Berechtigung zum Bereitstellen auf dem Zielserver. |
<Note>
Der Standardwert von `TWENTY_DEPLOY_URL` (`http://localhost:3000`) ist ein Platzhalter — von einem GitHub-gehosteten Runner ist er nicht erreichbar. Aktualisieren Sie sie auf die öffentliche URL Ihres Servers (oder verwenden Sie einen selbstgehosteten Runner mit Netzwerkzugriff), bevor Sie CD aktivieren.
</Note>
**Eine Vorschau-Bereitstellung aus einem PR auslösen:**
Fügen Sie einem Pull Request das Label `deploy` hinzu. Die `if:`-Bedingung in `cd.yml` führt den Job für diesen PR mit dem Head-Commit des PR aus, sodass Sie eine Änderung auf dem Zielserver vor dem Mergen validieren können.
### Fixieren der wiederverwendbaren Actions
Beide Workflows verweisen auf wiederverwendbare Actions mit `@main`, sodass Aktualisierungen der Actions im Repository `twentyhq/twenty` automatisch übernommen werden. Wenn Sie deterministische Builds möchten, ersetzen Sie `@main` in jeder `uses:`-Zeile durch eine Commit-SHA oder einen Release-Tag.
## Auf npm veröffentlichen
Die Veröffentlichung auf npm macht Ihre App im Twenty-Marktplatz auffindbar. Jeder Twenty-Arbeitsbereich kann Marktplatz-Apps direkt über die Benutzeroberfläche durchsuchen, installieren und aktualisieren.
@@ -138,7 +87,7 @@ Die Veröffentlichung auf npm macht Ihre App im Twenty-Marktplatz auffindbar. Je
### Anforderungen
* Ein [npm](https://www.npmjs.com)-Konto
* Das Schlüsselwort `twenty-app` in Ihrem `package.json`-Array `keywords` (manuell hinzufügen — es ist in der `create-twenty-app`-Vorlage standardmäßig nicht enthalten)
* Das Schlüsselwort `twenty-app` in Ihrem `package.json`-Array `keywords` (bereits enthalten, wenn Sie mit `create-twenty-app` ein Gerüst erstellen)
```json filename="package.json"
{
@@ -476,7 +476,7 @@ export default defineLogicFunction({
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: true,
isAuthRequired: false,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
@@ -220,7 +220,6 @@ Lo scaffolder ha già avviato per te un server Twenty locale. Per gestirlo in se
| -------------------------------------- | --------------------------------------------------------- |
| `yarn twenty server start` | Avvia il server locale (scarica l'immagine se necessario) |
| `yarn twenty server start --port 3030` | Avvia su una porta personalizzata |
| `yarn twenty server start --test` | Avvia un'istanza di test separata sulla porta 2021 |
| `yarn twenty server stop` | Arresta il server (conserva i dati) |
| `yarn twenty server status` | Mostra stato del server, URL e credenziali |
| `yarn twenty server logs` | Trasmetti in streaming i log del server |
@@ -229,20 +228,6 @@ Lo scaffolder ha già avviato per te un server Twenty locale. Per gestirlo in se
I dati vengono mantenuti tra i riavvii in due volumi Docker (`twenty-app-dev-data` per PostgreSQL, `twenty-app-dev-storage` per i file). Usa `reset` per cancellare tutto e ripartire da zero.
### Esecuzione di un'istanza di test
Passa `--test` a qualsiasi comando `server` per gestire una seconda istanza completamente isolata — utile per eseguire test di integrazione o per sperimentare senza toccare i tuoi dati di sviluppo principali.
| Comando | Descrizione |
| ---------------------------------- | ------------------------------------------------------------------------ |
| `yarn twenty server start --test` | Avvia l'istanza di test (per impostazione predefinita usa la porta 2021) |
| `yarn twenty server stop --test` | Arresta l'istanza di test |
| `yarn twenty server status --test` | Mostra stato, URL e credenziali dell'istanza di test |
| `yarn twenty server logs --test` | Trasmetti in streaming i log dell'istanza di test |
| `yarn twenty server reset --test` | Cancella i dati di test e riparti da zero |
L'istanza di test viene eseguita nel proprio container Docker (`twenty-app-dev-test`) con volumi dedicati (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) e configurazione dedicata, così può essere eseguita in parallelo con la tua istanza principale senza conflitti. Combina `--test` con `--port` per sovrascrivere il valore predefinito 2021.
<Note>
Il server richiede che **Docker** sia in esecuzione. Se vedi l'errore "Docker not running", assicurati che Docker Desktop (o il demone Docker) sia avviato.
</Note>
@@ -80,57 +80,6 @@ I tag di pre-release funzionano come previsto: incrementare `1.0.0-rc.1` → `1.
{/* TODO: add screenshot of the Upgrade button */}
## CI/CD automatizzati (workflow preconfigurati)
Le app generate con `create-twenty-app` includono due workflow di GitHub Actions pronti all'uso, nella cartella `.github/workflows/`. Sono pronti all'esecuzione non appena esegui il push del repository su GitHub — non è necessaria alcuna configurazione aggiuntiva per la CI e la CD richiede solo un singolo secret.
### CI — `ci.yml`
Esegue automaticamente i test di integrazione a ogni push su `main` e sulle pull request.
**Cosa fa:**
1. Esegue il checkout del codice sorgente della tua app.
2. Avvia un'istanza di test isolata di Twenty utilizzando l'azione composita `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` (l'equivalente per la CI di `yarn twenty server start --test`).
3. Abilita Corepack, configura Node.js dal tuo `.nvmrc` e installa le dipendenze con `yarn install --immutable`.
4. Esegue `yarn test`, passando `TWENTY_API_URL` e `TWENTY_API_KEY` dall'istanza avviata affinché i tuoi test possano comunicare con un server reale.
**Opzioni di configurazione:**
* `TWENTY_VERSION` (variabile di ambiente, predefinito `latest`) — fissa la versione del server Twenty usata nella CI modificando questo valore in `ci.yml`.
* La concorrenza è raggruppata per `github.ref` e annulla le esecuzioni in corso in caso di nuovi push.
Non sono necessari Secrets — l'istanza di test è effimera ed esiste solo per la durata del job.
### CD — `cd.yml`
Esegue il deploy della tua app su un server Twenty configurato a ogni push su `main` e, facoltativamente, da una pull request quando viene applicata l'etichetta `deploy`.
**Cosa fa:**
1. Esegue il checkout della testa della PR (per le PR etichettate) oppure del commit inviato.
2. Esegue `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — l'equivalente per la CI di `yarn twenty deploy`.
3. Esegue `twentyhq/twenty/.github/actions/install-twenty-app@main` in modo che la versione appena distribuita venga installata nello spazio di lavoro di destinazione.
**Configurazione richiesta:**
| Impostazione | Dove | Scopo |
| ----------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `TWENTY_DEPLOY_URL` | `env` in `cd.yml` (predefinito `http://localhost:3000`) | Il server Twenty su cui effettuare il deploy. Modificalo con l'URL reale del tuo server prima del primo utilizzo. |
| `TWENTY_DEPLOY_API_KEY` | Repository GitHub **Settings → Secrets and variables → Actions** | Chiave API con autorizzazione di deploy sul server di destinazione. |
<Note>
Il valore predefinito di `TWENTY_DEPLOY_URL`, `http://localhost:3000`, è un segnaposto — non raggiungerà alcuna risorsa da un runner ospitato su GitHub. Aggiornalo all'URL pubblico del tuo server (oppure usa un runner self-hosted con accesso di rete) prima di abilitare il CD.
</Note>
**Attivare un deploy di anteprima da una PR:**
Aggiungi l'etichetta `deploy` a una pull request. La condizione `if:` in `cd.yml` eseguirà il job per quella PR utilizzando il commit di testa della PR, permettendoti di convalidare una modifica sul server di destinazione prima del merge.
### Bloccare le azioni riutilizzabili
Entrambi i workflow fanno riferimento ad azioni riutilizzabili a `@main`, quindi gli aggiornamenti delle azioni nel repository `twentyhq/twenty` vengono recepiti automaticamente. Se desideri build deterministiche, sostituisci `@main` con uno SHA di commit o un tag di release in ciascuna riga `uses:`.
## Pubblicazione su npm
La pubblicazione su npm rende la tua app scopribile nel marketplace di Twenty. Qualsiasi spazio di lavoro Twenty può sfogliare, installare e aggiornare le app del marketplace direttamente dall'interfaccia utente.
@@ -138,7 +87,7 @@ La pubblicazione su npm rende la tua app scopribile nel marketplace di Twenty. Q
### Requisiti
* Un account [npm](https://www.npmjs.com)
* La parola chiave `twenty-app` nell'array `keywords` del tuo `package.json` (aggiungila manualmente — non è inclusa per impostazione predefinita nel template `create-twenty-app`)
* La parola chiave `twenty-app` nell'array `keywords` del tuo `package.json` (già inclusa quando inizializzi con `create-twenty-app`)
```json filename="package.json"
{
@@ -476,7 +476,7 @@ export default defineLogicFunction({
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: true,
isAuthRequired: false,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
@@ -220,7 +220,6 @@ A ferramenta de scaffolding já iniciou um servidor local do Twenty para você.
| -------------------------------------- | ------------------------------------------------------ |
| `yarn twenty server start` | Inicia o servidor local (baixa a imagem se necessário) |
| `yarn twenty server start --port 3030` | Iniciar em uma porta personalizada |
| `yarn twenty server start --test` | Inicie uma instância de teste separada na porta 2021 |
| `yarn twenty server stop` | Interrompe o servidor (preserva os dados) |
| `yarn twenty server status` | Mostra o status do servidor, a URL e as credenciais |
| `yarn twenty server logs` | Transmite os logs do servidor |
@@ -229,20 +228,6 @@ A ferramenta de scaffolding já iniciou um servidor local do Twenty para você.
Os dados são persistidos entre reinicializações em dois volumes do Docker (`twenty-app-dev-data` para PostgreSQL, `twenty-app-dev-storage` para arquivos). Use `reset` para apagar tudo e começar do zero.
### Executando uma instância de teste
Passe `--test` para qualquer comando de `server` para gerenciar uma segunda instância totalmente isolada — útil para executar testes de integração ou experimentar sem tocar nos seus dados principais de desenvolvimento.
| Comando | Descrição |
| ---------------------------------- | ------------------------------------------------------------- |
| `yarn twenty server start --test` | Inicia a instância de teste (padrão: porta 2021) |
| `yarn twenty server stop --test` | Interrompe a instância de teste |
| `yarn twenty server status --test` | Mostra o status da instância de teste, a URL e as credenciais |
| `yarn twenty server logs --test` | Transmite os logs da instância de teste |
| `yarn twenty server reset --test` | Exclui os dados de teste e inicia do zero |
A instância de teste é executada em seu próprio contêiner Docker (`twenty-app-dev-test`) com volumes dedicados (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) e configuração própria, para que possa ser executada em paralelo com sua instância principal sem conflitos. Combine `--test` com `--port` para substituir a porta padrão 2021.
<Note>
O servidor requer que o **Docker** esteja em execução. Se você vir um erro "Docker not running", certifique-se de que o Docker Desktop (ou o daemon do Docker) esteja iniciado.
</Note>
@@ -80,57 +80,6 @@ Tags de pré-lançamento funcionam como esperado: incrementar `1.0.0-rc.1` → `
{/* TODO: add screenshot of the Upgrade button */}
## CI/CD automatizado (fluxos de trabalho pré-configurados)
Os apps gerados com `create-twenty-app` já vêm com dois fluxos de trabalho do GitHub Actions prontos, em `.github/workflows/`. Eles estão prontos para executar assim que você fizer push do repositório para o GitHub — nenhuma configuração extra é necessária para CI, e CD requer apenas um único segredo.
### CI — `ci.yml`
Executa testes de integração a cada push para `main` e a cada pull request.
**O que faz:**
1. Faz checkout do código-fonte do seu app.
2. Inicia uma instância de teste do Twenty isolada usando a ação composta `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` (o equivalente em CI de `yarn twenty server start --test`).
3. Habilita o Corepack, configura o Node.js a partir do seu `.nvmrc` e instala as dependências com `yarn install --immutable`.
4. Executa `yarn test`, passando `TWENTY_API_URL` e `TWENTY_API_KEY` da instância iniciada para que seus testes possam se comunicar com um servidor real.
**Opções de configuração:**
* `TWENTY_VERSION` (env, padrão `latest`) — fixe a versão do servidor Twenty usada no CI editando isto em `ci.yml`.
* A concorrência é agrupada por `github.ref` e cancela execuções em andamento quando há novos pushes.
Nenhum segredo é necessário — a instância de teste é efêmera e existe apenas durante a execução do job.
### CD — `cd.yml`
Faz o deploy do seu app para um servidor Twenty configurado a cada push para `main` e, opcionalmente, a partir de um pull request quando o rótulo `deploy` é aplicado.
**O que faz:**
1. Faz checkout do head do PR (para PRs rotulados) ou do commit enviado.
2. Executa `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — o equivalente em CI de `yarn twenty deploy`.
3. Executa `twentyhq/twenty/.github/actions/install-twenty-app@main` para que a versão recém-implantada seja instalada no workspace de destino.
**Configuração obrigatória:**
| Configuração | Onde | Finalidade |
| ----------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `TWENTY_DEPLOY_URL` | `env` em `cd.yml` (padrão `http://localhost:3000`) | O servidor Twenty para o qual fazer o deploy. Altere isto para a URL real do seu servidor antes do primeiro uso. |
| `TWENTY_DEPLOY_API_KEY` | Repositório do GitHub **Settings → Secrets and variables → Actions** | Chave de API com permissão de deploy no servidor de destino. |
<Note>
O `TWENTY_DEPLOY_URL` padrão de `http://localhost:3000` é um placeholder — ele não alcançará nada a partir de um runner hospedado pelo GitHub. Atualize-o para a URL pública do seu servidor (ou use um runner self-hosted com acesso à rede) antes de habilitar o CD.
</Note>
**Acionando um deploy de pré-visualização a partir de um PR:**
Adicione o rótulo `deploy` a um pull request. A condição `if:` em `cd.yml` executará o job para esse PR usando o commit HEAD do PR, permitindo que você valide uma alteração no servidor de destino antes de fazer o merge.
### Fixando as ações reutilizáveis
Ambos os fluxos de trabalho fazem referência a ações reutilizáveis em `@main`, portanto as atualizações de ações no repositório `twentyhq/twenty` são aplicadas automaticamente. Se você quiser builds determinísticos, substitua `@main` por um SHA de commit ou uma tag de release em cada linha `uses:`.
## Publicação no npm
Publicar no npm torna seu aplicativo descobrível no Marketplace da Twenty. Qualquer espaço de trabalho da Twenty pode navegar, instalar e atualizar aplicativos do Marketplace diretamente pela UI.
@@ -138,7 +87,7 @@ Publicar no npm torna seu aplicativo descobrível no Marketplace da Twenty. Qual
### Requisitos
* Uma conta no [npm](https://www.npmjs.com)
* A palavra-chave `twenty-app` no array `keywords` do seu `package.json` (adicione-a manualmente — não é incluída por padrão no template `create-twenty-app`)
* A palavra-chave `twenty-app` no array `keywords` do seu `package.json` (já incluída quando você cria o projeto com `create-twenty-app`)
```json filename="package.json"
{
@@ -476,7 +476,7 @@ export default defineLogicFunction({
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: true,
isAuthRequired: false,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
@@ -220,7 +220,6 @@ npx create-twenty-app@latest my-twenty-app --example postcard
| -------------------------------------- | -------------------------------------------------------------- |
| `yarn twenty server start` | Запустить локальный сервер (при необходимости скачивает образ) |
| `yarn twenty server start --port 3030` | Запустить на пользовательском порту |
| `yarn twenty server start --test` | Запускает отдельный тестовый экземпляр на порту 2021 |
| `yarn twenty server stop` | Остановить сервер (данные сохраняются) |
| `yarn twenty server status` | Показать состояние сервера, URL и учётные данные |
| `yarn twenty server logs` | Потоковый вывод журналов сервера |
@@ -229,20 +228,6 @@ npx create-twenty-app@latest my-twenty-app --example postcard
Данные сохраняются между перезапусками в двух томах Docker (`twenty-app-dev-data` для PostgreSQL, `twenty-app-dev-storage` для файлов). Используйте `reset`, чтобы стереть всё и начать заново.
### Запуск тестового экземпляра
Передайте `--test` любой команде `server`, чтобы управлять вторым, полностью изолированным экземпляром — это полезно для запуска интеграционных тестов или экспериментов, не затрагивая ваши основные данные разработки.
| Команда | Описание |
| ---------------------------------- | ------------------------------------------------------------- |
| `yarn twenty server start --test` | Запустить тестовый экземпляр (по умолчанию — порт 2021) |
| `yarn twenty server stop --test` | Остановить тестовый экземпляр |
| `yarn twenty server status --test` | Показать состояние тестового экземпляра, URL и учётные данные |
| `yarn twenty server logs --test` | Выводить журналы тестового экземпляра в потоковом режиме |
| `yarn twenty server reset --test` | Удалить тестовые данные и начать с чистого листа |
Тестовый экземпляр запускается в собственном контейнере Docker (`twenty-app-dev-test`) с выделенными томами (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) и собственной конфигурацией, поэтому он может работать параллельно с вашим основным экземпляром без конфликтов. Совместите `--test` с `--port`, чтобы переопределить значение по умолчанию (2021).
<Note>
Для работы сервера необходимо, чтобы **Docker** был запущен. Если вы видите ошибку "Docker not running", убедитесь, что запущен Docker Desktop (или демон Docker).
</Note>
@@ -80,57 +80,6 @@ When updating an already deployed tarball app, the server requires the `version`
{/* TODO: add screenshot of the Upgrade button */}
## Автоматизированный CI/CD (рабочие процессы, сгенерированные шаблоном)
Приложения, созданные с помощью `create-twenty-app`, «из коробки» включают два рабочих процесса GitHub Actions в каталоге `.github/workflows/`. Они готовы к запуску, как только вы запушите репозиторий на GitHub — для CI не требуется дополнительной настройки, а для CD нужен лишь один секрет.
### CI — `ci.yml`
Автоматически запускает интеграционные тесты при каждом пуше в `main` и для каждого pull request.
**Что делает:**
1. Извлекает исходный код вашего приложения.
2. Запускает изолированный тестовый экземпляр Twenty с помощью составного действия `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` (эквивалент для CI `yarn twenty server start --test`).
3. Включает Corepack, настраивает Node.js на основе вашего `.nvmrc` и устанавливает зависимости с помощью `yarn install --immutable`.
4. Запускает `yarn test`, передавая `TWENTY_API_URL` и `TWENTY_API_KEY` из запущенного экземпляра, чтобы ваши тесты могли взаимодействовать с реальным сервером.
**Параметры конфигурации:**
* `TWENTY_VERSION` (переменная окружения, по умолчанию `latest`) — зафиксируйте версию сервера Twenty, используемую в CI, отредактировав это значение в `ci.yml`.
* Параллельные запуски группируются по `github.ref` и отменяют выполняющиеся прогоны при новых пушах.
Секреты не требуются — тестовый экземпляр эфемерен и существует только на время выполнения задания.
### CD — `cd.yml`
Разворачивает ваше приложение на настроенном сервере Twenty при каждом пуше в `main` и, при необходимости, из pull request при наличии метки `deploy`.
**Что делает:**
1. Извлекает head-коммит PR (для PR с меткой) или запушенный коммит.
2. Запускает `twentyhq/twenty/.github/actions/deploy-twenty-app@main` — эквивалент для CI `yarn twenty deploy`.
3. Запускает `twentyhq/twenty/.github/actions/install-twenty-app@main`, чтобы новая развернутая версия была установлена в целевое рабочее пространство.
**Обязательная конфигурация:**
| Настройка | Где | Назначение |
| ----------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `TWENTY_DEPLOY_URL` | `env` в `cd.yml` (по умолчанию `http://localhost:3000`) | Сервер Twenty, на который выполняется деплой. Перед первым использованием замените это на URL вашего реального сервера. |
| `TWENTY_DEPLOY_API_KEY` | В репозитории GitHub — **Settings → Secrets and variables → Actions** | Ключ API с правом деплоя на целевом сервере. |
<Note>
Значение `TWENTY_DEPLOY_URL` по умолчанию — `http://localhost:3000` — это заглушка: с хостируемого GitHub раннера к ней не будет доступа. Перед включением CD замените его на публичный URL вашего сервера (или используйте self-hosted раннер с сетевым доступом).
</Note>
**Запуск предварительного деплоя из PR:**
Добавьте к pull request метку `deploy`. Условие `if:` в `cd.yml` запустит задачу для этого PR, используя его head-коммит, что позволит проверить изменение на целевом сервере до слияния.
### Закрепление версий повторно используемых действий
Оба рабочих процесса ссылаются на повторно используемые действия с указанием `@main`, поэтому обновления действий в репозитории `twentyhq/twenty` подхватываются автоматически. Если вам нужны детерминированные сборки, замените `@main` на SHA коммита или тег релиза в каждой строке `uses:`.
## Публикация в npm
Публикация в npm делает ваше приложение видимым в маркетплейсе Twenty. Любое рабочее пространство Twenty может просматривать, устанавливать и обновлять приложения из маркетплейса непосредственно из интерфейса.
@@ -138,7 +87,7 @@ When updating an already deployed tarball app, the server requires the `version`
### Требования
* Учётная запись [npm](https://www.npmjs.com)
* Ключевое слово `twenty-app` в массиве `keywords` вашего `package.json` (добавьте его вручную — по умолчанию оно не включено в шаблон `create-twenty-app`)
* Ключевое слово `twenty-app` в массиве `keywords` вашего `package.json` (уже добавлено при создании проекта с помощью `create-twenty-app`)
```json filename="package.json"
{
@@ -476,7 +476,7 @@ export default defineLogicFunction({
httpRouteTriggerSettings: {
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: true,
isAuthRequired: false,
},
/*databaseEventTriggerSettings: {
eventName: 'people.created',
@@ -216,33 +216,18 @@ npx create-twenty-app@latest my-twenty-app --example postcard
İskelet oluşturucu sizin için zaten yerel bir Twenty sunucusu başlattı. Daha sonra yönetmek için `yarn twenty server` komutunu kullanın:
| Komut | Açıklama |
| -------------------------------------- | --------------------------------------------------------------- |
| `yarn twenty server start` | Yerel sunucuyu başlatır (gerekirse imajı çeker) |
| `yarn twenty server start --port 3030` | Özel bir portta başlatır |
| `yarn twenty server start --test` | 2021 numaralı bağlantı noktasında ayrı bir test örneği başlatın |
| `yarn twenty server stop` | Sunucuyu durdurur (verileri korur) |
| `yarn twenty server status` | Sunucu durumunu, URL'yi ve kimlik bilgilerini gösterir |
| `yarn twenty server logs` | Sunucu günlüklerini akış olarak iletir |
| `yarn twenty server logs --lines 100` | Son 100 günlük satırını gösterir |
| `yarn twenty server reset` | Tüm verileri siler ve sıfırdan başlatır |
| Komut | Açıklama |
| -------------------------------------- | ------------------------------------------------------ |
| `yarn twenty server start` | Yerel sunucuyu başlatır (gerekirse imajı çeker) |
| `yarn twenty server start --port 3030` | Özel bir portta başlatır |
| `yarn twenty server stop` | Sunucuyu durdurur (verileri korur) |
| `yarn twenty server status` | Sunucu durumunu, URL'yi ve kimlik bilgilerini gösterir |
| `yarn twenty server logs` | Sunucu günlüklerini akış olarak iletir |
| `yarn twenty server logs --lines 100` | Son 100 günlük satırını gösterir |
| `yarn twenty server reset` | Tüm verileri siler ve sıfırdan başlatır |
Veriler, yeniden başlatmalar arasında iki Docker biriminde kalıcıdır (PostgreSQL için `twenty-app-dev-data`, dosyalar için `twenty-app-dev-storage`). Her şeyi silip baştan başlamak için `reset` kullanın.
### Test örneği çalıştırma
`server` komutlarının herhangi birine `--test` parametresini vererek ikinci, tamamen yalıtılmış bir örneği yönetin — entegrasyon testlerini çalıştırmak veya ana geliştirme verilerinize dokunmadan denemeler yapmak için kullanışlıdır.
| Komut | Açıklama |
| ---------------------------------- | -------------------------------------------------------------- |
| `yarn twenty server start --test` | Test örneğini başlatır (varsayılan bağlantı noktası 2021'dir) |
| `yarn twenty server stop --test` | Test örneğini durdurur |
| `yarn twenty server status --test` | Test örneğinin durumunu, URL'yi ve kimlik bilgilerini gösterir |
| `yarn twenty server logs --test` | Test örneği günlüklerini akış olarak iletir |
| `yarn twenty server reset --test` | Test verilerini siler ve sıfırdan başlatır |
Test örneği, kendine ait bir Docker konteynerinde (`twenty-app-dev-test`), ayrılmış birimlerle (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) ve yapılandırmayla çalışır; böylece ana örneğinizle çakışma olmadan paralel olarak çalışabilir. Varsayılan 2021'i geçersiz kılmak için `--test` ile `--port`'u birlikte kullanın.
<Note>
Sunucunun çalışması için **Docker**'ın çalışıyor olması gerekir. "Docker not running" hatası görürseniz Docker Desktop'ın (veya Docker daemon'ının) başlatıldığından emin olun.
</Note>
@@ -80,57 +80,6 @@ Bir güncelleme yayımlamak için:
{/* TODO: add screenshot of the Upgrade button */}
## Otomatik CI/CD (hazır şablonlu iş akışları)
`create-twenty-app` ile oluşturulan uygulamalar, kutudan çıktığı gibi `.github/workflows/` altında iki GitHub Actions iş akışıyla gelir. Depoyu GitHuba iter itmez çalışmaya hazırdır — CI için ek bir kurulum gerekmez ve CD yalnızca tek bir gizli anahtar gerektirir.
### CI — `ci.yml`
Entegrasyon testlerini `main` dalına yapılan her itmede ve her çekme isteğinde otomatik olarak çalıştırır.
**Ne yapar:**
1. Uygulamanızın kaynak kodunu alır.
2. `twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main` bileşik eylemini kullanarak yalıtılmış bir Twenty test örneği başlatır (CI'daki `yarn twenty server start --test` eşdeğeri).
3. Corepacki etkinleştirir, `.nvmrc` dosyanızdan Node.js'i kurar ve bağımlılıkları `yarn install --immutable` ile yükler.
4. Oluşturulan örnekten `TWENTY_API_URL` ve `TWENTY_API_KEY` değerlerini aktararak `yarn test`i çalıştırır; böylece testleriniz gerçek bir sunucuyla haberleşebilir.
**Yapılandırma seçenekleri:**
* `TWENTY_VERSION` (ortam, varsayılanı `latest`) — CIda kullanılan Twenty sunucu sürümünü `ci.yml` içinde bunu düzenleyerek sabitleyin.
* Eşzamanlılık `github.ref` bazında gruplanır ve yeni itmelerde devam eden çalışmaları iptal eder.
Gizli anahtar gerekmez — test örneği geçicidir ve yalnızca iş süresi boyunca çalışır.
### CD — `cd.yml`
`main` dalına yapılan her itmede uygulamanızı yapılandırılmış bir Twenty sunucusuna dağıtır ve isteğe bağlı olarak `deploy` etiketi uygulandığında bir çekme isteğinden de dağıtım yapar.
**Ne yapar:**
1. Etiketli PR'ler için PR'in head commit'ini ya da itilen commit'i alır.
2. `twentyhq/twenty/.github/actions/deploy-twenty-app@main` çalıştırır — `yarn twenty deploy` komutunun CI eşdeğeridir.
3. `twentyhq/twenty/.github/actions/install-twenty-app@main` eylemini çalıştırır; böylece yeni dağıtılan sürüm hedef çalışma alanına kurulur.
**Gerekli yapılandırma:**
| Ayar | Koşul | Amaç |
| ----------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `TWENTY_DEPLOY_URL` | `cd.yml` içinde `env` (varsayılan: `http://localhost:3000`) | Dağıtımın yapılacağı Twenty sunucusu. İlk kullanımdan önce bunu gerçek sunucu URL'nizle değiştirin. |
| `TWENTY_DEPLOY_API_KEY` | GitHub deposu **Settings → Secrets and variables → Actions** | Hedef sunucuda dağıtım iznine sahip API anahtarı. |
<Note>
Varsayılan `TWENTY_DEPLOY_URL` olan `http://localhost:3000` bir yer tutucudur — GitHub barındırmalı bir çalıştırıcıdan hiçbir yere erişemez. CD'yi etkinleştirmeden önce bunu sunucunuzun genel URL'siyle güncelleyin (veya ağ erişimi olan öz barındırılan bir çalıştırıcı kullanın).
</Note>
**Bir PR'den bir önizleme dağıtımını tetikleme:**
Bir çekme isteğine `deploy` etiketini ekleyin. `cd.yml` içindeki `if:` koruması, ilgili PR için işi PR'in head commit'ini kullanarak çalıştırır; böylece birleştirmeden önce hedef sunucuda değişikliği doğrulayabilirsiniz.
### Yeniden kullanılabilir eylemleri sabitleme
Her iki iş akışı da `@main` üzerindeki yeniden kullanılabilir eylemlere başvurur; bu nedenle `twentyhq/twenty` deposundaki eylem güncellemeleri otomatik olarak alınır. Deterministik derlemeler istiyorsanız, her `uses:` satırında `@main` ifadesini bir commit SHA'sı veya sürüm etiketiyle değiştirin.
## npmye yayımlama
npmye yayımlamak, uygulamanızın Twenty pazaryerinde keşfedilebilir olmasını sağlar. Herhangi bir Twenty çalışma alanı, pazaryeri uygulamalarına doğrudan arayüzden göz atabilir, yükleyebilir ve güncelleyebilir.
@@ -138,7 +87,7 @@ npmye yayımlamak, uygulamanızın Twenty pazaryerinde keşfedilebilir olmas
### Gereksinimler
* Bir [npm](https://www.npmjs.com) hesabı
* `package.json` içindeki `keywords` dizinizdeki `twenty-app` anahtar sözcüğü (elle ekleyin — varsayılan olarak `create-twenty-app` şablonunda yer almaz)
* `package.json` içindeki `keywords` dizinizdeki `twenty-app` anahtar sözcüğü (`create-twenty-app` ile iskelet oluşturduğunuzda zaten eklenir)
```json filename="package.json"
{
@@ -113,6 +113,13 @@ test('Create and update record', async ({ page }) => {
await options.getByText('Hybrid').first().click({force: true});
recordFieldList.getByText('Work Preference').first().click({force: true});
// Fill previous companies
await recordFieldList.getByText('Previous Companies').first().click({force: true});
await recordFieldList.getByText('Previous Companies').nth(1).click({force: true});
await page.getByPlaceholder('Search').fill('VMw');
await page.getByRole('listbox').first().getByText('VMware').click({force: true});
await page.keyboard.press('Escape');
// Open full record page to get person ID
await page.getByRole('button', { name: /^Open/ }).click();
await page.waitForURL(/\/object\/person\//);
@@ -142,5 +149,6 @@ test('Create and update record', async ({ page }) => {
expect(findOnePersonReponseBody.data.person.linkedinLink.primaryLinkUrl).toBe('linkedin.com/johndoe');
expect(findOnePersonReponseBody.data.person.phones.primaryPhoneNumber).toBe('611223344');
expect(findOnePersonReponseBody.data.person.workPreference).toEqual(['HYBRID']);
expect(findOnePersonReponseBody.data.person.previousCompanies.edges[0].node.company.name).toBe('VMware');
});
@@ -1,5 +1,4 @@
node_modules
storybook-static
src/__stories__/example-sources-built/*
!src/__stories__/example-sources-built/bundle-sizes.json.d.ts
src/__stories__/example-sources-built
src/__stories__/example-sources-built-preact
@@ -60,7 +60,7 @@ const twentySharedAliases = Object.fromEntries(
const storyAlias = {
react: path.join(rootNodeModules, 'react'),
'react-dom': path.join(rootNodeModules, 'react-dom'),
'twenty-sdk': sdkIndividualIndex,
'@/sdk': sdkIndividualIndex,
'twenty-sdk/ui': twentyUiIndividualIndex,
...twentySharedAliases,
};
@@ -77,9 +77,6 @@ const STORY_COMPONENTS = [
'mui-example.front-component',
'twenty-ui-example.front-component',
'sdk-context-example.front-component',
'form-events.front-component',
'keyboard-events.front-component',
'host-api-calls.front-component',
];
const resolveEntryPoints = (): Record<string, string> => {
@@ -45,7 +45,7 @@ const getHtmlElementSchemas = (): ComponentSchema[] => {
events: element.events
? [...COMMON_HTML_EVENTS, ...element.events]
: COMMON_HTML_EVENTS,
htmlTag: element.htmlTag ?? extractHtmlTag(element.tag),
htmlTag: extractHtmlTag(element.tag),
}));
};
@@ -12,7 +12,6 @@ export const HtmlElementConfigZ = z.object({
.regex(/^Html[A-Z]/, 'Name must be PascalCase starting with Html'),
properties: z.record(z.string(), PropertySchemaZ),
events: z.array(z.string()).optional(),
htmlTag: z.string().optional(),
});
export const HtmlElementConfigArrayZ = z.array(HtmlElementConfigZ);
@@ -1,348 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer';
import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender';
const errorHandler = fn();
const createHostApiMocks = () => ({
navigate: fn().mockResolvedValue(undefined),
enqueueSnackbar: fn().mockResolvedValue(undefined),
openSidePanelPage: fn().mockResolvedValue(undefined),
closeSidePanel: fn().mockResolvedValue(undefined),
unmountFrontComponent: fn().mockResolvedValue(undefined),
updateProgress: fn().mockResolvedValue(undefined),
requestAccessTokenRefresh: fn().mockResolvedValue('refreshed-token'),
openCommandConfirmationModal: fn().mockResolvedValue(undefined),
});
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/EventForwarding',
component: FrontComponentRenderer,
parameters: {
layout: 'centered',
},
args: {
onError: errorHandler,
applicationAccessToken: 'fake-token',
executionContext: {
frontComponentId: 'storybook-test',
userId: null,
recordId: null,
},
colorScheme: 'light',
frontComponentHostCommunicationApi: createHostApiMocks(),
},
beforeEach: () => {
errorHandler.mockClear();
},
};
export default meta;
type Story = StoryObj<typeof FrontComponentRenderer>;
const MOUNT_TIMEOUT = 30000;
const INTERACTION_TIMEOUT = 5000;
const HOST_API_TIMEOUT = 10000;
const createComponentStory = (
name: string,
options?: { play?: Story['play'] },
): Story => ({
args: {
componentUrl: getBuiltStoryComponentPathForRender(
`${name}.front-component`,
),
},
...(options?.play ? { play: options.play } : {}),
});
const createHostApiStory = (play: Story['play']): Story => ({
...createComponentStory('host-api-calls'),
args: {
...createComponentStory('host-api-calls').args,
frontComponentHostCommunicationApi: createHostApiMocks(),
},
play,
});
export const FormTextInput: Story = createComponentStory('form-events', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'form-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const textInput = await canvas.findByTestId('text-input');
await userEvent.type(textInput, 'hello');
expect(
await canvas.findByText('hello', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
},
});
export const FormCheckbox: Story = createComponentStory('form-events', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'form-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const checkbox = await canvas.findByTestId('checkbox-input');
await userEvent.click(checkbox);
expect(
await canvas.findByText('true', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
},
});
export const FormFocusAndBlur: Story = createComponentStory('form-events', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'form-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const textInput = await canvas.findByTestId('text-input');
await userEvent.click(textInput);
expect(
await canvas.findByText('focused', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
await userEvent.click(await canvas.findByTestId('form-events-component'));
expect(
await canvas.findByText('blurred', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
},
});
export const FormSubmission: Story = createComponentStory('form-events', {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'form-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const textInput = await canvas.findByTestId('text-input');
await userEvent.type(textInput, 'hello');
const checkbox = await canvas.findByTestId('checkbox-input');
await userEvent.click(checkbox);
const submitButton = await canvas.findByTestId('submit-button');
await userEvent.click(submitButton);
expect(
await canvas.findByText(
'{"text":"hello","checkbox":true}',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
});
export const KeyboardBasicInput: Story = createComponentStory(
'keyboard-events',
{
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'keyboard-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const input = await canvas.findByTestId('keyboard-input');
await userEvent.click(input);
await userEvent.keyboard('a');
expect(
await canvas.findByText('a', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
expect(
await canvas.findByText('KeyA', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
expect(
await canvas.findByText(
/^[1-9]\d*$/,
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
},
);
export const KeyboardModifiers: Story = createComponentStory(
'keyboard-events',
{
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByTestId(
'keyboard-events-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const input = await canvas.findByTestId('keyboard-input');
await userEvent.click(input);
await userEvent.keyboard('{Shift>}b{/Shift}');
expect(
await canvas.findByText('shift', {}, { timeout: INTERACTION_TIMEOUT }),
).toBeVisible();
},
},
);
export const HostApiNavigate: Story = createHostApiStory(
async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi!;
await canvas.findByTestId(
'host-api-calls-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const navigateBtn = await canvas.findByTestId('btn-navigate');
await userEvent.click(navigateBtn);
await waitFor(
() => {
expect(api.navigate).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'navigate:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
);
export const HostApiSnackbar: Story = createHostApiStory(
async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi!;
await canvas.findByTestId(
'host-api-calls-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const snackbarBtn = await canvas.findByTestId('btn-snackbar');
await userEvent.click(snackbarBtn);
await waitFor(
() => {
expect(api.enqueueSnackbar).toHaveBeenCalledWith({
message: 'Test notification',
variant: 'success',
});
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'snackbar:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
);
export const HostApiProgress: Story = createHostApiStory(
async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi!;
await canvas.findByTestId(
'host-api-calls-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const progressBtn = await canvas.findByTestId('btn-progress');
await userEvent.click(progressBtn);
await waitFor(
() => {
expect(api.updateProgress).toHaveBeenCalledWith(50);
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'progress:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
);
export const HostApiClosePanel: Story = createHostApiStory(
async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const api = args.frontComponentHostCommunicationApi!;
await canvas.findByTestId(
'host-api-calls-component',
{},
{ timeout: MOUNT_TIMEOUT },
);
const closePanelBtn = await canvas.findByTestId('btn-close-panel');
await userEvent.click(closePanelBtn);
await waitFor(
() => {
expect(api.closeSidePanel).toHaveBeenCalled();
},
{ timeout: HOST_API_TIMEOUT },
);
expect(
await canvas.findByText(
'closePanel:success',
{},
{ timeout: INTERACTION_TIMEOUT },
),
).toBeVisible();
},
);
@@ -16,11 +16,7 @@ const meta: Meta<typeof FrontComponentRenderer> = {
args: {
onError: errorHandler,
applicationAccessToken: 'fake-token',
executionContext: {
frontComponentId: 'storybook-test',
userId: null,
recordId: null,
},
executionContext: { frontComponentId: 'storybook-test', userId: null },
},
beforeEach: () => {
errorHandler.mockClear();
@@ -96,9 +92,13 @@ export const Lifecycle: Story = {
expect(await canvas.findByText('Mounted')).toBeVisible();
expect(
await canvas.findByText(/Ticks: [1-9]\d*/, {}, { timeout: 10000 }),
).toBeVisible();
await waitFor(
() => {
const tickElement = canvas.getByTestId('tick-count');
expect(tickElement.textContent).toMatch(/Ticks: [1-9]\d*/);
},
{ timeout: 10000 },
);
},
};
@@ -121,7 +121,6 @@ export const SdkContext: Story = {
executionContext: {
frontComponentId: 'sdk-context-test',
userId: 'test-user-abc-123',
recordId: null,
},
},
play: async ({ canvasElement }) => {
@@ -16,11 +16,7 @@ const meta: Meta<typeof FrontComponentRenderer> = {
args: {
onError: errorHandler,
applicationAccessToken: 'fake-token',
executionContext: {
frontComponentId: 'storybook-test',
userId: null,
recordId: null,
},
executionContext: { frontComponentId: 'storybook-test', userId: null },
},
beforeEach: () => {
errorHandler.mockClear();
@@ -1,7 +0,0 @@
declare const bundleSizes: {
name: string;
reactBytes: number;
preactBytes: number;
}[];
export default bundleSizes;
@@ -1,6 +1,6 @@
import { defineFrontComponent } from 'twenty-sdk';
import styled from '@emotion/styled';
import { useState } from 'react';
import { defineFrontComponent } from '@/sdk';
const Card = styled.div`
padding: 24px;
@@ -1,141 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk';
import { type ChangeEvent, useState } from 'react';
const CARD_STYLE = {
padding: 24,
backgroundColor: '#f0fdf4',
border: '2px solid #22c55e',
borderRadius: 12,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
flexDirection: 'column' as const,
gap: 16,
maxWidth: 400,
};
const HEADING_STYLE = {
color: '#166534',
fontWeight: 700,
fontSize: 18,
margin: 0,
};
const LABEL_STYLE = {
fontSize: 13,
fontWeight: 600,
color: '#374151',
};
const HINT_STYLE = {
fontSize: 13,
color: '#6b7280',
};
const INPUT_STYLE = {
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: 6,
fontSize: 14,
};
const SUBMIT_BUTTON_STYLE = {
padding: '10px 20px',
backgroundColor: '#16a34a',
color: 'white',
border: 'none',
borderRadius: 6,
fontWeight: 600,
cursor: 'pointer',
};
const FormEventsComponent = () => {
const [textValue, setTextValue] = useState('');
const [checkboxValue, setCheckboxValue] = useState(false);
const [focusState, setFocusState] = useState('none');
const [submittedData, setSubmittedData] = useState<string | null>(null);
return (
<div data-testid="form-events-component" style={CARD_STYLE}>
<h2 style={HEADING_STYLE}>Form Events</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={LABEL_STYLE}>Text Input</label>
<input
data-testid="text-input"
type="text"
placeholder="Type here..."
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const detail = (event as unknown as { detail: { value?: string } })
.detail;
setTextValue(detail?.value ?? '');
}}
onFocus={() => setFocusState('focused')}
onBlur={() => setFocusState('blurred')}
style={INPUT_STYLE}
/>
<span data-testid="text-value" style={HINT_STYLE}>
{textValue}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
data-testid="checkbox-input"
type="checkbox"
checked={checkboxValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const detail = (
event as unknown as { detail: { checked?: boolean } }
).detail;
setCheckboxValue(detail?.checked ?? false);
}}
/>
<label style={LABEL_STYLE}>Check me</label>
<span data-testid="checkbox-value" style={HINT_STYLE}>
{String(checkboxValue)}
</span>
</div>
<span data-testid="focus-state" style={HINT_STYLE}>
{focusState}
</span>
<button
data-testid="submit-button"
type="button"
onClick={() =>
setSubmittedData(
JSON.stringify({ text: textValue, checkbox: checkboxValue }),
)
}
style={SUBMIT_BUTTON_STYLE}
>
Submit
</button>
{submittedData !== null && (
<pre
data-testid="submitted-data"
style={{
fontSize: 13,
background: '#dcfce7',
padding: 12,
borderRadius: 8,
margin: 0,
overflow: 'auto',
}}
>
{submittedData}
</pre>
)}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-form-00000000-0000-0000-0000-000000000020',
name: 'form-events-component',
description:
'Component testing form input events (onChange, onFocus, onBlur, submit)',
component: FormEventsComponent,
});
@@ -1,155 +0,0 @@
import {
AppPath,
closeSidePanel,
defineFrontComponent,
enqueueSnackbar,
navigate,
openSidePanelPage,
SidePanelPages,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk';
import { useState } from 'react';
const CARD_STYLE = {
padding: 24,
backgroundColor: '#faf5ff',
border: '2px solid #a78bfa',
borderRadius: 12,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
flexDirection: 'column' as const,
gap: 10,
maxWidth: 400,
};
const HEADING_STYLE = {
color: '#5b21b6',
fontWeight: 700,
fontSize: 18,
margin: 0,
};
const BUTTON_STYLE = {
padding: '8px 16px',
backgroundColor: '#7c3aed',
color: 'white',
border: 'none',
borderRadius: 6,
fontWeight: 600,
cursor: 'pointer',
fontSize: 13,
};
const STATUS_STYLE = {
fontSize: 13,
color: '#6b7280',
fontFamily: 'monospace',
};
const HostApiCallsComponent = () => {
const [apiStatus, setApiStatus] = useState('idle');
const callApi = async (name: string, apiFunction: () => Promise<void>) => {
try {
await apiFunction();
setApiStatus(`${name}:success`);
} catch (error) {
setApiStatus(
`${name}:error:${error instanceof Error ? error.message : String(error)}`,
);
}
};
return (
<div data-testid="host-api-calls-component" style={CARD_STYLE}>
<h2 style={HEADING_STYLE}>Host API Calls</h2>
<button
data-testid="btn-navigate"
type="button"
onClick={() =>
callApi('navigate', () =>
navigate(AppPath.RecordIndexPage, {
objectNamePlural: 'companies',
}),
)
}
style={BUTTON_STYLE}
>
Navigate
</button>
<button
data-testid="btn-snackbar"
type="button"
onClick={() =>
callApi('snackbar', () =>
enqueueSnackbar({
message: 'Test notification',
variant: 'success',
}),
)
}
style={BUTTON_STYLE}
>
Snackbar
</button>
<button
data-testid="btn-side-panel"
type="button"
onClick={() =>
callApi('sidePanel', () =>
openSidePanelPage({
page: SidePanelPages.ViewRecord,
pageTitle: 'Test Record',
}),
)
}
style={BUTTON_STYLE}
>
Open Side Panel
</button>
<button
data-testid="btn-close-panel"
type="button"
onClick={() => callApi('closePanel', () => closeSidePanel())}
style={BUTTON_STYLE}
>
Close Side Panel
</button>
<button
data-testid="btn-unmount"
type="button"
onClick={() => callApi('unmount', () => unmountFrontComponent())}
style={BUTTON_STYLE}
>
Unmount
</button>
<button
data-testid="btn-progress"
type="button"
onClick={() => callApi('progress', () => updateProgress(50))}
style={BUTTON_STYLE}
>
Update Progress (50)
</button>
<span data-testid="api-status" style={STATUS_STYLE}>
{apiStatus}
</span>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-hapi-00000000-0000-0000-0000-000000000022',
name: 'host-api-calls-component',
description:
'Component testing host communication API calls (navigate, snackbar, side panel, etc.)',
component: HostApiCallsComponent,
});
@@ -1,4 +1,4 @@
import { defineFrontComponent } from 'twenty-sdk';
import { defineFrontComponent } from '@/sdk';
import { useState } from 'react';
const InteractiveComponent = () => {
@@ -1,125 +0,0 @@
import { defineFrontComponent } from 'twenty-sdk';
import { type KeyboardEvent, useState } from 'react';
type RemoteKeyboardEventDetail = {
key?: string;
code?: string;
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
};
const KeyboardEventsComponent = () => {
const [lastKey, setLastKey] = useState('');
const [lastCode, setLastCode] = useState('');
const [modifiers, setModifiers] = useState('');
const [keyCount, setKeyCount] = useState(0);
// remote-dom serializes keyboard events into CustomEvent.detail
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
const data = (event as unknown as { detail: RemoteKeyboardEventDetail })
.detail;
setLastKey(data.key ?? '');
setLastCode(data.code ?? '');
setKeyCount((previousCount) => previousCount + 1);
const activeModifiers: string[] = [];
if (data.shiftKey) activeModifiers.push('shift');
if (data.ctrlKey) activeModifiers.push('ctrl');
if (data.metaKey) activeModifiers.push('meta');
if (data.altKey) activeModifiers.push('alt');
setModifiers(activeModifiers.join(','));
};
return (
<div
data-testid="keyboard-events-component"
style={{
padding: 24,
backgroundColor: '#fefce8',
border: '2px solid #eab308',
borderRadius: 12,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
flexDirection: 'column',
gap: 12,
maxWidth: 400,
}}
>
<h2
style={{
color: '#854d0e',
fontWeight: 700,
fontSize: 18,
margin: 0,
}}
>
Keyboard Events
</h2>
<input
data-testid="keyboard-input"
type="text"
placeholder="Press keys here..."
onKeyDown={handleKeyDown}
style={{
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: 6,
fontSize: 14,
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 16 }}>
<span style={{ fontSize: 13, color: '#6b7280' }}>
Key:{' '}
<span
data-testid="last-key"
style={{ fontWeight: 700, color: '#854d0e' }}
>
{lastKey}
</span>
</span>
<span style={{ fontSize: 13, color: '#6b7280' }}>
Code:{' '}
<span
data-testid="last-code"
style={{ fontWeight: 700, color: '#854d0e' }}
>
{lastCode}
</span>
</span>
</div>
<span style={{ fontSize: 13, color: '#6b7280' }}>
Modifiers:{' '}
<span
data-testid="modifiers"
style={{ fontWeight: 700, color: '#854d0e' }}
>
{modifiers}
</span>
</span>
<span style={{ fontSize: 13, color: '#6b7280' }}>
Key count:{' '}
<span
data-testid="key-count"
style={{ fontWeight: 700, color: '#854d0e' }}
>
{keyCount}
</span>
</span>
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: 'test-kbd0-00000000-0000-0000-0000-000000000021',
name: 'keyboard-events-component',
description:
'Component testing keyboard event serialization (key, code, modifiers)',
component: KeyboardEventsComponent,
});
@@ -1,4 +1,4 @@
import { defineFrontComponent } from 'twenty-sdk';
import { defineFrontComponent } from '@/sdk';
import { useEffect, useState } from 'react';
const LifecycleComponent = () => {
@@ -1,7 +1,7 @@
import { defineFrontComponent } from 'twenty-sdk';
import Button from '@mui/material/Button';
import MuiChip from '@mui/material/Chip';
import { useState } from 'react';
import { defineFrontComponent } from '@/sdk';
const MuiComponent = () => {
const [count, setCount] = useState(0);
@@ -1,9 +1,9 @@
import { useState } from 'react';
import {
defineFrontComponent,
useFrontComponentExecutionContext,
useUserId,
} from 'twenty-sdk';
import { useState } from 'react';
} from '@/sdk';
const CARD_STYLE = {
padding: 24,
@@ -72,7 +72,7 @@ const SdkContextComponent = () => {
overflow: 'auto',
}}
>
{JSON.stringify(fullContext, null, 2)}
{JSON.stringify(fullContext, null, 2) ?? 'undefined'}
</pre>
</div>
@@ -1,5 +1,5 @@
import { defineFrontComponent } from 'twenty-sdk';
import { useState } from 'react';
import { defineFrontComponent } from '@/sdk';
const SHADCN_CSS = `
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
@@ -1,4 +1,4 @@
import { defineFrontComponent } from 'twenty-sdk';
import { defineFrontComponent } from '@/sdk';
const StaticComponent = () => (
<div
@@ -1,6 +1,6 @@
import { defineFrontComponent } from 'twenty-sdk';
import { useState } from 'react';
import styled from 'styled-components';
import { defineFrontComponent } from '@/sdk';
const Card = styled.div`
padding: 24px;
@@ -1,4 +1,4 @@
import { defineFrontComponent } from 'twenty-sdk';
import { defineFrontComponent } from '@/sdk';
import { useState } from 'react';
const TAILWIND_CSS = `
@@ -1,5 +1,5 @@
import { defineFrontComponent } from 'twenty-sdk';
import { useState } from 'react';
import { defineFrontComponent } from '@/sdk';
import {
Button,
Chip,
@@ -1,12 +1,13 @@
import { type PropertySchema } from './PropertySchema';
import { SVG_PRESENTATION_PROPERTIES } from './SvgPresentationProperties';
type PropertySchema = {
type: 'string' | 'number' | 'boolean';
optional: boolean;
};
export type AllowedHtmlElement = {
tag: string;
name: string;
properties: Record<string, PropertySchema>;
events?: string[];
htmlTag?: string;
};
export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [
@@ -248,421 +249,4 @@ export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [
height: { type: 'number', optional: true },
},
},
// Semantic inline text
{ tag: 'html-b', name: 'HtmlB', properties: {} },
{ tag: 'html-i', name: 'HtmlI', properties: {} },
{ tag: 'html-u', name: 'HtmlU', properties: {} },
{ tag: 'html-s', name: 'HtmlS', properties: {} },
{ tag: 'html-mark', name: 'HtmlMark', properties: {} },
{ tag: 'html-sub', name: 'HtmlSub', properties: {} },
{ tag: 'html-sup', name: 'HtmlSup', properties: {} },
{ tag: 'html-abbr', name: 'HtmlAbbr', properties: {} },
{ tag: 'html-cite', name: 'HtmlCite', properties: {} },
{ tag: 'html-kbd', name: 'HtmlKbd', properties: {} },
{ tag: 'html-samp', name: 'HtmlSamp', properties: {} },
{ tag: 'html-var', name: 'HtmlVar', properties: {} },
{ tag: 'html-dfn', name: 'HtmlDfn', properties: {} },
{ tag: 'html-bdi', name: 'HtmlBdi', properties: {} },
{
tag: 'html-bdo',
name: 'HtmlBdo',
properties: {
dir: { type: 'string', optional: true },
},
},
{
tag: 'html-data',
name: 'HtmlData',
properties: {
value: { type: 'string', optional: true },
},
},
// Edited/annotated text
{
tag: 'html-del',
name: 'HtmlDel',
properties: {
cite: { type: 'string', optional: true },
dateTime: { type: 'string', optional: true },
},
},
{
tag: 'html-ins',
name: 'HtmlIns',
properties: {
cite: { type: 'string', optional: true },
dateTime: { type: 'string', optional: true },
},
},
{
tag: 'html-q',
name: 'HtmlQ',
properties: {
cite: { type: 'string', optional: true },
},
},
{
tag: 'html-time',
name: 'HtmlTime',
properties: {
dateTime: { type: 'string', optional: true },
},
},
// Ruby annotations
{ tag: 'html-ruby', name: 'HtmlRuby', properties: {} },
{ tag: 'html-rt', name: 'HtmlRt', properties: {} },
{ tag: 'html-rp', name: 'HtmlRp', properties: {} },
// Description lists
{ tag: 'html-dl', name: 'HtmlDl', properties: {} },
{ tag: 'html-dt', name: 'HtmlDt', properties: {} },
{ tag: 'html-dd', name: 'HtmlDd', properties: {} },
// Structural/semantic
{ tag: 'html-figure', name: 'HtmlFigure', properties: {} },
{ tag: 'html-figcaption', name: 'HtmlFigcaption', properties: {} },
{
tag: 'html-details',
name: 'HtmlDetails',
properties: {
open: { type: 'boolean', optional: true },
},
},
{ tag: 'html-summary', name: 'HtmlSummary', properties: {} },
{ tag: 'html-address', name: 'HtmlAddress', properties: {} },
{
tag: 'html-dialog',
name: 'HtmlDialog',
properties: {
open: { type: 'boolean', optional: true },
},
},
{ tag: 'html-hgroup', name: 'HtmlHgroup', properties: {} },
{ tag: 'html-search', name: 'HtmlSearch', properties: {} },
// Table additions
{ tag: 'html-caption', name: 'HtmlCaption', properties: {} },
{
tag: 'html-colgroup',
name: 'HtmlColgroup',
properties: {
span: { type: 'number', optional: true },
},
},
{
tag: 'html-col',
name: 'HtmlCol',
properties: {
span: { type: 'number', optional: true },
},
},
// Form additions
{
tag: 'html-fieldset',
name: 'HtmlFieldset',
properties: {
disabled: { type: 'boolean', optional: true },
name: { type: 'string', optional: true },
},
},
{ tag: 'html-legend', name: 'HtmlLegend', properties: {} },
{
tag: 'html-output',
name: 'HtmlOutput',
properties: {
name: { type: 'string', optional: true },
htmlFor: { type: 'string', optional: true },
},
},
{
tag: 'html-progress',
name: 'HtmlProgress',
properties: {
value: { type: 'number', optional: true },
max: { type: 'number', optional: true },
},
},
{
tag: 'html-meter',
name: 'HtmlMeter',
properties: {
value: { type: 'number', optional: true },
min: { type: 'number', optional: true },
max: { type: 'number', optional: true },
low: { type: 'number', optional: true },
high: { type: 'number', optional: true },
optimum: { type: 'number', optional: true },
},
},
{
tag: 'html-optgroup',
name: 'HtmlOptgroup',
properties: {
label: { type: 'string', optional: true },
disabled: { type: 'boolean', optional: true },
},
},
{ tag: 'html-datalist', name: 'HtmlDatalist', properties: {} },
// Media additions
{ tag: 'html-picture', name: 'HtmlPicture', properties: {} },
{
tag: 'html-track',
name: 'HtmlTrack',
properties: {
src: { type: 'string', optional: true },
kind: { type: 'string', optional: true },
srclang: { type: 'string', optional: true },
label: { type: 'string', optional: true },
default: { type: 'boolean', optional: true },
},
},
// Miscellaneous
{ tag: 'html-wbr', name: 'HtmlWbr', properties: {} },
{ tag: 'html-menu', name: 'HtmlMenu', properties: {} },
// SVG container/structural
{
tag: 'html-svg',
name: 'HtmlSvg',
properties: {
...SVG_PRESENTATION_PROPERTIES,
viewBox: { type: 'string', optional: true },
xmlns: { type: 'string', optional: true },
width: { type: 'string', optional: true },
height: { type: 'string', optional: true },
preserveAspectRatio: { type: 'string', optional: true },
},
},
{
tag: 'html-g',
name: 'HtmlG',
properties: {
...SVG_PRESENTATION_PROPERTIES,
},
},
{ tag: 'html-defs', name: 'HtmlDefs', properties: {} },
{
tag: 'html-symbol',
name: 'HtmlSymbol',
properties: {
viewBox: { type: 'string', optional: true },
},
},
{
tag: 'html-use',
name: 'HtmlUse',
properties: {
href: { type: 'string', optional: true },
x: { type: 'string', optional: true },
y: { type: 'string', optional: true },
width: { type: 'string', optional: true },
height: { type: 'string', optional: true },
},
},
{
tag: 'html-clippath',
name: 'HtmlClipPath',
htmlTag: 'clipPath',
properties: {
clipPathUnits: { type: 'string', optional: true },
},
},
{
tag: 'html-mask',
name: 'HtmlMask',
properties: {
maskUnits: { type: 'string', optional: true },
},
},
// SVG shapes
{
tag: 'html-circle',
name: 'HtmlCircle',
properties: {
...SVG_PRESENTATION_PROPERTIES,
cx: { type: 'string', optional: true },
cy: { type: 'string', optional: true },
r: { type: 'string', optional: true },
},
},
{
tag: 'html-ellipse',
name: 'HtmlEllipse',
properties: {
...SVG_PRESENTATION_PROPERTIES,
cx: { type: 'string', optional: true },
cy: { type: 'string', optional: true },
rx: { type: 'string', optional: true },
ry: { type: 'string', optional: true },
},
},
{
tag: 'html-rect',
name: 'HtmlRect',
properties: {
...SVG_PRESENTATION_PROPERTIES,
x: { type: 'string', optional: true },
y: { type: 'string', optional: true },
width: { type: 'string', optional: true },
height: { type: 'string', optional: true },
rx: { type: 'string', optional: true },
ry: { type: 'string', optional: true },
},
},
{
tag: 'html-line',
name: 'HtmlLine',
properties: {
...SVG_PRESENTATION_PROPERTIES,
x1: { type: 'string', optional: true },
y1: { type: 'string', optional: true },
x2: { type: 'string', optional: true },
y2: { type: 'string', optional: true },
},
},
{
tag: 'html-path',
name: 'HtmlPath',
properties: {
...SVG_PRESENTATION_PROPERTIES,
d: { type: 'string', optional: true },
},
},
{
tag: 'html-polygon',
name: 'HtmlPolygon',
properties: {
...SVG_PRESENTATION_PROPERTIES,
points: { type: 'string', optional: true },
},
},
{
tag: 'html-polyline',
name: 'HtmlPolyline',
properties: {
...SVG_PRESENTATION_PROPERTIES,
points: { type: 'string', optional: true },
},
},
// SVG text
{
tag: 'html-text',
name: 'HtmlText',
properties: {
...SVG_PRESENTATION_PROPERTIES,
x: { type: 'string', optional: true },
y: { type: 'string', optional: true },
dx: { type: 'string', optional: true },
dy: { type: 'string', optional: true },
textAnchor: { type: 'string', optional: true },
dominantBaseline: { type: 'string', optional: true },
},
},
{
tag: 'html-tspan',
name: 'HtmlTspan',
properties: {
...SVG_PRESENTATION_PROPERTIES,
x: { type: 'string', optional: true },
y: { type: 'string', optional: true },
dx: { type: 'string', optional: true },
dy: { type: 'string', optional: true },
},
},
// SVG gradients/patterns
{
tag: 'html-lineargradient',
name: 'HtmlLinearGradient',
htmlTag: 'linearGradient',
properties: {
x1: { type: 'string', optional: true },
y1: { type: 'string', optional: true },
x2: { type: 'string', optional: true },
y2: { type: 'string', optional: true },
gradientUnits: { type: 'string', optional: true },
gradientTransform: { type: 'string', optional: true },
},
},
{
tag: 'html-radialgradient',
name: 'HtmlRadialGradient',
htmlTag: 'radialGradient',
properties: {
cx: { type: 'string', optional: true },
cy: { type: 'string', optional: true },
r: { type: 'string', optional: true },
fx: { type: 'string', optional: true },
fy: { type: 'string', optional: true },
gradientUnits: { type: 'string', optional: true },
gradientTransform: { type: 'string', optional: true },
},
},
{
tag: 'html-stop',
name: 'HtmlStop',
properties: {
offset: { type: 'string', optional: true },
stopColor: { type: 'string', optional: true },
stopOpacity: { type: 'string', optional: true },
},
},
{
tag: 'html-pattern',
name: 'HtmlPattern',
properties: {
x: { type: 'string', optional: true },
y: { type: 'string', optional: true },
width: { type: 'string', optional: true },
height: { type: 'string', optional: true },
patternUnits: { type: 'string', optional: true },
patternTransform: { type: 'string', optional: true },
},
},
// SVG other
{
tag: 'html-image',
name: 'HtmlImage',
properties: {
href: { type: 'string', optional: true },
x: { type: 'string', optional: true },
y: { type: 'string', optional: true },
width: { type: 'string', optional: true },
height: { type: 'string', optional: true },
preserveAspectRatio: { type: 'string', optional: true },
},
},
{
tag: 'html-foreignobject',
name: 'HtmlForeignObject',
htmlTag: 'foreignObject',
properties: {
x: { type: 'string', optional: true },
y: { type: 'string', optional: true },
width: { type: 'string', optional: true },
height: { type: 'string', optional: true },
},
},
{
tag: 'html-marker',
name: 'HtmlMarker',
properties: {
markerWidth: { type: 'string', optional: true },
markerHeight: { type: 'string', optional: true },
refX: { type: 'string', optional: true },
refY: { type: 'string', optional: true },
orient: { type: 'string', optional: true },
markerUnits: { type: 'string', optional: true },
},
},
{ tag: 'html-title', name: 'HtmlTitle', properties: {} },
];
@@ -4,29 +4,24 @@ const UTILITY_TAG_MAPPINGS: Record<string, string> = {
'remote-style': 'RemoteStyle',
};
const getHostTagName = (element: (typeof ALLOWED_HTML_ELEMENTS)[number]) =>
element.htmlTag ??
(element.tag.startsWith('html-') ? element.tag.slice(5) : element.tag);
export const HTML_TAG_TO_REMOTE_COMPONENT: Record<string, string> = {
...Object.fromEntries(
ALLOWED_HTML_ELEMENTS.map((element) => [
getHostTagName(element),
element.tag.startsWith('html-') ? element.tag.slice(5) : element.tag,
element.name,
]),
),
...UTILITY_TAG_MAPPINGS,
};
// Maps standard HTML/SVG tag names to their custom element equivalents
// used by the remote DOM polyfill (e.g. "div" → "html-div",
// "clipPath" → "html-clippath").
// Maps standard HTML tag names to their custom element equivalents
// used by the remote DOM polyfill (e.g. "div" → "html-div").
// Consumed by the jsx-runtime wrapper so React creates the correct
// custom elements instead of standard HTML/SVG tags.
// custom elements instead of standard HTML tags.
export const HTML_TAG_TO_CUSTOM_ELEMENT_TAG: Record<string, string> = {
...Object.fromEntries(
ALLOWED_HTML_ELEMENTS.map((element) => [
getHostTagName(element),
element.tag.startsWith('html-') ? element.tag.slice(5) : element.tag,
element.tag,
]),
),
@@ -1,22 +0,0 @@
import { type PropertySchema } from './PropertySchema';
export const SVG_PRESENTATION_PROPERTIES: Record<string, PropertySchema> = {
fill: { type: 'string', optional: true },
fillOpacity: { type: 'string', optional: true },
fillRule: { type: 'string', optional: true },
stroke: { type: 'string', optional: true },
strokeWidth: { type: 'string', optional: true },
strokeOpacity: { type: 'string', optional: true },
strokeLinecap: { type: 'string', optional: true },
strokeLinejoin: { type: 'string', optional: true },
strokeDasharray: { type: 'string', optional: true },
strokeDashoffset: { type: 'string', optional: true },
strokeMiterlimit: { type: 'string', optional: true },
opacity: { type: 'string', optional: true },
transform: { type: 'string', optional: true },
clipPath: { type: 'string', optional: true },
clipRule: { type: 'string', optional: true },
mask: { type: 'string', optional: true },
filter: { type: 'string', optional: true },
pointerEvents: { type: 'string', optional: true },
};
@@ -92,162 +92,6 @@ export const componentRegistry: Map<string, ComponentRegistryValue> = new Map([
'html-source',
createRemoteComponentRenderer(createHtmlHostWrapper('source')),
],
['html-b', createRemoteComponentRenderer(createHtmlHostWrapper('b'))],
['html-i', createRemoteComponentRenderer(createHtmlHostWrapper('i'))],
['html-u', createRemoteComponentRenderer(createHtmlHostWrapper('u'))],
['html-s', createRemoteComponentRenderer(createHtmlHostWrapper('s'))],
['html-mark', createRemoteComponentRenderer(createHtmlHostWrapper('mark'))],
['html-sub', createRemoteComponentRenderer(createHtmlHostWrapper('sub'))],
['html-sup', createRemoteComponentRenderer(createHtmlHostWrapper('sup'))],
['html-abbr', createRemoteComponentRenderer(createHtmlHostWrapper('abbr'))],
['html-cite', createRemoteComponentRenderer(createHtmlHostWrapper('cite'))],
['html-kbd', createRemoteComponentRenderer(createHtmlHostWrapper('kbd'))],
['html-samp', createRemoteComponentRenderer(createHtmlHostWrapper('samp'))],
['html-var', createRemoteComponentRenderer(createHtmlHostWrapper('var'))],
['html-dfn', createRemoteComponentRenderer(createHtmlHostWrapper('dfn'))],
['html-bdi', createRemoteComponentRenderer(createHtmlHostWrapper('bdi'))],
['html-bdo', createRemoteComponentRenderer(createHtmlHostWrapper('bdo'))],
['html-data', createRemoteComponentRenderer(createHtmlHostWrapper('data'))],
['html-del', createRemoteComponentRenderer(createHtmlHostWrapper('del'))],
['html-ins', createRemoteComponentRenderer(createHtmlHostWrapper('ins'))],
['html-q', createRemoteComponentRenderer(createHtmlHostWrapper('q'))],
['html-time', createRemoteComponentRenderer(createHtmlHostWrapper('time'))],
['html-ruby', createRemoteComponentRenderer(createHtmlHostWrapper('ruby'))],
['html-rt', createRemoteComponentRenderer(createHtmlHostWrapper('rt'))],
['html-rp', createRemoteComponentRenderer(createHtmlHostWrapper('rp'))],
['html-dl', createRemoteComponentRenderer(createHtmlHostWrapper('dl'))],
['html-dt', createRemoteComponentRenderer(createHtmlHostWrapper('dt'))],
['html-dd', createRemoteComponentRenderer(createHtmlHostWrapper('dd'))],
[
'html-figure',
createRemoteComponentRenderer(createHtmlHostWrapper('figure')),
],
[
'html-figcaption',
createRemoteComponentRenderer(createHtmlHostWrapper('figcaption')),
],
[
'html-details',
createRemoteComponentRenderer(createHtmlHostWrapper('details')),
],
[
'html-summary',
createRemoteComponentRenderer(createHtmlHostWrapper('summary')),
],
[
'html-address',
createRemoteComponentRenderer(createHtmlHostWrapper('address')),
],
[
'html-dialog',
createRemoteComponentRenderer(createHtmlHostWrapper('dialog')),
],
[
'html-hgroup',
createRemoteComponentRenderer(createHtmlHostWrapper('hgroup')),
],
[
'html-search',
createRemoteComponentRenderer(createHtmlHostWrapper('search')),
],
[
'html-caption',
createRemoteComponentRenderer(createHtmlHostWrapper('caption')),
],
[
'html-colgroup',
createRemoteComponentRenderer(createHtmlHostWrapper('colgroup')),
],
['html-col', createRemoteComponentRenderer(createHtmlHostWrapper('col'))],
[
'html-fieldset',
createRemoteComponentRenderer(createHtmlHostWrapper('fieldset')),
],
[
'html-legend',
createRemoteComponentRenderer(createHtmlHostWrapper('legend')),
],
[
'html-output',
createRemoteComponentRenderer(createHtmlHostWrapper('output')),
],
[
'html-progress',
createRemoteComponentRenderer(createHtmlHostWrapper('progress')),
],
['html-meter', createRemoteComponentRenderer(createHtmlHostWrapper('meter'))],
[
'html-optgroup',
createRemoteComponentRenderer(createHtmlHostWrapper('optgroup')),
],
[
'html-datalist',
createRemoteComponentRenderer(createHtmlHostWrapper('datalist')),
],
[
'html-picture',
createRemoteComponentRenderer(createHtmlHostWrapper('picture')),
],
['html-track', createRemoteComponentRenderer(createHtmlHostWrapper('track'))],
['html-wbr', createRemoteComponentRenderer(createHtmlHostWrapper('wbr'))],
['html-menu', createRemoteComponentRenderer(createHtmlHostWrapper('menu'))],
['html-svg', createRemoteComponentRenderer(createHtmlHostWrapper('svg'))],
['html-g', createRemoteComponentRenderer(createHtmlHostWrapper('g'))],
['html-defs', createRemoteComponentRenderer(createHtmlHostWrapper('defs'))],
[
'html-symbol',
createRemoteComponentRenderer(createHtmlHostWrapper('symbol')),
],
['html-use', createRemoteComponentRenderer(createHtmlHostWrapper('use'))],
[
'html-clippath',
createRemoteComponentRenderer(createHtmlHostWrapper('clipPath')),
],
['html-mask', createRemoteComponentRenderer(createHtmlHostWrapper('mask'))],
[
'html-circle',
createRemoteComponentRenderer(createHtmlHostWrapper('circle')),
],
[
'html-ellipse',
createRemoteComponentRenderer(createHtmlHostWrapper('ellipse')),
],
['html-rect', createRemoteComponentRenderer(createHtmlHostWrapper('rect'))],
['html-line', createRemoteComponentRenderer(createHtmlHostWrapper('line'))],
['html-path', createRemoteComponentRenderer(createHtmlHostWrapper('path'))],
[
'html-polygon',
createRemoteComponentRenderer(createHtmlHostWrapper('polygon')),
],
[
'html-polyline',
createRemoteComponentRenderer(createHtmlHostWrapper('polyline')),
],
['html-text', createRemoteComponentRenderer(createHtmlHostWrapper('text'))],
['html-tspan', createRemoteComponentRenderer(createHtmlHostWrapper('tspan'))],
[
'html-lineargradient',
createRemoteComponentRenderer(createHtmlHostWrapper('linearGradient')),
],
[
'html-radialgradient',
createRemoteComponentRenderer(createHtmlHostWrapper('radialGradient')),
],
['html-stop', createRemoteComponentRenderer(createHtmlHostWrapper('stop'))],
[
'html-pattern',
createRemoteComponentRenderer(createHtmlHostWrapper('pattern')),
],
['html-image', createRemoteComponentRenderer(createHtmlHostWrapper('image'))],
[
'html-foreignobject',
createRemoteComponentRenderer(createHtmlHostWrapper('foreignObject')),
],
[
'html-marker',
createRemoteComponentRenderer(createHtmlHostWrapper('marker')),
],
['html-title', createRemoteComponentRenderer(createHtmlHostWrapper('title'))],
['remote-style', createRemoteComponentRenderer(RemoteStyleRenderer)],
['remote-fragment', RemoteFragmentRenderer],
]);
@@ -13,14 +13,14 @@ const EVENT_NAME_MAP: Record<string, string> = Object.fromEntries(
);
const VOID_ELEMENTS = new Set([
'area',
'base',
'input',
'br',
'col',
'embed',
'hr',
'img',
'input',
'area',
'base',
'col',
'embed',
'link',
'meta',
'source',
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More