25fe66565c
## Before <img width="1452" height="709" alt="image" src="https://github.com/user-attachments/assets/cd384ffa-cbe6-49d5-a807-ca8d580f55a9" /> <img width="1074" height="452" alt="image" src="https://github.com/user-attachments/assets/720d38db-3495-4032-8831-17d24ec6a7e7" /> ## After <img width="1421" height="865" alt="image" src="https://github.com/user-attachments/assets/2275c996-c895-4800-8324-2aa2ddfddd43" /> <img width="1348" height="870" alt="image" src="https://github.com/user-attachments/assets/3e1a891d-6db0-4cbd-870a-2a5bbde4929d" /> ## Summary Adds typed application variables with optional select **options**. This is the other half of #22059, split out from the custom-settings-tab removal. ## Changes - **Shared types**: `ApplicationVariable` / `ServerVariables` gain an optional `type` (a `FieldMetadataType` subset — `TEXT`, `BOOLEAN`, `NUMBER`, `DATE`, `SELECT`, `MULTI_SELECT`, `RAW_JSON`, `RICH_TEXT`, `ARRAY`, …) and select `options`. New `serializeApplicationVariableValue` / `deserializeApplicationVariableValue` helpers convert typed values to/from the encrypted string storage. - **Server**: `type`/`options` columns on `applicationVariable` and `applicationRegistrationVariable` (entities + DTOs), a fast `2-17` instance command, manifest processing via the serialization helpers, and a `QueryDeepPartialEntity` cast where the manifest JSON column is persisted. - **Frontend**: a polymorphic `SettingsApplicationVariableInput` that renders the native `Form*` field component for each type (boolean, number, date/date-time, select, multi-select, array, raw JSON, rich text, text); fragment/query updates to fetch `type`/`options`. - **SDK**: `defineApplication` validates that `SELECT`/`MULTI_SELECT` variables declare non-empty `options` at build time (since `options` is kept structurally optional for TypeORM/SDK compatibility). Variables default to `TEXT` when no type is given, so existing manifests are unaffected. ## Notes The generated GraphQL artifacts (`type`/`options` on the variable types) are regenerated by codegen; that change accompanies this PR. https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23 --- _Generated by [Claude Code](https://claude.ai/code/session_013Z7UB35V2mvUozh55QHG23)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22157?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
622 lines
23 KiB
Plaintext
622 lines
23 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 side panel page. Props depend on `page` — e.g. `ViewRecord` takes `recordId` + `objectNameSingular`, other pages take `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>
|
|
|
|
`getApplicationVariable` always returns a **string** (or `undefined`), regardless of the variable's declared `type`. The string is serialized consistently by type (booleans as `"true"` / `"false"`, numbers as decimal strings, arrays / objects as JSON), the same format used for logic-function `process.env` — parse it yourself (`Number(...)`, `JSON.parse(...)`, `=== 'true'`). See [Variable types](/developers/extend/apps/config/application#variable-types).
|
|
|
|
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 |
|
|
| `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** — Twenty's own component library; see [Using Twenty UI components](#using-twenty-ui-components) below
|
|
- **Emotion** — CSS-in-JS with `@emotion/react`
|
|
- **Styled-components** — `styled.div` patterns
|
|
- **Tailwind CSS** — utility classes
|
|
- **Any CSS-in-JS library** compatible with React
|
|
|
|
## Using Twenty UI components
|
|
|
|
Twenty ships its component library as the [`twenty-ui`](https://www.npmjs.com/package/twenty-ui/v/1.0.0-alpha.1) package. Front components can use it for buttons, tags, status pills, chips, avatars, icons, typography, and theme tokens that automatically match the workspace's light and dark theme.
|
|
|
|
### Installation
|
|
|
|
Add the package to your app, pinned to the version your Twenty instance ships:
|
|
|
|
```bash
|
|
yarn add twenty-ui@1.0.0-alpha.1
|
|
```
|
|
|
|
`twenty-ui` is bundled into your front component at build time, so it only needs to be a dependency of your app — there is nothing to configure at runtime.
|
|
|
|
### Importing components
|
|
|
|
Import from the matching subpath rather than the package root, so only the components you use end up in your bundle:
|
|
|
|
| Subpath | What it exports |
|
|
| --- | --- |
|
|
| `twenty-ui/input` | `Button` and form inputs |
|
|
| `twenty-ui/data-display` | `Tag`, `Status`, `Chip`, `Avatar`, and more |
|
|
| `twenty-ui/feedback` | `Callout`, `Banner`, `Info`, and more |
|
|
| `twenty-ui/typography` | `H1Title`, `H2Title`, `H3Title`, `Label`, and more |
|
|
| `twenty-ui/icon` | `Icon*` components (e.g. `IconCheck`) |
|
|
| `twenty-ui/theme-constants` | `ThemeProvider`, `themeCssVariables` |
|
|
|
|
```tsx
|
|
import { defineFrontComponent } from 'twenty-sdk/define';
|
|
import { Status, Tag } from 'twenty-ui/data-display';
|
|
import { Button } from 'twenty-ui/input';
|
|
|
|
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,
|
|
});
|
|
```
|
|
|
|
### Icons
|
|
|
|
Import individual icons from `twenty-ui/icon`:
|
|
|
|
```tsx
|
|
import { IconBox, IconCheck } from 'twenty-ui/icon';
|
|
```
|
|
|
|
Each named icon is tree-shaken, so importing a handful adds little to your bundle. Avoid `IconsProvider`, `useIcons`, and `iconsState` — they pull in the full Tabler icon set (several MB).
|
|
|
|
### Theming and theme tokens
|
|
|
|
Twenty UI components automatically match the workspace's light and dark theme — the renderer applies the active color scheme on the host, and the components resolve their colors against it.
|
|
|
|
To use the same design tokens in your own inline styles, call the `useTheme()` hook. It returns Twenty's theme tokens (spacing, colors, radii, fonts) wired to the active theme, with no `ThemeProvider` setup needed in your component:
|
|
|
|
```tsx
|
|
import { useTheme } from 'twenty-ui/theme-constants';
|
|
|
|
const Card = () => {
|
|
const theme = useTheme();
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: theme.spacing[4],
|
|
background: theme.background.secondary,
|
|
color: theme.font.color.primary,
|
|
}}
|
|
>
|
|
Themed card
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
Because `useTheme()` is a hook, you read tokens inside the component body, so the values always reflect the live theme. The same token map is also exported as the `themeCssVariables` constant, but prefer `useTheme()` in front components — a module-level constant that dereferences `themeCssVariables` can be undefined while the app manifest is extracted.
|
|
|
|
To branch on the active scheme explicitly, read it with `useColorScheme()` from `twenty-sdk/front-component`, which returns `'light'` or `'dark'`.
|