614bc7b7e6
## Summary Implements [core-team-issues#2473](https://github.com/twentyhq/core-team-issues/issues/2473): serve HTTP-triggered logic functions from a dedicated, **cookieless** public domain (`{workspaceSubdomain}.withtwenty.com`) instead of the same-site `/s/` route, so functions can safely return **arbitrary headers** — custom headers, `Permissions-Policy` (camera/mic/geolocation), `Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`, `Set-Cookie`, etc. The `/s/` route stays the strict, same-site path it is today. **Self-hosting is unchanged** — everything new is gated on `PUBLIC_DOMAIN_URL` being set. ### Why Today user-authored function responses are served same-site with the Twenty app, so the response-header allow-list is restricted to 5 safe headers and request headers are limited to a per-function allow-list. Serving from an origin that shares nothing with `*.twenty.com` removes that constraint safely — the same "user content domain" pattern as GitHub (`*.githubusercontent.com`) and CodeSandbox (`*.csb.app`). ## What's in here **Routing** - The **root-path → `/s` rewrite happens at the nginx ingress**, not in app code. The existing `api-ingress.yaml` already rewrites root paths onto `/s` (host-agnostically) when the edge sets `X-Twenty-Public-Domain: true`, so `*.withtwenty.com` and registered custom public domains are handled by the same mechanism. (An earlier in-app middleware was removed as a redundant, wrong-layer duplicate.) - `WorkspaceDomainsService.resolveWorkspaceAndPublicDomain` recognizes `*.` subdomains, resolves the workspace by subdomain, and returns `isIsolatedOrigin`. Explicitly registered public-domain rows still take precedence and keep their application scoping. The ingress preserves the `Host` header, so this resolution still fires. **Headers (server)** - Isolated origin → all response headers pass through and all request headers are forwarded. Same-site `/s/` keeps the strict allow-lists. (Global CORS already handles preflight/ACAO.) **`/s/` deprecation for new routes (cloud only)** - New `LOGIC_FUNCTION_LEGACY_ROUTE_CUTOFF` config var (ISO date, optional). When `PUBLIC_DOMAIN_URL` is set, functions created on/after the cutoff return **410 Gone** on `/s/` with the new URL. Existing routes and self-hosted instances are untouched. **Frontend education** - `publicFunctionDomain` added to `ClientConfig` (from `PUBLIC_DOMAIN_URL`). - The logic-function **Live URL** now resolves to `https://{workspaceSubdomain}.{publicFunctionDomain}{path}` on cloud, falling back to `/s/` for self-hosting. - Front components call their functions through the SDK (`RestApiClient`), which now targets the isolated domain via the injected `TWENTY_FUNCTIONS_URL`. - New **"Public URL"** section on the application **Settings** tab explaining the isolated domain (shown when the app exposes HTTP-triggered functions). **Docs**: note the `withtwenty.com` domain for external callers in the apps guide. ## Infra prerequisites (not code — needs dashboard work) - Wildcard DNS `*.withtwenty.com` (proxied) + wildcard TLS in the public-domain Cloudflare zone. - Edge (Cloudflare) sets `X-Twenty-Public-Domain: true` for `*.withtwenty.com` requests, so the existing nginx ingress rewrites them onto `/s` (same header the custom-domain flow already relies on). - Set `PUBLIC_DOMAIN_URL=https://withtwenty.com` on cloud. - Submit `withtwenty.com` to the **Public Suffix List** (required for cross-tenant cookie isolation before relying on `Set-Cookie`). ## Test plan - [x] `nx typecheck twenty-server`, `nx typecheck twenty-front` - [x] `lint:diff-with-main` + oxfmt clean (server + front) - [x] `npx jest route-trigger public-function-domain domain-server-config workspace-domains build-logic-function-event client-config` → server unit tests passing (resolution tiers, header passthrough vs allow-list, `/s/` cutoff 410) - [x] `npx jest getLogicFunctionHttpUrl` (front) and `nx test twenty-client-sdk` (RestApiClient routing) passing - [x] CI green (server, front, sdk, renderer, ui, zapier, example apps) - [ ] Manual: hit `{subdomain}.withtwenty.com/` end-to-end once infra is provisioned <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22045?utm_source=github" rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a>
552 lines
20 KiB
Plaintext
552 lines
20 KiB
Plaintext
---
|
|
title: Front Components
|
|
description: Build React components that render inside Twenty's UI with sandboxed isolation.
|
|
icon: "window-maximize"
|
|
---
|
|
|
|
Front components are React components that render directly inside Twenty's UI. They run in an **isolated Web Worker** using Remote DOM — your code is sandboxed but renders natively in the page, not in an iframe.
|
|
|
|
## Where front components can be used
|
|
|
|
Front components can render in two locations within Twenty:
|
|
|
|
- **Side panel** — Non-headless front components open in the right-hand side panel. This is the default behavior when a front component is triggered from the command menu.
|
|
- **Widgets (dashboards and record pages)** — Front components can be embedded as widgets inside [page layouts](/developers/extend/apps/layout/page-layouts). When configuring a dashboard or a record page layout, users can add a front component widget.
|
|
|
|
A front component on its own isn't reachable from the UI — you need to *surface* it. The two ways to do that are:
|
|
|
|
- **Pair it with a [command menu item](/developers/extend/apps/layout/command-menu-items)** — registers it in the command menu (Cmd+K) and, optionally, as a pinned quick-action.
|
|
- **Embed it as a widget in a [page layout](/developers/extend/apps/layout/page-layouts)** — places it on a record's detail page or dashboard.
|
|
|
|
## Basic example
|
|
|
|
The quickest way to see a front component in action is to pair it with a [`defineCommandMenuItem`](/developers/extend/apps/layout/command-menu-items), so it appears as a quick-action button in the top-right corner of the page:
|
|
|
|
```tsx src/front-components/hello-world.tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
|
|
const HelloWorld = () => {
|
|
return (
|
|
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
|
|
<h1>Hello from my app!</h1>
|
|
<p>This component renders inside Twenty.</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
|
|
name: 'hello-world',
|
|
description: 'A simple front component',
|
|
component: HelloWorld,
|
|
});
|
|
```
|
|
|
|
```ts src/command-menu-items/hello-world.command-menu-item.ts
|
|
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
|
|
|
export default defineCommandMenuItem({
|
|
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
|
shortLabel: 'Hello',
|
|
label: 'Hello World',
|
|
icon: 'IconBolt',
|
|
isPinned: true,
|
|
availabilityType: 'GLOBAL',
|
|
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
|
|
});
|
|
```
|
|
|
|
After syncing with `yarn twenty dev` (or running a one-shot `yarn twenty dev --once`), the quick action appears in the top-right corner of the page:
|
|
|
|
<div style={{textAlign: 'center'}}>
|
|
<img src="/images/docs/developers/extends/apps/quick-action.png" alt="Quick action button in the top-right corner" />
|
|
</div>
|
|
|
|
Click it to render the component inline.
|
|
|
|
## Configuration fields
|
|
|
|
| Field | Required | Description |
|
|
|-------|----------|-------------|
|
|
| `universalIdentifier` | Yes | Stable unique ID for this component |
|
|
| `component` | Yes | A React component function |
|
|
| `name` | No | Display name |
|
|
| `description` | No | Description of what the component does |
|
|
| `isHeadless` | No | Set to `true` if the component has no visible UI (see below) |
|
|
|
|
## Placing a front component on a page
|
|
|
|
Beyond commands, you can embed a front component directly into a record page by adding it as a widget in a **page layout**. See [Page Layouts](/developers/extend/apps/layout/page-layouts) for details.
|
|
|
|
## Headless vs non-headless
|
|
|
|
Front components come in two rendering modes controlled by the `isHeadless` option:
|
|
|
|
**Non-headless (default)** — The component renders a visible UI. When triggered from the command menu it opens in the side panel. This is the default behavior when `isHeadless` is `false` or omitted.
|
|
|
|
**Headless (`isHeadless: true`)** — The component mounts invisibly in the background. It does not open the side panel. Headless components are designed for actions that execute logic and then unmount themselves — for example, running an async task, navigating to a page, or showing a confirmation modal. They pair naturally with the SDK Command components described below.
|
|
|
|
```tsx src/front-components/sync-tracker.tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import { useRecordId, enqueueSnackbar } from 'twenty-sdk/front-component';
|
|
import { useEffect } from 'react';
|
|
|
|
const SyncTracker = () => {
|
|
const recordId = useRecordId();
|
|
|
|
useEffect(() => {
|
|
enqueueSnackbar({ message: `Tracking record ${recordId}`, variant: 'info' });
|
|
}, [recordId]);
|
|
|
|
return null;
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: '...',
|
|
name: 'sync-tracker',
|
|
description: 'Tracks record views silently',
|
|
isHeadless: true,
|
|
component: SyncTracker,
|
|
});
|
|
```
|
|
|
|
Because the component returns `null`, Twenty skips rendering a container for it — no empty space appears in the layout. The component still has access to all hooks and the host communication API.
|
|
|
|
## SDK Command components
|
|
|
|
The `twenty-sdk` package provides four Command helper components designed for headless front components. Each component executes an action on mount, handles errors by showing a snackbar notification, and automatically unmounts the front component when done.
|
|
|
|
Import them from `twenty-sdk/command`:
|
|
|
|
- **`Command`** — Runs an async callback via the `execute` prop.
|
|
- **`CommandLink`** — Navigates to an app path. Props: `to`, `params`, `queryParams`, `options`.
|
|
- **`CommandModal`** — Opens a confirmation modal. If the user confirms, executes the `execute` callback. Props: `title`, `subtitle`, `execute`, `confirmButtonText`, `confirmButtonAccent`.
|
|
- **`CommandOpenSidePanelPage`** — Opens a specific side panel page. Props: `page`, `pageTitle`, `pageIcon`.
|
|
|
|
Here is a full example of a headless front component using `Command` to run an action from the command menu:
|
|
|
|
```tsx src/front-components/run-action.tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import { Command } from 'twenty-sdk/command';
|
|
import { CoreApiClient } from 'twenty-sdk/clients';
|
|
|
|
const RunAction = () => {
|
|
const execute = async () => {
|
|
const client = new CoreApiClient();
|
|
|
|
await client.mutation({
|
|
createTask: {
|
|
__args: { data: { title: 'Created by my app' } },
|
|
id: true,
|
|
},
|
|
});
|
|
};
|
|
|
|
return <Command execute={execute} />;
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
|
|
name: 'run-action',
|
|
description: 'Creates a task from the command menu',
|
|
component: RunAction,
|
|
isHeadless: true,
|
|
});
|
|
```
|
|
|
|
```ts src/command-menu-items/run-action.command-menu-item.ts
|
|
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
|
|
|
export default defineCommandMenuItem({
|
|
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
|
|
label: 'Run my action',
|
|
icon: 'IconPlayerPlay',
|
|
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
|
|
});
|
|
```
|
|
|
|
And an example using `CommandModal` to ask for confirmation before executing:
|
|
|
|
```tsx src/front-components/delete-draft.tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import { CommandModal } from 'twenty-sdk/command';
|
|
|
|
const DeleteDraft = () => {
|
|
const execute = async () => {
|
|
// perform the deletion
|
|
};
|
|
|
|
return (
|
|
<CommandModal
|
|
title="Delete draft?"
|
|
subtitle="This action cannot be undone."
|
|
execute={execute}
|
|
confirmButtonText="Delete"
|
|
confirmButtonAccent="danger"
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: 'a7b8c9d0-e1f2-3456-abcd-567890123456',
|
|
name: 'delete-draft',
|
|
description: 'Deletes a draft with confirmation',
|
|
component: DeleteDraft,
|
|
isHeadless: true,
|
|
});
|
|
```
|
|
|
|
## Calling a logic function
|
|
|
|
Front components run browser-side in a sandboxed Web Worker, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
|
|
|
|
A logic function declared with `httpRouteTriggerSettings` is reachable over HTTP at its route path. Twenty injects the base URL your functions are served from into the worker as `TWENTY_FUNCTIONS_URL`, together with the `TWENTY_APP_ACCESS_TOKEN` that authenticates the call. There is no dedicated SDK client for invoking your own functions yet, so call them with a plain `fetch`:
|
|
|
|
> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://<your-workspace-subdomain>.twenty.com<path>` — this is exactly what `TWENTY_FUNCTIONS_URL` resolves to. For external callers, copy the exact URL from the function's **HTTP trigger** settings or the application's **Settings** tab.
|
|
|
|
<Warning>
|
|
The legacy `/s/` function route is **deprecated** and will be **deactivated on 2026-07-24**. Use `TWENTY_FUNCTIONS_URL` (above) instead, and migrate any hard-coded `/s/` URLs before that date. The `/s/` route remains available for self-hosting.
|
|
</Warning>
|
|
|
|
A headless front component can run the call on mount via the `Command` component, then unmount automatically:
|
|
|
|
```tsx src/front-components/sync-prs.tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import { Command } from 'twenty-sdk/command';
|
|
|
|
const SyncPrs = () => {
|
|
const execute = async () => {
|
|
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
|
|
});
|
|
};
|
|
|
|
return <Command execute={execute} />;
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: '...',
|
|
name: 'sync-prs',
|
|
description: 'Triggers the fetch-prs logic function',
|
|
isHeadless: true,
|
|
component: SyncPrs,
|
|
});
|
|
```
|
|
|
|
The path appended to `TWENTY_FUNCTIONS_URL` is the logic function's `httpRouteTriggerSettings.path`. Keep `isAuthRequired: true`; the `TWENTY_APP_ACCESS_TOKEN` Twenty mints for your component authenticates the request:
|
|
|
|
```ts src/logic-functions/fetch-prs.logic-function.ts
|
|
import { defineLogicFunction } from 'twenty-sdk/define';
|
|
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
|
|
|
const handler = async (event: RoutePayload) => {
|
|
const { owner, repo } = (event.body ?? {}) as { owner: string; repo: string };
|
|
// ...fetch from GitHub and persist records...
|
|
return { ok: true };
|
|
};
|
|
|
|
export default defineLogicFunction({
|
|
universalIdentifier: '...',
|
|
name: 'fetch-prs',
|
|
handler,
|
|
httpRouteTriggerSettings: {
|
|
path: '/github/fetch-prs',
|
|
httpMethod: 'POST',
|
|
isAuthRequired: true,
|
|
},
|
|
});
|
|
```
|
|
|
|
<Note>
|
|
`TWENTY_FUNCTIONS_URL` and `TWENTY_APP_ACCESS_TOKEN` are injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
|
|
</Note>
|
|
|
|
### Calling the Twenty REST API
|
|
|
|
To read or write Twenty records from a front component, use `RestApiClient` from `twenty-client-sdk/rest`. It belongs to the same client family as `CoreApiClient` and `MetadataApiClient`, but targets the Twenty REST API (`/rest/...`) instead of the GraphQL API, reading its base URL from `TWENTY_API_URL`.
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `get(path, options?)` | Sends a `GET` request |
|
|
| `post(path, body?, options?)` | Sends a `POST` request |
|
|
| `put(path, body?, options?)` | Sends a `PUT` request |
|
|
| `patch(path, body?, options?)` | Sends a `PATCH` request |
|
|
| `delete(path, options?)` | Sends a `DELETE` request |
|
|
| `request(method, path, options?)` | Generic request with any HTTP method |
|
|
|
|
`options` accepts `headers`, `query` (a record of query-string params; nullish values are skipped), and an `AbortSignal` via `signal`. A non-`FormData` object `body` is JSON-serialized automatically. On a `401`, the client refreshes the access token once through the host and retries the request.
|
|
|
|
The base URL and token are resolved from the environment by default. Pass overrides to the constructor when needed — for example in tests:
|
|
|
|
```ts
|
|
const client = new RestApiClient({
|
|
baseUrl: 'https://myworkspace.twenty.com',
|
|
token: 'my-token',
|
|
});
|
|
```
|
|
|
|
Failed requests throw a `RestApiClientError` exposing `status`, `statusText`, `url`, and the parsed `body`:
|
|
|
|
```tsx
|
|
import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
|
|
|
|
const client = new RestApiClient();
|
|
|
|
try {
|
|
const people = await client.get('/rest/people', {
|
|
query: { limit: 10 },
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof RestApiClientError) {
|
|
console.error(error.status, error.body);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Accessing runtime context
|
|
|
|
Inside your component, use SDK hooks to access the current user, record, and component instance:
|
|
|
|
```tsx src/front-components/record-info.tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import {
|
|
useUserId,
|
|
useRecordId,
|
|
useFrontComponentId,
|
|
} from 'twenty-sdk/front-component';
|
|
|
|
const RecordInfo = () => {
|
|
const userId = useUserId();
|
|
const recordId = useRecordId();
|
|
const componentId = useFrontComponentId();
|
|
|
|
return (
|
|
<div>
|
|
<p>User: {userId}</p>
|
|
<p>Record: {recordId ?? 'No record context'}</p>
|
|
<p>Component: {componentId}</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: 'b2c3d4e5-f6a7-8901-bcde-f23456789012',
|
|
name: 'record-info',
|
|
component: RecordInfo,
|
|
});
|
|
```
|
|
|
|
Available hooks:
|
|
|
|
| Hook | Returns | Description |
|
|
|------|---------|-------------|
|
|
| `useUserId()` | `string` or `null` | The current user's ID |
|
|
| `useSelectedRecordIds()` | `string[]` | All selected record IDs (empty array if none selected) |
|
|
| `useRecordId()` | `string` or `null` | **Deprecated.** Use `useSelectedRecordIds()` instead |
|
|
| `useFrontComponentId()` | `string` | This component instance's ID |
|
|
| `useColorScheme()` | `'light'` or `'dark'` | The host UI's active color scheme (`System` is already resolved) |
|
|
| `useFrontComponentExecutionContext(selector)` | varies | Access the full execution context with a selector function |
|
|
|
|
## Application variables
|
|
|
|
Application variables defined in [`defineApplication()`](/developers/extend/apps/config/application) with `isSecret: false` are available inside front components via the `getApplicationVariable` utility:
|
|
|
|
```tsx src/front-components/greeting.tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import { getApplicationVariable } from 'twenty-sdk/front-component';
|
|
|
|
const Greeting = () => {
|
|
const recipientName = getApplicationVariable('DEFAULT_RECIPIENT_NAME') ?? 'World';
|
|
|
|
return <p>Hello, {recipientName}!</p>;
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: '...',
|
|
name: 'greeting',
|
|
component: Greeting,
|
|
});
|
|
```
|
|
|
|
<Warning>
|
|
Secret variables (`isSecret: true`) are **not** exposed to front components. They are only available in [logic functions](/developers/extend/apps/logic/logic-functions), which run server-side. This prevents sensitive values like API keys from being sent to the browser.
|
|
</Warning>
|
|
|
|
The following system variables are always available via `process.env`:
|
|
|
|
| Variable | Description |
|
|
|----------|-------------|
|
|
| `TWENTY_FUNCTIONS_URL` | Base URL your app's HTTP logic functions are served from (used by `RestApiClient`) |
|
|
| `TWENTY_API_URL` | Base URL of the Twenty core API |
|
|
| `TWENTY_APP_ACCESS_TOKEN` | Short-lived token scoped to your app's role |
|
|
|
|
## Host communication API
|
|
|
|
Front components can trigger navigation, modals, and notifications using functions from `twenty-sdk`:
|
|
|
|
| Function | Description |
|
|
|----------|-------------|
|
|
| `navigate(to, params?, queryParams?, options?)` | Navigate to a page in the app |
|
|
| `openSidePanelPage(params)` | Open a side panel |
|
|
| `closeSidePanel()` | Close the side panel |
|
|
| `openCommandConfirmationModal(params)` | Show a confirmation dialog |
|
|
| `enqueueSnackbar(params)` | Show a toast notification |
|
|
| `unmountFrontComponent()` | Unmount the component |
|
|
| `updateProgress(progress)` | Update a progress indicator |
|
|
|
|
Here is an example that uses the host API to show a snackbar and close the side panel after an action completes:
|
|
|
|
```tsx src/front-components/archive-record.tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import { useRecordId } from 'twenty-sdk/front-component';
|
|
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
|
|
import { CoreApiClient } from 'twenty-sdk/clients';
|
|
|
|
const ArchiveRecord = () => {
|
|
const recordId = useRecordId();
|
|
|
|
const handleArchive = async () => {
|
|
const client = new CoreApiClient();
|
|
|
|
await client.mutation({
|
|
updateTask: {
|
|
__args: { id: recordId, data: { status: 'ARCHIVED' } },
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
await enqueueSnackbar({
|
|
message: 'Record archived',
|
|
variant: 'success',
|
|
});
|
|
|
|
await closeSidePanel();
|
|
};
|
|
|
|
return (
|
|
<div style={{ padding: '20px' }}>
|
|
<p>Archive this record?</p>
|
|
<button onClick={handleArchive}>Archive</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: 'c9d0e1f2-a3b4-5678-cdef-789012345678',
|
|
name: 'archive-record',
|
|
description: 'Archives the current record',
|
|
component: ArchiveRecord,
|
|
});
|
|
```
|
|
|
|
### Working with multiple records
|
|
|
|
Use `useSelectedRecordIds()` to handle multiple selected records. This is useful for bulk operations:
|
|
|
|
```tsx src/front-components/bulk-export.tsx
|
|
import { defineFrontComponent, numberOfSelectedRecords } from 'twenty-sdk/define';
|
|
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
|
|
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
|
|
import { CoreApiClient } from 'twenty-sdk/clients';
|
|
|
|
const BulkExport = () => {
|
|
const selectedRecordIds = useSelectedRecordIds();
|
|
|
|
const handleExport = async () => {
|
|
const client = new CoreApiClient();
|
|
|
|
for (const recordId of selectedRecordIds) {
|
|
await client.mutation({
|
|
updateTask: {
|
|
__args: { id: recordId, data: { exported: true } },
|
|
id: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
await enqueueSnackbar({
|
|
message: `Exported ${selectedRecordIds.length} records`,
|
|
variant: 'success',
|
|
});
|
|
|
|
await closeSidePanel();
|
|
};
|
|
|
|
return (
|
|
<div style={{ padding: '20px' }}>
|
|
<p>Export {selectedRecordIds.length} selected record(s)?</p>
|
|
<button onClick={handleExport}>Export</button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
|
|
name: 'bulk-export',
|
|
description: 'Export selected records',
|
|
component: BulkExport,
|
|
command: {
|
|
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678902',
|
|
label: 'Bulk Export',
|
|
availabilityType: 'RECORD_SELECTION',
|
|
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
|
|
},
|
|
});
|
|
```
|
|
|
|
## Public assets
|
|
|
|
Front components can access files from the app's `public/` directory using `getPublicAssetUrl`:
|
|
|
|
```tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import { getPublicAssetUrl } from 'twenty-sdk/utils';
|
|
|
|
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: '...',
|
|
name: 'logo',
|
|
component: Logo,
|
|
});
|
|
```
|
|
|
|
See the [public assets section](/developers/extend/apps/config/public-assets) for details.
|
|
|
|
## Styling
|
|
|
|
Front components support multiple styling approaches. You can use:
|
|
|
|
- **Inline styles** — `style={{ color: 'red' }}`
|
|
- **Twenty UI components** — import from `twenty-sdk/ui` (Button, Tag, Status, Chip, Avatar, and more)
|
|
- **Emotion** — CSS-in-JS with `@emotion/react`
|
|
- **Styled-components** — `styled.div` patterns
|
|
- **Tailwind CSS** — utility classes
|
|
- **Any CSS-in-JS library** compatible with React
|
|
|
|
```tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import { Button, Tag, Status } from 'twenty-sdk/ui';
|
|
|
|
const StyledWidget = () => {
|
|
return (
|
|
<div style={{ padding: '16px', display: 'flex', gap: '8px' }}>
|
|
<Button title="Click me" onClick={() => alert('Clicked!')} />
|
|
<Tag text="Active" color="green" />
|
|
<Status color="green" text="Online" />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default defineFrontComponent({
|
|
universalIdentifier: 'e5f6a7b8-c9d0-1234-efab-567890123456',
|
|
name: 'styled-widget',
|
|
component: StyledWidget,
|
|
});
|
|
```
|