Compare commits

..
1593 changed files with 37347 additions and 72678 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 -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
@@ -31,6 +31,7 @@
"src/remote/mock/**/*",
"src/host/generated/host-component-registry.ts",
"src/remote/generated/remote-components.ts",
"src/remote/generated/remote-elements.ts"
"src/remote/generated/remote-elements.ts",
"src/__stories__/**/*"
]
}

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