snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
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 }}

View File

@@ -0,0 +1,48 @@
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 }}

View File

@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
/e2e/.auth
/e2e/.results
/playwright-report
/test-results
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
*.d.ts

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -0,0 +1,37 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
},
"overrides": [
{
"files": ["**/*.logic-function.ts", "**/logic-functions/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["twenty-shared", "twenty-shared/*"],
"message": "Logic functions must not import from twenty-shared directly. Import runtime types and helpers from `twenty-sdk/logic-function` instead so the logic-function bundle stays minimal."
}
]
}
]
}
}
]
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -0,0 +1,13 @@
## 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/fixtures/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- 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.

View File

@@ -0,0 +1,13 @@
## 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/fixtures/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- 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.

View File

@@ -0,0 +1,13 @@
## 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/fixtures/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- 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.

View File

@@ -0,0 +1,36 @@
# Postcard App — Twenty App Example
A rich example app showcasing all Twenty app entity types. Use this as a reference when building your own apps.
## What's included
This app demonstrates every entity type available in the Twenty SDK:
| Entity | Files | What it shows |
|--------|-------|---------------|
| **Application** | `src/application.config.ts` | App metadata, application variables, server variables |
| **Objects** | `src/objects/` | Custom objects with inline fields, junction tables |
| **Fields** | `src/fields/` | Standalone fields, relations (ONE_TO_MANY, MANY_TO_ONE), extending standard objects |
| **Logic Functions** | `src/logic-functions/` | HTTP routes, database event triggers, cron schedules, tool functions, install hooks |
| **Front Components** | `src/components/` | React components rendered inside Twenty's UI |
| **Roles** | `src/roles/` | Permission roles with object and field-level access control |
| **Views** | `src/views/` | Saved table views with column configuration |
| **Navigation** | `src/navigation-menu-items/` | Sidebar links targeting views |
| **Skills** | `src/skills/` | AI skill providing context to agents |
| **Agents** | `src/agents/` | AI agent with a system prompt |
| **Page Layouts** | `src/page-layouts/` | Custom record page with a front component widget |
## Getting started
```bash
# From this directory
yarn install
yarn twenty dev
```
## Learn more
- [Getting Started](https://docs.twenty.com/developers/extend/apps/getting-started)
- [Building Apps](https://docs.twenty.com/developers/extend/apps/building)
- [Publishing](https://docs.twenty.com/developers/extend/apps/publishing)
- [Discord](https://discord.gg/cx5n4Jzs57)

View File

@@ -0,0 +1,68 @@
import { type Locator, expect, test as setup } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
const AUTH_DIR = path.resolve(__dirname, '.auth');
const STORAGE_STATE = path.join(AUTH_DIR, 'user.json');
const WORKSPACE_ORIGIN_FILE = path.join(AUTH_DIR, 'workspace-origin.txt');
const LOGIN = process.env.E2E_LOGIN ?? 'tim@apple.dev';
const PASSWORD = process.env.E2E_PASSWORD ?? 'tim@apple.dev';
const WORKSPACE_NAME = process.env.E2E_WORKSPACE_NAME ?? 'Apple';
const isVisible = async (locator: Locator) =>
locator.isVisible().catch(() => false);
setup('authenticate', async ({ page }) => {
await page.goto('/');
// A fresh load shows the auth provider choice even when credentials are prefilled.
const continueWithEmail = page.getByRole('button', {
name: 'Continue with Email',
});
const emailField = page.getByPlaceholder('Email');
// Wait on the concrete auth UI rather than networkidle (flaky per Playwright).
await expect(continueWithEmail.or(emailField).first()).toBeVisible();
if (await isVisible(continueWithEmail)) {
await continueWithEmail.click();
}
if (await isVisible(emailField)) {
await emailField.fill(LOGIN);
await page.getByRole('button', { name: 'Continue', exact: true }).click();
}
const passwordField = page.getByPlaceholder('Password');
await passwordField.waitFor({ state: 'visible' });
await passwordField.fill(PASSWORD);
const signInButton = page.getByRole('button', { name: 'Sign in' });
await expect(signInButton).toBeEnabled();
await signInButton.click();
// Multi-workspace logins land on a picker; single-workspace logins skip it.
const workspacePicker = page.getByText('Choose a workspace');
const reachedPicker = await workspacePicker
.waitFor({ state: 'visible', timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (reachedPicker) {
await page.getByText(WORKSPACE_NAME, { exact: true }).click();
await page.waitForFunction(() => window.location.href.includes('verify'));
await page.waitForFunction(() => !window.location.href.includes('verify'));
}
await page.waitForFunction(
() => window.localStorage.getItem('tokenPairState') !== null,
undefined,
{ timeout: 15_000 },
);
fs.mkdirSync(AUTH_DIR, { recursive: true });
fs.writeFileSync(WORKSPACE_ORIGIN_FILE, new URL(page.url()).origin);
await page.context().storageState({ path: STORAGE_STATE });
});

View File

@@ -0,0 +1,152 @@
import { expect, test } from '@playwright/test';
import { CARD_TEST_IDS } from '../src/components/card-test-ids';
import { resolveE2eWorkspaceUrl } from './utils/resolve-e2e-workspace-url';
// Seeded postcard record the preview should display.
const RECORD_ID = process.env.E2E_POSTCARD_RECORD_ID;
const EXPECTED_NAME = process.env.E2E_POSTCARD_NAME;
const EXPECTED_STATUS = process.env.E2E_POSTCARD_STATUS;
const EXPECTED_CONTENT = process.env.E2E_POSTCARD_CONTENT;
const STATUS_BADGE_BACKGROUND: Record<string, string> = {
DRAFT: 'rgb(153, 153, 153)',
SENT: 'rgb(232, 140, 48)',
DELIVERED: 'rgb(76, 175, 80)',
RETURNED: 'rgb(224, 82, 82)',
};
// Error states rendered by card.front-component.tsx when it cannot authenticate
// or fetch the record. None of these may appear once the component renders.
const FALLBACK_TEXTS = [
'No postcard data',
'Record not found',
'No record ID',
'apiUrl: missing',
];
test.describe('Postcard card front component', () => {
test.beforeAll(() => {
if (!RECORD_ID) {
throw new Error(
'E2E_POSTCARD_RECORD_ID is required and must point to a seeded postcard record. ' +
'Ensure the postcard app is installed and a record exists before running this test.',
);
}
});
// The front component renders in a separate iframe + Web Worker, so its
// failures never reach the assertion output. Surface browser console output,
// uncaught errors, failed requests, and 4xx/5xx responses (e.g. a presigned
// S3 403) in the test log so CI failures are diagnosable without the trace.
test.beforeEach(({ page }) => {
page.on('console', (message) => {
console.log(`[browser:${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => {
console.log(`[pageerror] ${error.message}\n${error.stack ?? ''}`);
});
page.on('requestfailed', (request) => {
console.log(
`[requestfailed] ${request.method()} ${request.url()} — ${
request.failure()?.errorText ?? 'unknown error'
}`,
);
});
page.on('response', (response) => {
if (response.status() >= 400) {
console.log(
`[response ${response.status()}] ${response
.request()
.method()} ${response.url()}`,
);
}
});
page.on('worker', (worker) => {
console.log(`[worker started] ${worker.url()}`);
});
});
test('renders the postcard name and status badge in the record preview', async ({
page,
}) => {
await page.goto(`${resolveE2eWorkspaceUrl()}/object/postCard/${RECORD_ID}`);
const card = page.getByTestId(CARD_TEST_IDS.root);
await expect(card).toBeVisible();
const cardName = card.getByTestId(CARD_TEST_IDS.name);
const cardStatus = card.getByTestId(CARD_TEST_IDS.status);
const cardContent = card.getByTestId(CARD_TEST_IDS.content);
await expect(cardName).toHaveCount(1);
await expect(cardStatus).toHaveCount(1);
await expect(cardContent).toHaveCount(1);
if (EXPECTED_NAME) {
await expect(cardName).toHaveText(EXPECTED_NAME);
}
if (EXPECTED_STATUS) {
await expect(cardStatus).toHaveText(EXPECTED_STATUS);
}
if (EXPECTED_CONTENT) {
await expect(cardContent).toHaveText(EXPECTED_CONTENT);
}
// Redundant style assertions: the front component sets every style inline, so
// verifying the computed styles proves the component's own render + the
// remote-dom style bridge ran end-to-end (not just that text leaked onto the
// page). These mirror card.front-component.tsx exactly.
// Root container.
await expect(card).toHaveCSS('padding', '24px');
// Name.
await expect(cardName).toHaveCSS('font-size', '15px');
await expect(cardName).toHaveCSS('font-weight', '600');
await expect(cardName).toHaveCSS('color', 'rgb(51, 51, 51)');
// Status badge: white text on a status-dependent colored, rounded chip.
await expect(cardStatus).toHaveCSS('color', 'rgb(255, 255, 255)');
await expect(cardStatus).toHaveCSS('font-size', '11px');
await expect(cardStatus).toHaveCSS('font-weight', '600');
await expect(cardStatus).toHaveCSS('border-radius', '4px');
await expect(cardStatus).toHaveCSS('padding-top', '2px');
await expect(cardStatus).toHaveCSS('padding-bottom', '2px');
await expect(cardStatus).toHaveCSS('padding-left', '8px');
await expect(cardStatus).toHaveCSS('padding-right', '8px');
if (EXPECTED_STATUS && EXPECTED_STATUS in STATUS_BADGE_BACKGROUND) {
await expect(cardStatus).toHaveCSS(
'background-color',
STATUS_BADGE_BACKGROUND[EXPECTED_STATUS],
);
}
// Content.
await expect(cardContent).toHaveCSS('font-size', '14px');
await expect(cardContent).toHaveCSS('color', 'rgb(85, 85, 85)');
await expect(cardContent).toHaveCSS('margin', '0px');
await expect(cardContent).toHaveCSS('white-space', 'pre-line');
for (const fallback of FALLBACK_TEXTS) {
await expect(page.getByText(fallback, { exact: false })).toHaveCount(0);
}
const sdkPanel = page.getByTestId(CARD_TEST_IDS.sdkPanel);
await expect(sdkPanel).toBeVisible();
await expect(page.getByTestId(CARD_TEST_IDS.sdkCore)).toHaveText('core: ok');
await expect(page.getByTestId(CARD_TEST_IDS.sdkMetadata)).toHaveText(
'metadata: ok',
);
await expect(page.getByTestId(CARD_TEST_IDS.sdkRest)).toHaveText('rest: ok');
});
});

View File

@@ -0,0 +1,146 @@
import { expect, test, type Page, type Response } from '@playwright/test';
import { CARD_TEST_IDS } from '../src/components/card-test-ids';
import { resolveE2eWorkspaceUrl } from './utils/resolve-e2e-workspace-url';
const RECORD_ID = process.env.E2E_POSTCARD_RECORD_ID;
const FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER = 'twenty:front-component-shared-dependencies';
const REACT_LICENSE_BANNER = '@license React';
const MIN_SHARED_DEPENDENCIES_BUNDLE_BYTES = 50_000;
const MAX_COMPONENT_TO_SHARED_DEPENDENCIES_RATIO = 0.65;
const resolveJavaScriptBody = async (
page: Page,
response: Response,
): Promise<Buffer> => {
const contentType = response.headers()['content-type'] ?? '';
if (contentType.includes('application/json')) {
const { url } = (await response.json()) as { url: string };
const redirectedResponse = await page.request.get(url);
return Buffer.from(await redirectedResponse.body());
}
return response.body();
};
const findCardComponentBundleBody = async (
page: Page,
frontComponentResponses: Response[],
): Promise<Buffer | null> => {
for (const response of frontComponentResponses) {
if (response.status() !== 200) {
continue;
}
const body = await resolveJavaScriptBody(page, response);
if (body.toString('utf-8').includes(CARD_TEST_IDS.root)) {
return body;
}
}
return null;
};
test.describe('Postcard shared dependencies bundle', () => {
test.beforeAll(() => {
if (!RECORD_ID) {
throw new Error(
'E2E_POSTCARD_RECORD_ID is required and must point to a seeded postcard record. ' +
'Ensure the postcard app is installed and a record exists before running this test.',
);
}
});
test.beforeEach(({ page }) => {
page.on('console', (message) => {
console.log(`[browser:${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => {
console.log(`[pageerror] ${error.message}\n${error.stack ?? ''}`);
});
page.on('requestfailed', (request) => {
console.log(
`[requestfailed] ${request.method()} ${request.url()} — ${
request.failure()?.errorText ?? 'unknown error'
}`,
);
});
page.on('response', (response) => {
if (response.status() >= 400) {
console.log(
`[response ${response.status()}] ${response
.request()
.method()} ${response.url()}`,
);
}
});
page.on('worker', (worker) => {
console.log(`[worker started] ${worker.url()}`);
});
});
test('serves the shared dependencies bundle and keeps react out of the component bundle', async ({
page,
}) => {
const sharedDependenciesResponses: Response[] = [];
const frontComponentResponses: Response[] = [];
page.on('response', (response) => {
const url = response.url();
if (url.includes('/rest/front-component-shared-dependencies/')) {
sharedDependenciesResponses.push(response);
}
if (url.includes('/rest/front-components/')) {
frontComponentResponses.push(response);
}
});
await page.goto(`${resolveE2eWorkspaceUrl()}/object/postCard/${RECORD_ID}`);
const card = page.getByTestId(CARD_TEST_IDS.root);
await expect(card).toBeVisible();
await expect(card.getByTestId(CARD_TEST_IDS.name)).toHaveCount(1);
expect(sharedDependenciesResponses.length).toBeGreaterThan(0);
const sharedDependenciesResponse = sharedDependenciesResponses[0];
expect(sharedDependenciesResponse.status()).toBe(200);
expect(sharedDependenciesResponse.url()).toMatch(
/\/rest\/front-component-shared-dependencies\/[0-9a-f-]{36}\/[0-9a-f]{64}\.js(?:\?[^#]*)?$/,
);
const sharedDependenciesBundleBody = await resolveJavaScriptBody(
page,
sharedDependenciesResponse,
);
expect(sharedDependenciesBundleBody.byteLength).toBeGreaterThan(
MIN_SHARED_DEPENDENCIES_BUNDLE_BYTES,
);
const cardBundleBody = await findCardComponentBundleBody(
page,
frontComponentResponses,
);
expect(cardBundleBody).not.toBeNull();
const cardBundleSource = (cardBundleBody as Buffer).toString('utf-8');
expect(cardBundleSource).toContain(FRONT_COMPONENT_SHARED_DEPENDENCIES_IMPORT_SPECIFIER);
expect(cardBundleSource).not.toContain(REACT_LICENSE_BANNER);
expect((cardBundleBody as Buffer).byteLength).toBeLessThan(
sharedDependenciesBundleBody.byteLength *
MAX_COMPONENT_TO_SHARED_DEPENDENCIES_RATIO,
);
});
});

View File

@@ -0,0 +1,25 @@
import * as fs from 'fs';
import * as path from 'path';
const WORKSPACE_ORIGIN_FILE = path.resolve(
__dirname,
'..',
'.auth',
'workspace-origin.txt',
);
export const resolveE2eWorkspaceUrl = (): string => {
const fromEnv = process.env.E2E_WORKSPACE_URL;
if (fromEnv) {
return fromEnv.replace(/\/$/, '');
}
try {
return fs
.readFileSync(WORKSPACE_ORIGIN_FILE, 'utf8')
.trim()
.replace(/\/$/, '');
} catch {
return 'http://app.localhost:3001';
}
};

View File

@@ -0,0 +1,41 @@
{
"name": "postcard",
"version": "0.1.2",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2",
"twenty": ">=2.23.0"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.13.0",
"frontComponentSharedDependencies": [
"react",
"react-dom/client"
],
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"playwright": "^1.60.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"twenty-client-sdk": "2.31.0",
"twenty-sdk": "2.31.0",
"typescript": "^5.9.3",
"vitest": "^4.0.0"
}
}

View File

@@ -0,0 +1,60 @@
import { defineConfig, devices } from '@playwright/test';
import * as path from 'path';
// WebKit on Linux ignores Playwright's `timezoneId` context emulation for
// Intl.resolvedOptions(), so it falls back to the runner's OS zone — the legacy
// `CET` alias, which WebKit's ICU rejects. `formatInTimeZone` then throws and
// crashes the record page before the front component can render. Pinning TZ
// here forces a WebKit-supported IANA zone at the OS/ICU level (the layer
// WebKit actually reads); the browser process Playwright spawns inherits it.
process.env.TZ = 'Europe/Paris';
// Front-end base URL of the running Twenty instance under test.
const FRONT_BASE_URL = process.env.FRONT_BASE_URL ?? 'http://localhost:3001';
export default defineConfig({
testDir: './e2e',
outputDir: './e2e/.results',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
timeout: 60 * 1000,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: FRONT_BASE_URL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
headless: true,
testIdAttribute: 'data-testid',
// Browser-level timezone emulation. Honored by Chromium; WebKit/Linux
// ignores it for Intl.resolvedOptions() (see the TZ pin above, which is the
// actual fix there). Kept aligned with TZ for deterministic date rendering.
timezoneId: 'Europe/Paris',
},
expect: {
timeout: 15_000,
},
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: path.resolve(__dirname, 'e2e/.auth/user.json'),
},
dependencies: ['setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: path.resolve(__dirname, 'e2e/.auth/user.json'),
},
dependencies: ['setup'],
},
],
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

View File

@@ -0,0 +1,87 @@
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 docker: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) {
throw new Error(
`App uninstall failed during teardown: ${JSON.stringify(uninstallResult.error, null, 2)}`,
);
}
}

View File

@@ -0,0 +1,56 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application.config';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
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: { paging: { first: 50 }, filter: {} },
edges: {
node: {
nameSingular: true,
fields: {
__args: { paging: { first: 500 }, filter: {} },
edges: { node: { name: true } },
},
},
},
},
});
const obj = objects.edges
.map((e) => 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,
);
console.log('names', names);
expect(names).toContain('name');
expect(names).toContain('content');
expect(names).toContain('status');
expect(names).toContain('deliveredAt');
expect(names).toContain('recipient');
});
});

View File

@@ -0,0 +1,13 @@
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'b8d4f2a3-9c5e-4f7b-a012-3e4d5c6b7a8f',
name: 'postcard-drafter',
label: 'Postcard Drafter',
icon: 'IconRobot',
description: 'Helps draft postcard messages',
prompt:
'You are a postcard writing assistant. Help users draft concise, warm ' +
'postcard messages. Follow the postcard writing guidelines. Ask for the ' +
'recipient name and the occasion if not provided.',
});

View File

@@ -0,0 +1,32 @@
import { defineApplication } from 'twenty-sdk/define';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from './roles/default-function.role';
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'8b2df3cc-23ad-4e1b-87fd-f880d4cefd58';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: 'Postcard App',
description: 'Send postcards easily with Twenty',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
description: 'Default recipient name for postcards',
value: 'Alex Karp',
isSecret: false,
},
},
serverVariables: {
POSTCARD_API_KEY: {
description: 'API key for the postcard printing service',
isSecret: true,
isRequired: true,
},
POSTCARD_SENDER_NAME: {
description: 'Default sender name on postcards',
isSecret: false,
isRequired: false,
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,19 @@
import { defineCommandMenuItem } from 'twenty-sdk/define';
import { GENERATE_POST_CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../components/generate-post-card-component-effect';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export const GENERATE_POST_CARD_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER =
'0f795c1c-8e25-44da-8962-80bef9602ee2';
export default defineCommandMenuItem({
universalIdentifier:
GENERATE_POST_CARD_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER,
label: 'Generate post card content',
shortLabel: 'Generate content',
icon: 'IconSparkles',
isPinned: true,
availabilityType: 'RECORD_SELECTION',
availabilityObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
frontComponentUniversalIdentifier:
GENERATE_POST_CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,15 @@
import { defineCommandMenuItem } from 'twenty-sdk/define';
import { SEND_POST_CARDS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../components/send-post-cards-component-effect';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineCommandMenuItem({
universalIdentifier: 'bd75de13-87a1-4f7a-94e2-92e19e97523c',
label: 'Send post cards',
shortLabel: 'Send',
icon: 'IconSend',
isPinned: true,
availabilityType: 'RECORD_SELECTION',
availabilityObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
frontComponentUniversalIdentifier:
SEND_POST_CARDS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,13 @@
// Shared between the front component and its e2e spec so the test ids cannot
// drift. Keep this module side-effect free so the Playwright test can import it
// without pulling in the component's SDK runtime dependencies.
export const CARD_TEST_IDS = {
root: 'postcard-card',
name: 'postcard-card-name',
status: 'postcard-card-status',
content: 'postcard-card-content',
sdkPanel: 'postcard-sdk-panel',
sdkCore: 'postcard-sdk-core',
sdkMetadata: 'postcard-sdk-metadata',
sdkRest: 'postcard-sdk-rest',
} as const;

View File

@@ -0,0 +1,295 @@
import { useCallback, useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { RestApiClient } from 'twenty-client-sdk/rest';
import { defineFrontComponent } from 'twenty-sdk/define';
import { useRecordId } from 'twenty-sdk/front-component';
import { isDefined } from 'src/utils/isDefined';
import { CARD_TEST_IDS } from './card-test-ids';
export const CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'88c15ae2-5f87-4a6b-b48f-1974bbe62eb7';
type PostCardRecord = {
name: string;
content: string;
status: string;
};
const STATUS_COLORS: Record<string, string> = {
DRAFT: '#999',
SENT: '#e88c30',
DELIVERED: '#4caf50',
RETURNED: '#e05252',
};
const CardDisplay = ({
name,
content,
status,
}: {
name: string;
content: string;
status: string;
}) => {
return (
<div
data-testid={CARD_TEST_IDS.root}
style={{ padding: '24px', fontFamily: 'sans-serif' }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
}}
>
<span
data-testid={CARD_TEST_IDS.name}
style={{ fontSize: '15px', fontWeight: 600, color: '#333' }}
>
{name || 'Untitled'}
</span>
<span
data-testid={CARD_TEST_IDS.status}
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: '4px',
color: '#fff',
backgroundColor: STATUS_COLORS[status] ?? '#999',
}}
>
{status}
</span>
</div>
<p
data-testid={CARD_TEST_IDS.content}
style={{
fontSize: '14px',
lineHeight: '1.6',
color: '#555',
margin: 0,
whiteSpace: 'pre-line',
}}
>
{content || 'No content yet...'}
</p>
</div>
);
};
type SdkProbeState = 'pending' | 'ok' | 'error';
const SDK_PROBE_LABEL: Record<SdkProbeState, string> = {
pending: '…',
ok: 'ok',
error: 'error',
};
const SdkProbeRow = ({
testId,
label,
state,
}: {
testId: string;
label: string;
state: SdkProbeState;
}) => (
<span
data-testid={testId}
style={{ fontSize: '11px', color: state === 'error' ? '#e05252' : '#888' }}
>
{label}: {SDK_PROBE_LABEL[state]}
</span>
);
const SdkHealthPanel = () => {
const [coreState, setCoreState] = useState<SdkProbeState>('pending');
const [metadataState, setMetadataState] = useState<SdkProbeState>('pending');
const [restState, setRestState] = useState<SdkProbeState>('pending');
useEffect(() => {
let cancelled = false;
const probe = async (
run: () => Promise<unknown>,
setState: (state: SdkProbeState) => void,
) => {
try {
await run();
if (!cancelled) {
setState('ok');
}
} catch {
if (!cancelled) {
setState('error');
}
}
};
probe(() => new CoreApiClient().query({ __typename: true }), setCoreState);
probe(
() => new MetadataApiClient().query({ __typename: true }),
setMetadataState,
);
probe(() => new RestApiClient().get('/rest/postCards'), setRestState);
return () => {
cancelled = true;
};
}, []);
return (
<div
data-testid={CARD_TEST_IDS.sdkPanel}
style={{
display: 'flex',
gap: '12px',
padding: '0 24px 16px',
fontFamily: 'sans-serif',
}}
>
<SdkProbeRow
testId={CARD_TEST_IDS.sdkCore}
label="core"
state={coreState}
/>
<SdkProbeRow
testId={CARD_TEST_IDS.sdkMetadata}
label="metadata"
state={metadataState}
/>
<SdkProbeRow
testId={CARD_TEST_IDS.sdkRest}
label="rest"
state={restState}
/>
</div>
);
};
const PostCardPreview = () => {
const recordId = useRecordId();
const [postCard, setPostCard] = useState<PostCardRecord | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPostCard = useCallback(async () => {
if (!isDefined(recordId)) {
setLoading(false);
setError('No record ID');
return;
}
try {
setError(null);
const client = new CoreApiClient();
const result = await client.query({
postCard: {
__args: {
filter: { id: { eq: recordId } },
},
name: true,
content: true,
status: true,
},
});
const record = result?.postCard;
if (!isDefined(record)) {
setError('Record not found');
setPostCard(null);
} else {
setPostCard({
name: record.name ?? '',
content: record.content ?? '',
status: record.status ?? 'DRAFT',
});
}
} catch (fetchError) {
setError(
fetchError instanceof Error ? fetchError.message : String(fetchError),
);
setPostCard(null);
}
setLoading(false);
}, [recordId]);
useEffect(() => {
fetchPostCard();
const interval = setInterval(fetchPostCard, 3000);
return () => {
clearInterval(interval);
setPostCard(null);
setLoading(false);
setError(null);
};
}, [fetchPostCard]);
if (loading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: '#b0a89a',
fontFamily: 'Georgia, serif',
fontSize: '14px',
}}
>
Loading postcard...
</div>
);
}
if (!isDefined(postCard)) {
return (
<div
style={{
padding: '16px',
color: '#b0a89a',
fontFamily: 'sans-serif',
fontSize: '13px',
}}
>
<div>{error ?? 'No postcard data'}</div>
<div style={{ marginTop: '8px', fontSize: '11px', color: '#ccc' }}>
recordId: {recordId ?? 'null'} | apiUrl:{' '}
{process.env.TWENTY_API_URL ? 'set' : 'missing'} | token:{' '}
{(process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY)
? 'set'
: 'missing'}
</div>
</div>
);
}
return (
<>
<CardDisplay
name={postCard.name}
content={postCard.content}
status={postCard.status}
/>
<SdkHealthPanel />
</>
);
};
export default defineFrontComponent({
universalIdentifier: CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'card-component',
description: 'A component using an external component file',
component: PostCardPreview,
});

View File

@@ -0,0 +1,117 @@
import { useEffect } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
unmountFrontComponent,
updateProgress,
useRecordId,
} from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const SYSTEM_PROMPT =
'You are a postcard writing assistant. Write a short, warm postcard message ' +
'under 150 words. Use a personal tone. Output ONLY the postcard message ' +
'text, nothing else — no greeting label, no sign-off label, just the message.';
const GeneratePostCardEffect = () => {
const recordId = useRecordId();
useEffect(() => {
if (recordId === null) {
enqueueSnackbar({
message: 'Please select exactly one record',
variant: 'error',
});
unmountFrontComponent();
return;
}
const generate = async () => {
try {
const client = new CoreApiClient();
const userPrompt = 'Write a postcard message for a friend.';
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
if (!apiBaseUrl || !token) {
throw new Error('API configuration missing');
}
const response = await fetch(`${apiBaseUrl}/rest/ai/generate-text`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
systemPrompt: SYSTEM_PROMPT,
userPrompt,
}),
});
if (!response.ok) {
const errorBody = await response.text();
if (errorBody.includes('No AI models are available')) {
throw new Error(
'No AI models configured. Go to Settings > Admin Panel > AI to add a provider API key.',
);
}
throw new Error(`AI request failed with status ${response.status}`);
}
const data = (await response.json()) as { text?: string };
const generatedContent = data.text ?? '';
await updateProgress(0.7);
await client.mutation({
updatePostCard: {
__args: {
id: recordId,
data: { content: generatedContent },
},
id: true,
},
});
await updateProgress(1);
await enqueueSnackbar({
message: 'Post card content generated!',
variant: 'success',
});
await unmountFrontComponent();
} catch (error) {
await enqueueSnackbar({
message:
error instanceof Error
? error.message
: 'Failed to generate content',
variant: 'error',
});
await unmountFrontComponent();
}
};
generate();
}, [recordId]);
return null;
};
export const GENERATE_POST_CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'bd7649c0-7540-4267-ac4b-f062fbd635a3';
export default defineFrontComponent({
universalIdentifier: GENERATE_POST_CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Generate Post Card',
description: 'Generates postcard content using AI',
isHeadless: true,
component: GeneratePostCardEffect,
});

View File

@@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
unmountFrontComponent,
updateProgress,
useRecordId,
} from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
const SendPostCardsEffect = () => {
const recordId = useRecordId();
useEffect(() => {
const send = async () => {
try {
await updateProgress(0.1);
const client = new CoreApiClient();
await updateProgress(0.3);
if (recordId) {
await client.mutation({
updatePostCard: {
__args: {
id: recordId,
data: { status: 'SENT' },
},
id: true,
},
});
await enqueueSnackbar({
message: `Postcard sent`,
variant: 'success',
});
}
await unmountFrontComponent();
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to send postcards';
await enqueueSnackbar({ message, variant: 'error' });
await unmountFrontComponent();
}
};
send();
}, [recordId]);
return null;
};
export const SEND_POST_CARDS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'a3b7c2d1-4e5f-4a8b-9c0d-1e2f3a4b5c6d';
export default defineFrontComponent({
universalIdentifier: SEND_POST_CARDS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Send Post Cards',
description: 'Sets postcard status to Sent',
isHeadless: true,
component: SendPostCardsEffect,
});

View File

@@ -0,0 +1,14 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
// Field on existing company object
export default defineField({
universalIdentifier: 'f922fdb8-10a9-4f11-a1d0-992a779f6dff',
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.BOOLEAN,
name: 'canReceivePostcards',
label: 'Can Receive Postcards',
description: 'Whether the company can receive postcards',
icon: 'IconMailbox',
defaultValue: true,
});

View File

@@ -0,0 +1,22 @@
import { defineField, FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
import {
POST_CARDS_ON_PERSON_ID,
RECIPIENT_ON_POST_CARD_ID,
} from './recipient-on-post-card.field';
export default defineField({
universalIdentifier: POST_CARDS_ON_PERSON_ID,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
type: FieldType.RELATION,
name: 'postCards',
label: 'Post Cards',
icon: 'IconMail',
relationTargetObjectMetadataUniversalIdentifier:
POST_CARD_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier: RECIPIENT_ON_POST_CARD_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

View File

@@ -0,0 +1,22 @@
import { defineField, FieldType, RelationType, OnDeleteAction, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export const RECIPIENT_ON_POST_CARD_ID = 'c44f158e-2747-42c6-9295-75b8cbae7039';
export const POST_CARDS_ON_PERSON_ID = 'd4aa40b9-077b-46f3-a685-5f0f08ad318c';
export default defineField({
universalIdentifier: RECIPIENT_ON_POST_CARD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'recipient',
label: 'Recipient',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier: POST_CARDS_ON_PERSON_ID,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'recipientId',
},
});

View File

@@ -0,0 +1,35 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { definePostInstallLogicFunction } from 'twenty-sdk/define';
const SEED_POST_CARDS = [
{
name: 'Greetings from Paris',
content: 'Wish you were here! The Eiffel Tower is breathtaking.',
},
{
name: 'Hello from Tokyo',
content: 'Cherry blossoms are in full bloom. Sending love!',
},
];
const handler = async () => {
const client = new CoreApiClient();
await client.mutation({
createPostCards: {
__args: { data: SEED_POST_CARDS as any },
id: true,
},
} as any);
console.log(`Seeded ${SEED_POST_CARDS.length} post cards on install.`);
return {};
};
export default definePostInstallLogicFunction({
universalIdentifier: '852c6321-1563-4396-b7c5-9d370f3d30a9',
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 30,
handler,
});

View File

@@ -0,0 +1,17 @@
import { definePreInstallLogicFunction } from 'twenty-sdk/define';
const handler = async (params: any) => {
console.log(
`Pre-install logic function executed successfully with params`,
params,
);
return {};
};
export default definePreInstallLogicFunction({
universalIdentifier: 'bf27f558-4ec6-481f-b76e-1dbcd05aef1f',
name: 'pre-install',
description: 'Runs before migrations to set up the application.',
timeoutSeconds: 10,
handler,
});

View File

@@ -0,0 +1,12 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
export default defineNavigationMenuItem({
universalIdentifier: 'c1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d',
position: 0,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
});

View File

@@ -0,0 +1,94 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
RETURNED = 'RETURNED',
}
export const POST_CARD_UNIVERSAL_IDENTIFIER =
'54b589ca-eeed-4950-a176-358418b85c05';
export const CONTENT_FIELD_UNIVERSAL_IDENTIFIER =
'cb9679ff-8f10-4dba-9e75-d571922c6d29';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER =
'f84f131c-1cfb-4800-916f-7f98e0bdf748';
export default defineObject({
universalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post card',
labelPlural: 'Post cards',
description: 'A post card object',
icon: 'IconMail',
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
label: 'Name',
description: "Postcard's name",
icon: 'IconAbc',
name: 'name',
},
{
universalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
label: 'Content',
description: "Postcard's content",
icon: 'IconAbc',
name: 'content',
},
{
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconSend',
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{
id: '8ab3abad-02e7-4670-9283-983d7fac7fe4',
value: PostCardStatus.DRAFT,
label: 'Draft',
position: 0,
color: 'gray',
},
{
id: '2bcdd195-6c99-4d69-84b2-e2838ee54467',
value: PostCardStatus.SENT,
label: 'Sent',
position: 1,
color: 'orange',
},
{
id: '918ff60c-c26e-4fae-8eba-3fbce04dc48b',
value: PostCardStatus.DELIVERED,
label: 'Delivered',
position: 2,
color: 'green',
},
{
id: '3c91a653-5d31-4023-be6c-a69c68d21233',
value: PostCardStatus.RETURNED,
label: 'Returned',
position: 3,
color: 'orange',
},
],
name: 'status',
},
{
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
type: FieldType.DATE_TIME,
label: 'Delivered at',
icon: 'IconCheck',
isNullable: true,
defaultValue: null,
name: 'deliveredAt',
},
],
});

View File

@@ -0,0 +1,51 @@
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from 'src/objects/post-card.object';
import { CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/components/card.front-component';
import { GENERATE_POST_CARD_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER } from 'src/command-menu-items/generate-post-card.command-menu-item';
export default definePageLayout({
universalIdentifier: 'f2bf4b9f-0485-46f0-89bb-9a65d2b939b1',
name: 'Post Card Record Page',
type: 'RECORD_PAGE',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
tabs: [
{
universalIdentifier: 'a8c3d1e2-4f5b-4a6c-9d0e-1f2a3b4c5d6e',
title: 'Fields',
position: 0,
icon: 'IconList',
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
widgets: [
{
universalIdentifier: '3d388201-f421-4189-8266-f632e494091b',
title: 'Post Card Fields',
type: 'FIELDS',
configuration: {
configurationType: 'FIELDS',
},
},
],
},
{
universalIdentifier: '14dbc57a-5080-48d8-b276-bea9146edff9',
title: 'Preview',
position: 50,
icon: 'IconEye',
widgets: [
{
universalIdentifier: 'd70f837e-8d0c-4fe2-b70e-89b5bc173499',
title: 'Card Preview',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
CARD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
headerCommandMenuItemUniversalIdentifiers: [
GENERATE_POST_CARD_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIER,
],
},
},
],
},
],
});

View File

@@ -0,0 +1,40 @@
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b648f87b-1d26-4961-b974-0908fd991061';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Default function role',
description: 'Default role for function Twenty client',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canUpdateAllSettings: false,
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [
{
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
canReadFieldValue: true,
canUpdateFieldValue: true,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});

View File

@@ -0,0 +1,15 @@
import { defineSkill } from 'twenty-sdk/define';
export default defineSkill({
universalIdentifier: 'a7c3e1f2-8b4d-4e6a-9f01-2d3c4b5a6e7f',
name: 'postcard-writing-guidelines',
label: 'Postcard Writing Guidelines',
icon: 'IconPencil',
description: 'Guidelines for writing effective postcards',
content:
'When writing a postcard: keep the message under 150 words, use a warm ' +
'and personal tone, mention the recipient by name, and include a clear ' +
'call-to-action if the postcard is business-related. Always fill in both ' +
'the recipientName and recipientAddress fields before marking the status ' +
'as SENT.',
});

View File

@@ -0,0 +1,3 @@
export const isDefined = <T>(
value: T | null | undefined,
): value is NonNullable<T> => value !== null && value !== undefined;

View File

@@ -0,0 +1,55 @@
import { defineView, ViewType } from 'twenty-sdk/define';
import {
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
NAME_FIELD_UNIVERSAL_IDENTIFIER,
POST_CARD_UNIVERSAL_IDENTIFIER,
} from '../objects/post-card.object';
import { RECIPIENT_ON_POST_CARD_ID } from '../fields/recipient-on-post-card.field';
export const ALL_POST_CARDS_VIEW_ID = 'b1a2b3c4-0001-4a7b-8c9d-0e1f2a3b4c5d';
export default defineView({
universalIdentifier: ALL_POST_CARDS_VIEW_ID,
name: 'All Post Cards',
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconMail',
position: 0,
fields: [
{
universalIdentifier: '501adcc2-6c2b-48bd-a042-042478e839ec',
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
{
universalIdentifier: '38d9c9d1-a55c-4662-9727-e1e19bed6d82',
fieldMetadataUniversalIdentifier: RECIPIENT_ON_POST_CARD_ID,
position: 1,
isVisible: true,
size: 300,
},
{
universalIdentifier: '75956ae8-5725-48be-a785-c6d1d4c74b60',
fieldMetadataUniversalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
position: 2,
isVisible: true,
size: 500,
},
{
universalIdentifier: 'cedd1429-c589-41d7-bae2-81b26c3534a0',
fieldMetadataUniversalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
position: 3,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'babf6f19-6261-40cb-85fa-6c67eccf75f9',
fieldMetadataUniversalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
position: 4,
isVisible: true,
size: 150,
},
],
});

View File

@@ -0,0 +1,44 @@
{
"compileOnSave": false,
"compilerOptions": {
"sourceMap": true,
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"target": "es2018",
"module": "esnext",
"lib": ["es2020", "dom"],
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"resolveJsonModule": true,
"paths": {
"src/*": ["./src/*"],
"~/*": ["./*"]
}
},
"exclude": [
"node_modules",
"dist",
"e2e",
"playwright.config.ts",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.integration-test.ts"
],
"references": [
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,27 @@
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({
resolve: {
tsconfigPaths: true,
},
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
},
},
});

File diff suppressed because it is too large Load Diff