1936 lines
85 KiB
Plaintext
1936 lines
85 KiB
Plaintext
---
|
||
title: Apps erstellen
|
||
description: Definieren Sie Objekte, Logikfunktionen, Frontend-Komponenten und mehr mit dem Twenty SDK.
|
||
---
|
||
|
||
<Warning>
|
||
Apps befinden sich derzeit in der Alpha-Phase. Die Funktion ist funktionsfähig, entwickelt sich jedoch noch weiter.
|
||
</Warning>
|
||
|
||
Das Paket `twenty-sdk` stellt typisierte Bausteine zum Erstellen Ihrer App bereit. Diese Seite behandelt alle im SDK verfügbaren Entitätstypen und API-Clients.
|
||
|
||
## DefineEntity-Funktionen
|
||
|
||
Das SDK stellt Funktionen bereit, um die Entitäten Ihrer App zu definieren. Sie müssen `export default defineEntity({...})` verwenden, damit das SDK Ihre Entitäten erkennt. Diese Funktionen validieren Ihre Konfiguration zur Build-Zeit und bieten IDE-Autovervollständigung sowie Typsicherheit.
|
||
|
||
<Note>
|
||
**Die Dateiorganisation liegt bei Ihnen.**
|
||
Die Entitätserkennung ist AST-basiert — das SDK findet Aufrufe von `export default defineEntity(...)`, unabhängig davon, wo sich die Datei befindet. Das Gruppieren von Dateien nach Typ (z. B. `logic-functions/`, `roles/`) ist lediglich eine Konvention, keine Voraussetzung.
|
||
</Note>
|
||
|
||
<AccordionGroup>
|
||
<Accordion title="Rolle definieren" description="Rollenberechtigungen und Objektzugriff konfigurieren">
|
||
|
||
Rollen kapseln Berechtigungen für die Objekte und Aktionen Ihres Workspaces.
|
||
|
||
```ts restricted-company-role.ts
|
||
import {
|
||
defineRole,
|
||
PermissionFlag,
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||
} from 'twenty-sdk';
|
||
|
||
export default defineRole({
|
||
universalIdentifier: '2c80f640-2083-4803-bb49-003e38279de6',
|
||
label: 'My new role',
|
||
description: 'A role that can be used in your workspace',
|
||
canReadAllObjectRecords: false,
|
||
canUpdateAllObjectRecords: false,
|
||
canSoftDeleteAllObjectRecords: false,
|
||
canDestroyAllObjectRecords: false,
|
||
canUpdateAllSettings: false,
|
||
canBeAssignedToAgents: false,
|
||
canBeAssignedToUsers: false,
|
||
canBeAssignedToApiKeys: false,
|
||
objectPermissions: [
|
||
{
|
||
objectUniversalIdentifier:
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||
canReadObjectRecords: true,
|
||
canUpdateObjectRecords: true,
|
||
canSoftDeleteObjectRecords: false,
|
||
canDestroyObjectRecords: false,
|
||
},
|
||
],
|
||
fieldPermissions: [
|
||
{
|
||
objectUniversalIdentifier:
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||
fieldUniversalIdentifier:
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.fields.name.universalIdentifier,
|
||
canReadFieldValue: false,
|
||
canUpdateFieldValue: false,
|
||
},
|
||
],
|
||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||
});
|
||
```
|
||
|
||
</Accordion>
|
||
<Accordion title="Anwendung definieren" description="Anwendungsmetadaten konfigurieren (erforderlich, eine pro App)">
|
||
|
||
Jede App muss genau einen Aufruf von `defineApplication` haben, der Folgendes beschreibt:
|
||
|
||
* **Identität**: Bezeichner, Anzeigename und Beschreibung.
|
||
* **Berechtigungen**: welche Rolle ihre Funktionen und Frontend-Komponenten verwenden.
|
||
* **(Optional) Variablen**: Schlüssel–Wert-Paare, die Ihren Funktionen als Umgebungsvariablen zur Verfügung gestellt werden.
|
||
* **(Optional) Pre-/Post-Installationsfunktionen**: Logikfunktionen, die vor oder nach der Installation ausgeführt werden.
|
||
|
||
```ts src/application-config.ts
|
||
import { defineApplication } from 'twenty-sdk';
|
||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||
|
||
export default defineApplication({
|
||
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
|
||
displayName: 'My Twenty App',
|
||
description: 'My first Twenty app',
|
||
icon: 'IconWorld',
|
||
applicationVariables: {
|
||
DEFAULT_RECIPIENT_NAME: {
|
||
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
||
description: 'Default recipient name for postcards',
|
||
value: 'Jane Doe',
|
||
isSecret: false,
|
||
},
|
||
},
|
||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||
});
|
||
```
|
||
|
||
Notizen:
|
||
* `universalIdentifier`-Felder sind deterministische IDs, die Ihnen gehören. Erzeugen Sie sie einmal und halten Sie sie über Synchronisierungen hinweg stabil.
|
||
* `applicationVariables` werden zu Umgebungsvariablen für Ihre Funktionen und Frontend-Komponenten (z. B. ist `DEFAULT_RECIPIENT_NAME` als `process.env.DEFAULT_RECIPIENT_NAME` verfügbar).
|
||
* `defaultRoleUniversalIdentifier` muss auf eine mit `defineRole()` definierte Rolle verweisen (siehe oben).
|
||
* Pre- und Post-Installationsfunktionen werden während des Manifest-Builds automatisch erkannt — Sie müssen sie in `defineApplication()` nicht referenzieren.
|
||
|
||
#### Marktplatz-Metadaten
|
||
|
||
Wenn Sie planen, [Ihre App zu veröffentlichen](/l/de/developers/extend/apps/publishing), steuern diese optionalen Felder, wie Ihre App im Marktplatz erscheint:
|
||
|
||
| Feld | Beschreibung |
|
||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `author` | Name des Autors oder des Unternehmens |
|
||
| `category` | App-Kategorie für die Filterung im Marktplatz |
|
||
| `logoUrl` | Pfad zu Ihrem App-Logo (z. B. `public/logo.png`) |
|
||
| `screenshots` | Array von Screenshot-Pfaden (z. B. `public/screenshot-1.png`) |
|
||
| `aboutDescription` | Längere Markdown-Beschreibung für den Tab "Info". Wenn weggelassen, verwendet der Marketplace die `README.md` des Pakets von npm |
|
||
| `websiteUrl` | Link zu Ihrer Website |
|
||
| `termsUrl` | Link zu den Nutzungsbedingungen |
|
||
| `emailSupport` | Support-E-Mail-Adresse |
|
||
| `issueReportUrl` | Link zum Issue-Tracker |
|
||
|
||
#### Rollen und Berechtigungen
|
||
|
||
Das Feld `defaultRoleUniversalIdentifier` in `application-config.ts` legt die Standardrolle fest, die von den Logikfunktionen und Frontend-Komponenten Ihrer App verwendet wird. Details finden Sie oben unter `defineRole`.
|
||
|
||
* Das zur Laufzeit als `TWENTY_APP_ACCESS_TOKEN` injizierte Token wird aus dieser Rolle abgeleitet.
|
||
* Der typisierte Client ist auf die dieser Rolle gewährten Berechtigungen beschränkt.
|
||
* Befolgen Sie das Least-Privilege-Prinzip: Erstellen Sie eine dedizierte Rolle nur mit den Berechtigungen, die Ihre Funktionen benötigen.
|
||
|
||
##### Standard-Funktionsrolle
|
||
|
||
Wenn Sie eine neue App erzeugen, erstellt die CLI eine Standard-Rolldatei:
|
||
|
||
```ts src/roles/default-role.ts
|
||
import { defineRole, PermissionFlag } from 'twenty-sdk';
|
||
|
||
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: true,
|
||
canUpdateAllObjectRecords: false,
|
||
canSoftDeleteAllObjectRecords: false,
|
||
canDestroyAllObjectRecords: false,
|
||
canUpdateAllSettings: false,
|
||
canBeAssignedToAgents: false,
|
||
canBeAssignedToUsers: false,
|
||
canBeAssignedToApiKeys: false,
|
||
objectPermissions: [],
|
||
fieldPermissions: [],
|
||
permissionFlags: [],
|
||
});
|
||
```
|
||
|
||
Der `universalIdentifier` dieser Rolle wird in `application-config.ts` als `defaultRoleUniversalIdentifier` referenziert:
|
||
|
||
* **\*.role.ts** definiert, was die Rolle darf.
|
||
* **application-config.ts** verweist auf diese Rolle, sodass Ihre Funktionen deren Berechtigungen erben.
|
||
|
||
Notizen:
|
||
* Beginnen Sie mit der vorab erstellten Rolle und schränken Sie sie schrittweise gemäß dem Least-Privilege-Prinzip ein.
|
||
* Ersetzen Sie `objectPermissions` und `fieldPermissions` durch die Objekte und Felder, die Ihre Funktionen tatsächlich benötigen.
|
||
* `permissionFlags` steuern den Zugriff auf Funktionen auf Plattformebene. Halten Sie sie minimal.
|
||
* Ein funktionierendes Beispiel finden Sie unter: [`hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts).
|
||
|
||
</Accordion>
|
||
<Accordion title="Objekt definieren" description="Benutzerdefinierte Objekte mit Feldern definieren">
|
||
|
||
Benutzerdefinierte Objekte beschreiben sowohl Schema als auch Verhalten für Datensätze in Ihrem Workspace. Verwenden Sie `defineObject()`, um Objekte mit eingebauter Validierung zu definieren:
|
||
|
||
```ts postCard.object.ts
|
||
import { defineObject, FieldType } from 'twenty-sdk';
|
||
|
||
enum PostCardStatus {
|
||
DRAFT = 'DRAFT',
|
||
SENT = 'SENT',
|
||
DELIVERED = 'DELIVERED',
|
||
RETURNED = 'RETURNED',
|
||
}
|
||
|
||
export default defineObject({
|
||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||
nameSingular: 'postCard',
|
||
namePlural: 'postCards',
|
||
labelSingular: 'Post Card',
|
||
labelPlural: 'Post Cards',
|
||
description: 'A post card object',
|
||
icon: 'IconMail',
|
||
fields: [
|
||
{
|
||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||
name: 'content',
|
||
type: FieldType.TEXT,
|
||
label: 'Content',
|
||
description: "Postcard's content",
|
||
icon: 'IconAbc',
|
||
},
|
||
{
|
||
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
|
||
name: 'recipientName',
|
||
type: FieldType.FULL_NAME,
|
||
label: 'Recipient name',
|
||
icon: 'IconUser',
|
||
},
|
||
{
|
||
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
|
||
name: 'recipientAddress',
|
||
type: FieldType.ADDRESS,
|
||
label: 'Recipient address',
|
||
icon: 'IconHome',
|
||
},
|
||
{
|
||
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||
name: 'status',
|
||
type: FieldType.SELECT,
|
||
label: 'Status',
|
||
icon: 'IconSend',
|
||
defaultValue: `'${PostCardStatus.DRAFT}'`,
|
||
options: [
|
||
{ value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' },
|
||
{ value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' },
|
||
{ value: PostCardStatus.DELIVERED, label: 'Delivered', position: 2, color: 'green' },
|
||
{ value: PostCardStatus.RETURNED, label: 'Returned', position: 3, color: 'orange' },
|
||
],
|
||
},
|
||
{
|
||
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
|
||
name: 'deliveredAt',
|
||
type: FieldType.DATE_TIME,
|
||
label: 'Delivered at',
|
||
icon: 'IconCheck',
|
||
isNullable: true,
|
||
defaultValue: null,
|
||
},
|
||
],
|
||
});
|
||
```
|
||
|
||
Hauptpunkte:
|
||
|
||
* Verwenden Sie `defineObject()` für eingebaute Validierung und bessere IDE-Unterstützung.
|
||
* Der `universalIdentifier` muss eindeutig und über Deployments hinweg stabil sein.
|
||
* Jedes Feld benötigt `name`, `type`, `label` und einen eigenen stabilen `universalIdentifier`.
|
||
* Das Array `fields` ist optional — Sie können Objekte ohne benutzerdefinierte Felder definieren.
|
||
* Sie können mit `yarn twenty add` neue Objekte erzeugen; der Assistent führt Sie durch Benennung, Felder und Beziehungen.
|
||
|
||
<Note>
|
||
**Basisfelder werden automatisch erstellt.** Wenn Sie ein benutzerdefiniertes Objekt definieren, fügt Twenty automatisch Standardfelder hinzu
|
||
wie `id`, `name`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` und `deletedAt`.
|
||
Sie müssen diese nicht in Ihrem `fields`-Array definieren — fügen Sie nur Ihre benutzerdefinierten Felder hinzu.
|
||
Sie können Standardfelder überschreiben, indem Sie in Ihrem `fields`-Array ein Feld mit demselben Namen definieren,
|
||
dies wird jedoch nicht empfohlen.
|
||
</Note>
|
||
|
||
</Accordion>
|
||
<Accordion title="defineField — Standardfelder" description="Bestehende Objekte mit zusätzlichen Feldern erweitern">
|
||
|
||
Verwenden Sie `defineField()`, um Objekten, die Ihnen nicht gehören — etwa Standardobjekten von Twenty (Person, Company usw.) — Felder hinzuzufügen oder Objekten aus anderen Apps. Im Gegensatz zu Inline-Feldern in `defineObject()` benötigen eigenständige Felder einen `objectUniversalIdentifier`, um anzugeben, welches Objekt sie erweitern:
|
||
|
||
```ts src/fields/company-loyalty-tier.field.ts
|
||
import { defineField, FieldType } from 'twenty-sdk';
|
||
|
||
export default defineField({
|
||
universalIdentifier: 'f2a1b3c4-d5e6-7890-abcd-ef1234567890',
|
||
objectUniversalIdentifier: '701aecb9-eb1c-4d84-9d94-b954b231b64b', // Company object
|
||
name: 'loyaltyTier',
|
||
type: FieldType.SELECT,
|
||
label: 'Loyalty Tier',
|
||
icon: 'IconStar',
|
||
options: [
|
||
{ value: 'BRONZE', label: 'Bronze', position: 0, color: 'orange' },
|
||
{ value: 'SILVER', label: 'Silver', position: 1, color: 'gray' },
|
||
{ value: 'GOLD', label: 'Gold', position: 2, color: 'yellow' },
|
||
],
|
||
});
|
||
```
|
||
|
||
Hauptpunkte:
|
||
* Der `objectUniversalIdentifier` identifiziert das Zielobjekt. Für Standardobjekte verwenden Sie `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`, die aus `twenty-sdk` exportiert werden.
|
||
* Wenn Sie Felder inline in `defineObject()` definieren, benötigen Sie `objectUniversalIdentifier` **nicht** — er wird vom übergeordneten Objekt geerbt.
|
||
* `defineField()` ist die einzige Möglichkeit, Felder zu Objekten hinzuzufügen, die Sie nicht mit `defineObject()` erstellt haben.
|
||
|
||
</Accordion>
|
||
<Accordion title="defineField — Relationsfelder" description="Objekte mit bidirektionalen Relationen verbinden">
|
||
|
||
Relationen verbinden Objekte miteinander. In Twenty sind Relationen stets **bidirektional** — Sie definieren beide Seiten, und jede Seite referenziert die andere.
|
||
|
||
Es gibt zwei Relationstypen:
|
||
|
||
| Beziehungstyp | Beschreibung | Fremdschlüssel vorhanden? |
|
||
| ------------- | ----------------------------------------------------------------------- | ------------------------- |
|
||
| `MANY_TO_ONE` | Viele Datensätze dieses Objekts verweisen auf einen Datensatz des Ziels | Ja (`joinColumnName`) |
|
||
| `ONE_TO_MANY` | Ein Datensatz dieses Objekts hat viele Datensätze des Ziels | Nein (inverse Seite) |
|
||
|
||
#### Wie Relationen funktionieren
|
||
|
||
Jede Relation erfordert **zwei Felder**, die sich gegenseitig referenzieren:
|
||
|
||
1. Die **MANY_TO_ONE**-Seite — befindet sich auf dem Objekt, das den Fremdschlüssel hält
|
||
2. Die **ONE_TO_MANY**-Seite — befindet sich auf dem Objekt, dem die Sammlung gehört
|
||
|
||
Beide Felder verwenden `FieldType.RELATION` und verweisen über `relationTargetFieldMetadataUniversalIdentifier` gegenseitig aufeinander.
|
||
|
||
#### Beispiel: Postkarte hat viele Empfänger
|
||
|
||
Angenommen, eine `PostCard` kann an viele `PostCardRecipient`-Datensätze gesendet werden. Jeder Empfänger gehört genau zu einer Postkarte.
|
||
|
||
**Schritt 1: Definieren Sie die ONE_TO_MANY-Seite auf PostCard** (die "eine" Seite):
|
||
|
||
```ts src/fields/post-card-recipients-on-post-card.field.ts
|
||
import { defineField, FieldType, RelationType } from 'twenty-sdk';
|
||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||
|
||
// Export so the other side can reference it
|
||
export const POST_CARD_RECIPIENTS_FIELD_ID = 'a1111111-1111-1111-1111-111111111111';
|
||
// Import from the other side
|
||
import { POST_CARD_FIELD_ID } from './post-card-on-post-card-recipient.field';
|
||
|
||
export default defineField({
|
||
universalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||
type: FieldType.RELATION,
|
||
name: 'postCardRecipients',
|
||
label: 'Post Card Recipients',
|
||
icon: 'IconUsers',
|
||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_FIELD_ID,
|
||
universalSettings: {
|
||
relationType: RelationType.ONE_TO_MANY,
|
||
},
|
||
});
|
||
```
|
||
|
||
**Schritt 2: Definieren Sie die MANY_TO_ONE-Seite auf PostCardRecipient** (die "viele" Seite — hält den Fremdschlüssel):
|
||
|
||
```ts src/fields/post-card-on-post-card-recipient.field.ts
|
||
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk';
|
||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||
import { POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER } from '../objects/post-card-recipient.object';
|
||
|
||
// Export so the other side can reference it
|
||
export const POST_CARD_FIELD_ID = 'b2222222-2222-2222-2222-222222222222';
|
||
// Import from the other side
|
||
import { POST_CARD_RECIPIENTS_FIELD_ID } from './post-card-recipients-on-post-card.field';
|
||
|
||
export default defineField({
|
||
universalIdentifier: POST_CARD_FIELD_ID,
|
||
objectUniversalIdentifier: POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
|
||
type: FieldType.RELATION,
|
||
name: 'postCard',
|
||
label: 'Post Card',
|
||
icon: 'IconMail',
|
||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||
universalSettings: {
|
||
relationType: RelationType.MANY_TO_ONE,
|
||
onDelete: OnDeleteAction.CASCADE,
|
||
joinColumnName: 'postCardId',
|
||
},
|
||
});
|
||
```
|
||
|
||
<Note>
|
||
**Zyklische Importe:** Beide Relationsfelder referenzieren gegenseitig den `universalIdentifier` des jeweils anderen. Um Probleme mit zyklischen Importen zu vermeiden, exportieren Sie Ihre Feld-IDs als benannte Konstanten aus jeder Datei und importieren Sie sie in der jeweils anderen Datei. Das Build-System löst dies zur Kompilierzeit auf.
|
||
</Note>
|
||
|
||
#### Relationen zu Standardobjekten
|
||
|
||
Um eine Relation mit einem integrierten Twenty-Objekt (Person, Company usw.) zu erstellen, verwenden Sie `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS`:
|
||
|
||
```ts src/fields/person-on-self-hosting-user.field.ts
|
||
import {
|
||
defineField,
|
||
FieldType,
|
||
RelationType,
|
||
OnDeleteAction,
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||
} from 'twenty-sdk';
|
||
import { SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER } from '../objects/self-hosting-user.object';
|
||
|
||
export const PERSON_FIELD_ID = 'c3333333-3333-3333-3333-333333333333';
|
||
export const SELF_HOSTING_USER_REVERSE_FIELD_ID = 'd4444444-4444-4444-4444-444444444444';
|
||
|
||
export default defineField({
|
||
universalIdentifier: PERSON_FIELD_ID,
|
||
objectUniversalIdentifier: SELF_HOSTING_USER_UNIVERSAL_IDENTIFIER,
|
||
type: FieldType.RELATION,
|
||
name: 'person',
|
||
label: 'Person',
|
||
description: 'Person matching with the self hosting user',
|
||
isNullable: true,
|
||
relationTargetObjectMetadataUniversalIdentifier:
|
||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||
relationTargetFieldMetadataUniversalIdentifier: SELF_HOSTING_USER_REVERSE_FIELD_ID,
|
||
universalSettings: {
|
||
relationType: RelationType.MANY_TO_ONE,
|
||
onDelete: OnDeleteAction.SET_NULL,
|
||
joinColumnName: 'personId',
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Eigenschaften von Relationsfeldern
|
||
|
||
| Eigenschaft | Erforderlich | Beschreibung |
|
||
| ------------------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||
| `type` | Ja | Muss `FieldType.RELATION` sein |
|
||
| `relationTargetObjectMetadataUniversalIdentifier` | Ja | Der `universalIdentifier` des Zielobjekts |
|
||
| `relationTargetFieldMetadataUniversalIdentifier` | Ja | Der `universalIdentifier` des entsprechenden Felds auf dem Zielobjekt |
|
||
| `universalSettings.relationType` | Ja | `RelationType.MANY_TO_ONE` oder `RelationType.ONE_TO_MANY` |
|
||
| `universalSettings.onDelete` | Nur für MANY_TO_ONE | Was passiert, wenn der referenzierte Datensatz gelöscht wird: `CASCADE`, `SET_NULL`, `RESTRICT` oder `NO_ACTION` |
|
||
| `universalSettings.joinColumnName` | Nur für MANY_TO_ONE | Datenbankspaltenname für den Fremdschlüssel (z. B. `postCardId`) |
|
||
|
||
#### Inline-Relationsfelder in defineObject
|
||
|
||
Sie können Relationsfelder auch direkt innerhalb von `defineObject()` definieren. In diesem Fall lassen Sie `objectUniversalIdentifier` weg — er wird vom übergeordneten Objekt geerbt:
|
||
|
||
```ts
|
||
export default defineObject({
|
||
universalIdentifier: '...',
|
||
nameSingular: 'postCardRecipient',
|
||
// ...
|
||
fields: [
|
||
{
|
||
universalIdentifier: POST_CARD_FIELD_ID,
|
||
type: FieldType.RELATION,
|
||
name: 'postCard',
|
||
label: 'Post Card',
|
||
relationTargetObjectMetadataUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||
relationTargetFieldMetadataUniversalIdentifier: POST_CARD_RECIPIENTS_FIELD_ID,
|
||
universalSettings: {
|
||
relationType: RelationType.MANY_TO_ONE,
|
||
onDelete: OnDeleteAction.CASCADE,
|
||
joinColumnName: 'postCardId',
|
||
},
|
||
},
|
||
// ... other fields
|
||
],
|
||
});
|
||
```
|
||
</Accordion>
|
||
<Accordion title="defineLogicFunction" description="Logikfunktionen und deren Trigger definieren">
|
||
|
||
Jede Funktionsdatei verwendet `defineLogicFunction()`, um eine Konfiguration mit einem Handler und optionalen Triggern zu exportieren.
|
||
|
||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||
import { defineLogicFunction } from 'twenty-sdk';
|
||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||
|
||
const handler = async (params: RoutePayload) => {
|
||
const client = new CoreApiClient();
|
||
const name = 'name' in params.queryStringParameters
|
||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||
: 'Hello world';
|
||
|
||
const result = await client.mutation({
|
||
createPostCard: {
|
||
__args: { data: { name } },
|
||
id: true,
|
||
name: true,
|
||
},
|
||
});
|
||
return result;
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||
name: 'create-new-post-card',
|
||
timeoutSeconds: 2,
|
||
handler,
|
||
httpRouteTriggerSettings: {
|
||
path: '/post-card/create',
|
||
httpMethod: 'GET',
|
||
isAuthRequired: false,
|
||
},
|
||
/*databaseEventTriggerSettings: {
|
||
eventName: 'people.created',
|
||
},*/
|
||
/*cronTriggerSettings: {
|
||
pattern: '0 0 1 1 *',
|
||
},*/
|
||
});
|
||
```
|
||
|
||
Verfügbare Trigger-Typen:
|
||
* **httpRoute**: Stellt Ihre Funktion unter einem HTTP-Pfad und einer Methode **unter dem Endpunkt `/s/`** bereit:
|
||
> z. B. `path: '/post-card/create'` ist unter `https://your-twenty-server.com/s/post-card/create` aufrufbar
|
||
* **cron**: Führt Ihre Funktion nach Zeitplan mithilfe eines CRON-Ausdrucks aus.
|
||
* **databaseEvent**: Wird bei Lebenszyklusereignissen von Workspace-Objekten ausgeführt. Wenn die Ereignisoperation `updated` ist, können bestimmte zu überwachende Felder im Array `updatedFields` angegeben werden. Wenn das Array undefiniert oder leer ist, löst jede Aktualisierung die Funktion aus.
|
||
> z. B. `person.updated`, `*.created`, `company.*`
|
||
|
||
<Note>
|
||
Sie können eine Funktion auch manuell über die CLI ausführen:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty exec -n create-new-post-card -p '{"key": "value"}'
|
||
```
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty exec -y e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||
```
|
||
|
||
Sie können Protokolle mit folgendem Befehl ansehen:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty logs
|
||
```
|
||
</Note>
|
||
|
||
#### Routen-Trigger-Payload
|
||
|
||
Wenn ein Route-Trigger Ihre Logikfunktion aufruft, erhält sie ein `RoutePayload`-Objekt, das dem [AWS-HTTP-API-v2-Format](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) folgt.
|
||
Importieren Sie den Typ `RoutePayload` aus `twenty-sdk`:
|
||
|
||
```ts
|
||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
|
||
|
||
const handler = async (event: RoutePayload) => {
|
||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||
const { method, path } = event.requestContext.http;
|
||
|
||
return { message: 'Success' };
|
||
};
|
||
```
|
||
|
||
Der Typ `RoutePayload` hat die folgende Struktur:
|
||
|
||
| Eigenschaft | Typ | Beschreibung | Beispiel |
|
||
| ---------------------------- | ------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||
| `headers` | `Record<string, string \| undefined>` | HTTP-Header (nur die in `forwardedRequestHeaders` aufgelisteten) | siehe Abschnitt unten |
|
||
| `queryStringParameters` | `Record<string, string \| undefined>` | Query-String-Parameter (mehrere Werte mit Kommas verbunden) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
|
||
| `pathParameters` | `Record<string, string \| undefined>` | Aus dem Routenmuster extrahierte Pfadparameter | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||
| `body` | `object \| null` | Geparster Request-Body (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||
| `isBase64Encoded` | `boolean` | Gibt an, ob der Body Base64-codiert ist | |
|
||
| `requestContext.http.method` | `string` | HTTP-Methode (GET, POST, PUT, PATCH, DELETE) | |
|
||
| `requestContext.http.path` | `string` | Rohpfad der Anfrage | |
|
||
|
||
|
||
#### forwardedRequestHeaders
|
||
|
||
Standardmäßig werden HTTP-Header von eingehenden Anfragen aus Sicherheitsgründen nicht an Ihre Logikfunktion weitergegeben.
|
||
Um auf bestimmte Header zuzugreifen, listen Sie diese im Array `forwardedRequestHeaders` auf:
|
||
|
||
```ts
|
||
export default defineLogicFunction({
|
||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||
name: 'webhook-handler',
|
||
handler,
|
||
httpRouteTriggerSettings: {
|
||
path: '/webhook',
|
||
httpMethod: 'POST',
|
||
isAuthRequired: false,
|
||
forwardedRequestHeaders: ['x-webhook-signature', 'content-type'],
|
||
},
|
||
});
|
||
```
|
||
|
||
Greifen Sie in Ihrem Handler wie folgt auf die weitergeleiteten Header zu:
|
||
|
||
```ts
|
||
const handler = async (event: RoutePayload) => {
|
||
const signature = event.headers['x-webhook-signature'];
|
||
const contentType = event.headers['content-type'];
|
||
|
||
// Validate webhook signature...
|
||
return { received: true };
|
||
};
|
||
```
|
||
|
||
<Note>
|
||
Header-Namen werden in Kleinbuchstaben normalisiert. Greifen Sie mit Schlüsseln in Kleinbuchstaben darauf zu (z. B. `event.headers['content-type']`).
|
||
</Note>
|
||
|
||
#### Eine Funktion als Tool bereitstellen
|
||
|
||
Logikfunktionen können als **Tools** für KI-Agenten und Workflows verfügbar gemacht werden. Wenn eine Funktion als Tool markiert ist, wird sie von den KI-Funktionen von Twenty auffindbar und kann in Workflow-Automatisierungen verwendet werden.
|
||
|
||
Um eine Logikfunktion als Tool zu markieren, setzen Sie `isTool: true`:
|
||
|
||
```ts src/logic-functions/enrich-company.logic-function.ts
|
||
import { defineLogicFunction } from 'twenty-sdk';
|
||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||
|
||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||
const client = new CoreApiClient();
|
||
|
||
const result = await client.mutation({
|
||
createTask: {
|
||
__args: {
|
||
data: {
|
||
title: `Enrich data for ${params.companyName}`,
|
||
body: `Domain: ${params.domain ?? 'unknown'}`,
|
||
},
|
||
},
|
||
id: true,
|
||
},
|
||
});
|
||
|
||
return { taskId: result.createTask.id };
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||
name: 'enrich-company',
|
||
description: 'Enrich a company record with external data',
|
||
timeoutSeconds: 10,
|
||
handler,
|
||
isTool: true,
|
||
});
|
||
```
|
||
|
||
Hauptpunkte:
|
||
|
||
* Sie können `isTool` mit Triggern kombinieren — eine Funktion kann gleichzeitig sowohl ein Tool (von KI-Agenten aufrufbar) als auch durch Ereignisse ausgelöst werden.
|
||
* **`toolInputSchema`** (optional): Ein JSON-Schema-Objekt, das die Parameter beschreibt, die Ihre Funktion akzeptiert. Das Schema wird automatisch durch statische Analyse des Quellcodes ermittelt, Sie können es jedoch auch explizit festlegen:
|
||
|
||
```ts
|
||
export default defineLogicFunction({
|
||
...,
|
||
toolInputSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
companyName: {
|
||
type: 'string',
|
||
description: 'The name of the company to enrich',
|
||
},
|
||
domain: {
|
||
type: 'string',
|
||
description: 'The company website domain (optional)',
|
||
},
|
||
},
|
||
required: ['companyName'],
|
||
},
|
||
});
|
||
```
|
||
|
||
<Note>
|
||
**Schreiben Sie eine gute `description`.** KI-Agenten verlassen sich auf das `description`-Feld der Funktion, um zu entscheiden, wann das Tool verwendet werden soll. Seien Sie konkret darin, was das Tool tut und wann es aufgerufen werden soll.
|
||
</Note>
|
||
|
||
</Accordion>
|
||
<Accordion title="definePreInstallLogicFunction" description="Eine Pre-Installations-Logikfunktion definieren (eine pro App)">
|
||
|
||
Eine Pre-Installationsfunktion ist eine Logikfunktion, die automatisch ausgeführt wird, bevor Ihre App in einem Arbeitsbereich installiert wird. Dies ist nützlich für Validierungsaufgaben, Überprüfungen von Voraussetzungen oder die Vorbereitung des Status des Arbeitsbereichs, bevor die Hauptinstallation fortgesetzt wird.
|
||
|
||
```ts src/logic-functions/pre-install.ts
|
||
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||
|
||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||
console.log('Pre install logic function executed successfully!', payload.previousVersion);
|
||
};
|
||
|
||
export default definePreInstallLogicFunction({
|
||
universalIdentifier: 'e0604b9e-e946-456b-886d-3f27d9a6b324',
|
||
name: 'pre-install',
|
||
description: 'Runs before installation to prepare the application.',
|
||
timeoutSeconds: 300,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
Sie können die Pre-Installationsfunktion auch jederzeit manuell über die CLI ausführen:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty exec --preInstall
|
||
```
|
||
|
||
Hauptpunkte:
|
||
* Pre-Installationsfunktionen verwenden `definePreInstallLogicFunction()` — eine spezialisierte Variante, die Trigger-Einstellungen (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`) weglässt.
|
||
* Der Handler erhält ein `InstallLogicFunctionPayload` mit `{ previousVersion: string }` — die Version der App, die zuvor installiert war (oder eine leere Zeichenkette bei Neuinstallationen).
|
||
* Pro Anwendung ist nur eine Pre-Installationsfunktion zulässig. Der Manifest-Build schlägt fehl, wenn mehr als eine erkannt wird.
|
||
* Der `universalIdentifier` der Funktion wird während des Builds im Anwendungsmanifest automatisch als `preInstallLogicFunctionUniversalIdentifier` gesetzt — Sie müssen ihn nicht in `defineApplication()` referenzieren.
|
||
* Das standardmäßige Timeout ist auf 300 Sekunden (5 Minuten) festgelegt, um längere Vorbereitungsvorgänge zu ermöglichen.
|
||
|
||
</Accordion>
|
||
<Accordion title="definePostInstallLogicFunction" description="Eine Post-Installations-Logikfunktion definieren (eine pro App)">
|
||
|
||
Eine Post-Installationsfunktion ist eine Logikfunktion, die automatisch ausgeführt wird, nachdem Ihre App in einem Arbeitsbereich installiert wurde. Dies ist nützlich für einmalige Einrichtungsvorgänge wie das Befüllen mit Standarddaten, das Erstellen erster Datensätze oder das Konfigurieren von Arbeitsbereichseinstellungen.
|
||
|
||
```ts src/logic-functions/post-install.ts
|
||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||
|
||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||
};
|
||
|
||
export default definePostInstallLogicFunction({
|
||
universalIdentifier: 'f7a2b9c1-3d4e-5678-abcd-ef9876543210',
|
||
name: 'post-install',
|
||
description: 'Runs after installation to set up the application.',
|
||
timeoutSeconds: 300,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
Sie können die Post-Installationsfunktion auch jederzeit manuell über die CLI ausführen:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty exec --postInstall
|
||
```
|
||
|
||
Hauptpunkte:
|
||
* Post-Installationsfunktionen verwenden `definePostInstallLogicFunction()` — eine spezialisierte Variante, die Trigger-Einstellungen (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`) weglässt.
|
||
* Der Handler erhält ein `InstallLogicFunctionPayload` mit `{ previousVersion: string }` — die Version der App, die zuvor installiert war (oder eine leere Zeichenkette bei Neuinstallationen).
|
||
* Pro Anwendung ist nur eine Post-Installationsfunktion zulässig. Der Manifest-Build schlägt fehl, wenn mehr als eine erkannt wird.
|
||
* Der `universalIdentifier` der Funktion wird während des Builds im Anwendungsmanifest automatisch als `postInstallLogicFunctionUniversalIdentifier` gesetzt — Sie müssen ihn nicht in `defineApplication()` referenzieren.
|
||
* Das standardmäßige Timeout ist auf 300 Sekunden (5 Minuten) festgelegt, um längere Einrichtungsvorgänge wie Daten-Seeding zu ermöglichen.
|
||
|
||
</Accordion>
|
||
<Accordion title="defineFrontComponent" description="Frontend-Komponenten für benutzerdefinierte UI definieren">
|
||
|
||
Frontend-Komponenten sind React-Komponenten, die direkt innerhalb der Twenty-UI gerendert werden. Sie laufen in einem **isolierten Web Worker** unter Verwendung von Remote DOM — Ihr Code wird in einer Sandbox ausgeführt, rendert jedoch nativ auf der Seite, nicht in einem iframe.
|
||
|
||
#### Wo Front-Komponenten verwendet werden können
|
||
|
||
Front-Komponenten können an zwei Stellen innerhalb von Twenty gerendert werden:
|
||
|
||
* **Seitenpanel** — Nicht-Headless-Front-Komponenten werden im rechten Seitenpanel geöffnet. Dies ist das Standardverhalten, wenn eine Front-Komponente über das Befehlsmenü ausgelöst wird.
|
||
* **Widgets (Dashboards und Datensatzseiten)** — Front-Komponenten können als Widgets in Seitenlayouts eingebettet werden. Beim Konfigurieren eines Dashboards oder eines Datensatzseiten-Layouts können Benutzer ein Front-Komponenten-Widget hinzufügen.
|
||
|
||
#### Einfaches Beispiel
|
||
|
||
Der schnellste Weg, eine Frontend-Komponente in Aktion zu sehen, ist, sie als Befehl zu registrieren. Das Hinzufügen eines `command`-Felds mit `isPinned: true` lässt sie als Schnellaktionsschaltfläche oben rechts auf der Seite erscheinen — kein Seitenlayout erforderlich:
|
||
|
||
```tsx src/front-components/hello-world.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
|
||
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,
|
||
command: {
|
||
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||
shortLabel: 'Hello',
|
||
label: 'Hello World',
|
||
icon: 'IconBolt',
|
||
isPinned: true,
|
||
availabilityType: 'GLOBAL',
|
||
},
|
||
});
|
||
```
|
||
|
||
Nach dem Synchronisieren mit `yarn twenty dev` erscheint die Schnellaktion oben rechts auf der Seite:
|
||
|
||
<div style={{textAlign: 'center'}}>
|
||
<img src="/images/docs/developers/extends/apps/quick-action.png" alt="Schnellaktionsschaltfläche oben rechts" />
|
||
</div>
|
||
|
||
Klicken Sie darauf, um die Komponente inline zu rendern.
|
||
|
||
{/* TODO: add screenshot of the rendered front component */}
|
||
|
||
#### Konfigurationsfelder
|
||
|
||
| Feld | Erforderlich | Beschreibung |
|
||
| --------------------- | ------------ | ---------------------------------------------------------------------------------------- |
|
||
| `universalIdentifier` | Ja | Stabile eindeutige ID für diese Komponente |
|
||
| `component` | Ja | Eine React-Komponentenfunktion |
|
||
| `name` | Nein | Anzeigename |
|
||
| `description` | Nein | Beschreibung dessen, was die Komponente macht |
|
||
| `isHeadless` | Nein | Auf `true` setzen, wenn die Komponente keine sichtbare UI hat (siehe unten) |
|
||
| `command` | Nein | Die Komponente als Befehl registrieren (siehe unten [Befehlsoptionen](#command-options)) |
|
||
|
||
#### Eine Frontend-Komponente auf einer Seite platzieren
|
||
|
||
Über Befehle hinaus können Sie eine Frontend-Komponente direkt in eine Datensatzseite einbetten, indem Sie sie als Widget in einem **Seitenlayout** hinzufügen. Details finden Sie im Abschnitt [definePageLayout](#definepagelayout).
|
||
|
||
#### Headless vs. Nicht-Headless
|
||
|
||
Front-Komponenten gibt es in zwei Rendering-Modi, die durch die Option `isHeadless` gesteuert werden:
|
||
|
||
**Nicht-Headless (Standard)** — Die Komponente rendert eine sichtbare UI. Wird sie über das Befehlsmenü ausgelöst, öffnet sie sich im Seitenpanel. Dies ist das Standardverhalten, wenn `isHeadless` `false` ist oder weggelassen wird.
|
||
|
||
**Headless (`isHeadless: true`)** — Die Komponente wird unsichtbar im Hintergrund gemountet. Sie öffnet das Seitenpanel nicht. Headless-Komponenten sind für Aktionen konzipiert, die Logik ausführen und sich anschließend selbst unmounten — zum Beispiel das Ausführen einer asynchronen Aufgabe, das Navigieren zu einer Seite oder das Anzeigen eines Bestätigungsdialogs. Sie lassen sich gut mit den unten beschriebenen SDK-Command-Komponenten kombinieren.
|
||
|
||
```tsx src/front-components/sync-tracker.tsx
|
||
import { defineFrontComponent, useRecordId, enqueueSnackbar } from 'twenty-sdk';
|
||
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,
|
||
});
|
||
```
|
||
|
||
Da die Komponente `null` zurückgibt, überspringt Twenty das Rendern eines Containers dafür — im Layout entsteht kein Leerraum. Die Komponente hat dennoch Zugriff auf alle Hooks und die Host-Kommunikations-API.
|
||
|
||
#### SDK-Command-Komponenten
|
||
|
||
Das Paket `twenty-sdk` stellt vier Command-Hilfskomponenten bereit, die für Headless-Front-Komponenten ausgelegt sind. Jede Komponente führt beim Mounten eine Aktion aus, behandelt Fehler durch Anzeige einer Snackbar-Benachrichtigung und unmountet die Front-Komponente nach Abschluss automatisch.
|
||
|
||
Importieren Sie sie aus `twenty-sdk/command`:
|
||
|
||
* **`Command`** — Führt einen asynchronen Callback über das Prop `execute` aus.
|
||
* **`CommandLink`** — Navigiert zu einem App-Pfad. Props: `to`, `params`, `queryParams`, `options`.
|
||
* **`CommandModal`** — Öffnet einen Bestätigungsdialog. Bestätigt der Benutzer, wird der Callback `execute` ausgeführt. Props: `title`, `subtitle`, `execute`, `confirmButtonText`, `confirmButtonAccent`.
|
||
* **`CommandOpenSidePanelPage`** — Öffnet eine bestimmte Seite im Seitenpanel. Props: `page`, `pageTitle`, `pageIcon`.
|
||
|
||
Hier ist ein vollständiges Beispiel einer Headless-Front-Komponente, die `Command` verwendet, um eine Aktion aus dem Befehlsmenü auszuführen:
|
||
|
||
```tsx src/front-components/run-action.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
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,
|
||
command: {
|
||
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
|
||
label: 'Run my action',
|
||
icon: 'IconPlayerPlay',
|
||
},
|
||
});
|
||
```
|
||
|
||
Und ein Beispiel, das `CommandModal` verwendet, um vor der Ausführung um Bestätigung zu bitten:
|
||
|
||
```tsx src/front-components/delete-draft.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
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,
|
||
command: {
|
||
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
|
||
label: 'Delete draft',
|
||
icon: 'IconTrash',
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Zugriff auf den Laufzeitkontext
|
||
|
||
Verwenden Sie innerhalb Ihrer Komponente SDK-Hooks, um auf den aktuellen Benutzer, den Datensatz und die Komponenteninstanz zuzugreifen:
|
||
|
||
```tsx src/front-components/record-info.tsx
|
||
import {
|
||
defineFrontComponent,
|
||
useUserId,
|
||
useRecordId,
|
||
useFrontComponentId,
|
||
} from 'twenty-sdk';
|
||
|
||
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,
|
||
});
|
||
```
|
||
|
||
Verfügbare Hooks:
|
||
|
||
| Hook | Gibt zurück | Beschreibung |
|
||
| --------------------------------------------- | -------------------- | --------------------------------------------------------------------------- |
|
||
| `useUserId()` | `string` oder `null` | Die ID des aktuellen Benutzers |
|
||
| `useRecordId()` | `string` oder `null` | Die ID des aktuellen Datensatzes (wenn auf einer Datensatzseite platziert) |
|
||
| `useFrontComponentId()` | `string` | Die ID dieser Komponenteninstanz |
|
||
| `useFrontComponentExecutionContext(selector)` | variiert | Zugriff auf den vollständigen Ausführungskontext mit einer Selektorfunktion |
|
||
|
||
#### Host-Kommunikations-API
|
||
|
||
Frontend-Komponenten können Navigation, Modals und Benachrichtigungen mittels Funktionen aus `twenty-sdk` auslösen:
|
||
|
||
| Funktion | Beschreibung |
|
||
| ----------------------------------------------- | ----------------------------------------- |
|
||
| `navigate(to, params?, queryParams?, options?)` | Zu einer Seite in der App navigieren |
|
||
| `openSidePanelPage(params)` | Ein Seitenpanel öffnen |
|
||
| `closeSidePanel()` | Seitenpanel schließen |
|
||
| `openCommandConfirmationModal(params)` | Einen Bestätigungsdialog anzeigen |
|
||
| `enqueueSnackbar(params)` | Eine Toast-Benachrichtigung anzeigen |
|
||
| `unmountFrontComponent()` | Die Komponente entfernen |
|
||
| `updateProgress(progress)` | Einen Fortschrittsindikator aktualisieren |
|
||
|
||
Hier ist ein Beispiel, das die Host-API verwendet, um nach Abschluss einer Aktion eine Snackbar anzuzeigen und das Seitenpanel zu schließen:
|
||
|
||
```tsx src/front-components/archive-record.tsx
|
||
import { defineFrontComponent, useRecordId } from 'twenty-sdk';
|
||
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk';
|
||
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,
|
||
});
|
||
```
|
||
|
||
#### Befehlsoptionen
|
||
|
||
Das Hinzufügen eines `command`-Felds zu `defineFrontComponent` registriert die Komponente im Befehlsmenü (Cmd+K). Wenn `isPinned` `true` ist, erscheint sie außerdem als Schnellaktionsschaltfläche oben rechts auf der Seite.
|
||
|
||
| Feld | Erforderlich | Beschreibung |
|
||
| --------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||
| `universalIdentifier` | Ja | Stabile eindeutige ID für den Befehl |
|
||
| `label` | Ja | Vollständiges Label, das im Befehlsmenü (Cmd+K) angezeigt wird |
|
||
| `shortLabel` | Nein | Kürzeres Label, das auf der angehefteten Schnellaktionsschaltfläche angezeigt wird |
|
||
| `icon` | Nein | Neben dem Label angezeigter Icon-Name (z. B. 'IconBolt', 'IconSend') |
|
||
| `isPinned` | Nein | Bei `true` wird der Befehl als Schnellaktionsschaltfläche oben rechts auf der Seite angezeigt |
|
||
| `availabilityType` | Nein | Steuert, wo der Befehl erscheint: 'GLOBAL' (immer verfügbar), 'RECORD_SELECTION' (nur wenn Datensätze ausgewählt sind) oder 'FALLBACK' (wird angezeigt, wenn keine anderen Befehle passen) |
|
||
| `availabilityObjectUniversalIdentifier` | Nein | Beschränken Sie den Befehl auf Seiten eines bestimmten Objekttyps (z. B. nur bei Company-Datensätzen) |
|
||
| `conditionalAvailabilityExpression` | Nein | Ein boolescher Ausdruck, um dynamisch zu steuern, ob der Befehl sichtbar ist (siehe unten) |
|
||
|
||
#### Bedingte Verfügbarkeitsausdrücke
|
||
|
||
Mit dem Feld `conditionalAvailabilityExpression` können Sie basierend auf dem aktuellen Seitenkontext steuern, wann ein Befehl sichtbar ist. Importieren Sie typisierte Variablen und Operatoren aus `twenty-sdk`, um Ausdrücke zu erstellen:
|
||
|
||
```tsx
|
||
import {
|
||
defineFrontComponent,
|
||
pageType,
|
||
numberOfSelectedRecords,
|
||
objectPermissions,
|
||
everyEquals,
|
||
isDefined,
|
||
} from 'twenty-sdk';
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'bulk-action',
|
||
component: BulkAction,
|
||
command: {
|
||
universalIdentifier: '...',
|
||
label: 'Bulk Update',
|
||
availabilityType: 'RECORD_SELECTION',
|
||
conditionalAvailabilityExpression: everyEquals(
|
||
objectPermissions,
|
||
'canUpdateObjectRecords',
|
||
true,
|
||
),
|
||
},
|
||
});
|
||
```
|
||
|
||
**Kontextvariablen** — sie repräsentieren den aktuellen Zustand der Seite:
|
||
|
||
| Variable | Typ | Beschreibung |
|
||
| ------------------------------ | --------- | --------------------------------------------------------------- |
|
||
| `pageType` | `string` | Aktueller Seitentyp (z. B. 'RecordIndexPage', 'RecordShowPage') |
|
||
| `isInSidePanel` | `boolean` | Ob die Komponente in einem Seitenpanel gerendert wird |
|
||
| `numberOfSelectedRecords` | `number` | Anzahl der aktuell ausgewählten Datensätze |
|
||
| `isSelectAll` | `boolean` | Ob „Alle auswählen“ aktiv ist |
|
||
| `selectedRecords` | `array` | Die ausgewählten Datensatzobjekte |
|
||
| `favoriteRecordIds` | `array` | IDs der favorisierten Datensätze |
|
||
| `objectPermissions` | `object` | Berechtigungen für den aktuellen Objekttyp |
|
||
| `targetObjectReadPermissions` | `object` | Leseberechtigungen für das Zielobjekt |
|
||
| `targetObjectWritePermissions` | `object` | Schreibberechtigungen für das Zielobjekt |
|
||
| `featureFlags` | `object` | Aktive Feature-Flags |
|
||
| `objectMetadataItem` | `object` | Metadaten des aktuellen Objekttyps |
|
||
| `hasAnySoftDeleteFilterOnView` | `boolean` | Ob die aktuelle Ansicht einen Soft-Delete-Filter hat |
|
||
|
||
**Operatoren** — Variablen zu booleschen Ausdrücken kombinieren:
|
||
|
||
| Operator | Beschreibung |
|
||
| ----------------------------------- | ------------------------------------------------------------------------------------------- |
|
||
| `isDefined(value)` | `true`, wenn der Wert nicht null/undefined ist |
|
||
| `isNonEmptyString(value)` | `true`, wenn der Wert eine nicht leere Zeichenfolge ist |
|
||
| `includes(array, value)` | `true`, wenn das Array den Wert enthält |
|
||
| `includesEvery(array, prop, value)` | `true`, wenn die Eigenschaft jedes Elements den Wert enthält |
|
||
| `every(array, prop)` | `true`, wenn die Eigenschaft bei jedem Element truthy ist |
|
||
| `everyDefined(array, prop)` | `true`, wenn die Eigenschaft bei jedem Element definiert ist |
|
||
| `everyEquals(array, prop, value)` | `true`, wenn die Eigenschaft bei jedem Element dem Wert entspricht |
|
||
| `some(array, prop)` | `true`, wenn die Eigenschaft bei mindestens einem Element truthy ist |
|
||
| `someDefined(array, prop)` | `true`, wenn die Eigenschaft bei mindestens einem Element definiert ist |
|
||
| `someEquals(array, prop, value)` | `true`, wenn die Eigenschaft bei mindestens einem Element dem Wert entspricht |
|
||
| `someNonEmptyString(array, prop)` | `true`, wenn die Eigenschaft bei mindestens einem Element eine nicht leere Zeichenfolge ist |
|
||
| `none(array, prop)` | `true`, wenn die Eigenschaft bei jedem Element falsy ist |
|
||
| `noneDefined(array, prop)` | `true`, wenn die Eigenschaft bei jedem Element undefined ist |
|
||
| `noneEquals(array, prop, value)` | `true`, wenn die Eigenschaft bei keinem Element dem Wert entspricht |
|
||
|
||
#### Öffentliche Assets
|
||
|
||
Frontend-Komponenten können mit `getPublicAssetUrl` auf Dateien aus dem `public/`-Verzeichnis der App zugreifen:
|
||
|
||
```tsx
|
||
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk';
|
||
|
||
const Logo = () => <img src={getPublicAssetUrl('logo.png')} alt="Logo" />;
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'logo',
|
||
component: Logo,
|
||
});
|
||
```
|
||
|
||
Details finden Sie im Abschnitt [Öffentliche Assets](#accessing-public-assets-with-getpublicasseturl).
|
||
|
||
#### Styling
|
||
|
||
Frontend-Komponenten unterstützen mehrere Styling-Ansätze. Sie können verwenden:
|
||
|
||
* **Inline-Styles** — `style={{ color: 'red' }}`
|
||
* **Twenty-UI-Komponenten** — Import aus `twenty-sdk/ui` (Button, Tag, Status, Chip, Avatar und mehr)
|
||
* **Emotion** — CSS-in-JS mit `@emotion/react`
|
||
* **Styled-components** — `styled.div`-Muster
|
||
* **Tailwind CSS** — Utility-Klassen
|
||
* **Beliebige CSS-in-JS-Bibliothek**, die mit React kompatibel ist
|
||
|
||
```tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
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,
|
||
});
|
||
```
|
||
|
||
</Accordion>
|
||
|
||
<Accordion title="defineSkill" description="Skills für KI-Agenten definieren">
|
||
|
||
Skills definieren wiederverwendbare Anweisungen und Fähigkeiten, die KI-Agenten in Ihrem Arbeitsbereich verwenden können. Verwenden Sie `defineSkill()`, um Skills mit eingebauter Validierung zu definieren:
|
||
|
||
```ts src/skills/example-skill.ts
|
||
import { defineSkill } from 'twenty-sdk';
|
||
|
||
export default defineSkill({
|
||
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||
name: 'sales-outreach',
|
||
label: 'Sales Outreach',
|
||
description: 'Guides the AI agent through a structured sales outreach process',
|
||
icon: 'IconBrain',
|
||
content: `You are a sales outreach assistant. When reaching out to a prospect:
|
||
1. Research the company and recent news
|
||
2. Identify the prospect's role and likely pain points
|
||
3. Draft a personalized message referencing specific details
|
||
4. Keep the tone professional but conversational`,
|
||
});
|
||
```
|
||
|
||
Hauptpunkte:
|
||
* `name` ist eine eindeutige Kennung (als Zeichenfolge) für den Skill (kebab-case empfohlen).
|
||
* `label` ist der menschenlesbare Anzeigename, der in der UI angezeigt wird.
|
||
* `content` enthält die Skill-Anweisungen — dies ist der Text, den der KI-Agent verwendet.
|
||
* `icon` (optional) legt das in der UI angezeigte Symbol fest.
|
||
* `description` (optional) liefert zusätzlichen Kontext zum Zweck des Skills.
|
||
|
||
</Accordion>
|
||
<Accordion title="defineAgent" description="KI-Agenten mit benutzerdefinierten Prompts definieren">
|
||
|
||
Agenten sind KI-Assistenten, die innerhalb Ihres Workspaces leben. Verwenden Sie `defineAgent()`, um Agenten mit einem benutzerdefinierten System-Prompt zu erstellen:
|
||
|
||
```ts src/agents/example-agent.ts
|
||
import { defineAgent } from 'twenty-sdk';
|
||
|
||
export default defineAgent({
|
||
universalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
|
||
name: 'sales-assistant',
|
||
label: 'Sales Assistant',
|
||
description: 'Helps the sales team draft outreach emails and research prospects',
|
||
icon: 'IconRobot',
|
||
prompt: 'You are a helpful sales assistant. Help users with their questions and tasks.',
|
||
});
|
||
```
|
||
|
||
Hauptpunkte:
|
||
* `name` ist die eindeutige Kennzeichnungs-Zeichenfolge für den Agenten (kebab-case empfohlen).
|
||
* `label` ist der in der UI angezeigte Anzeigename.
|
||
* `prompt` ist der System-Prompt, der das Verhalten des Agenten definiert.
|
||
* `description` (optional) liefert Kontext dazu, was der Agent tut.
|
||
* `icon` (optional) legt das in der UI angezeigte Symbol fest.
|
||
* `modelId` (optional) überschreibt das vom Agenten verwendete Standard-KI-Modell.
|
||
|
||
</Accordion>
|
||
<Accordion title="defineView" description="Gespeicherte Views für Objekte definieren">
|
||
|
||
Ansichten sind gespeicherte Konfigurationen dafür, wie Datensätze eines Objekts angezeigt werden — einschließlich sichtbarer Felder, deren Reihenfolge sowie angewendeter Filter oder Gruppen. Verwenden Sie `defineView()`, um vorkonfigurierte Ansichten mit Ihrer App auszuliefern:
|
||
|
||
```ts src/views/example-view.ts
|
||
import { defineView, ViewKey } from 'twenty-sdk';
|
||
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
|
||
import { NAME_FIELD_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
|
||
|
||
export default defineView({
|
||
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||
name: 'All example items',
|
||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||
icon: 'IconList',
|
||
key: ViewKey.INDEX,
|
||
position: 0,
|
||
fields: [
|
||
{
|
||
universalIdentifier: 'f926bdb7-6af7-4683-9a09-adbca56c29f0',
|
||
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||
position: 0,
|
||
isVisible: true,
|
||
size: 200,
|
||
},
|
||
],
|
||
});
|
||
```
|
||
|
||
Hauptpunkte:
|
||
* `objectUniversalIdentifier` gibt an, auf welches Objekt diese Ansicht angewendet wird.
|
||
* `key` bestimmt den Ansichtstyp (z. B. `ViewKey.INDEX` für die Hauptlistenansicht).
|
||
* `fields` steuert, welche Spalten erscheinen und in welcher Reihenfolge. Jedes Feld referenziert einen `fieldMetadataUniversalIdentifier`.
|
||
* Für erweiterte Konfigurationen können Sie außerdem `filters`, `filterGroups`, `groups` und `fieldGroups` definieren.
|
||
* `position` steuert die Reihenfolge, wenn mehrere Ansichten für dasselbe Objekt existieren.
|
||
|
||
</Accordion>
|
||
<Accordion title="defineNavigationMenuItem" description="Seitenleisten-Navigationslinks definieren">
|
||
|
||
Navigationsmenüeinträge fügen der Workspace-Seitenleiste benutzerdefinierte Einträge hinzu. Verwenden Sie `defineNavigationMenuItem()`, um auf Ansichten, externe URLs oder Objekte zu verlinken:
|
||
|
||
```ts src/navigation-menu-items/example-navigation-menu-item.ts
|
||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk';
|
||
import { EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER } from '../views/example-view';
|
||
|
||
export default defineNavigationMenuItem({
|
||
universalIdentifier: '9327db91-afa1-41b6-bd9d-2b51a26efb4c',
|
||
name: 'example-navigation-menu-item',
|
||
icon: 'IconList',
|
||
color: 'blue',
|
||
position: 0,
|
||
type: NavigationMenuItemType.VIEW,
|
||
viewUniversalIdentifier: EXAMPLE_VIEW_UNIVERSAL_IDENTIFIER,
|
||
});
|
||
```
|
||
|
||
Hauptpunkte:
|
||
* `type` bestimmt, worauf der Menüeintrag verweist: `NavigationMenuItemType.VIEW` für eine gespeicherte Ansicht oder `NavigationMenuItemType.LINK` für eine externe URL.
|
||
* Für Ansichtslinks setzen Sie `viewUniversalIdentifier`. Für externe Links setzen Sie `link`.
|
||
* `position` steuert die Reihenfolge in der Seitenleiste.
|
||
* `icon` und `color` (optional) passen das Erscheinungsbild an.
|
||
|
||
</Accordion>
|
||
<Accordion title="definePageLayout" description="Benutzerdefinierte Seitenlayouts für Datensatzansichten definieren">
|
||
|
||
Seitenlayouts ermöglichen es Ihnen, das Aussehen einer Datensatzdetailseite anzupassen — welche Tabs erscheinen, welche Widgets sich in jedem Tab befinden und wie sie angeordnet sind. Verwenden Sie `definePageLayout()`, um benutzerdefinierte Layouts mit Ihrer App auszuliefern:
|
||
|
||
```ts src/page-layouts/example-record-page-layout.ts
|
||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk';
|
||
import { EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER } from '../objects/example-object';
|
||
import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
|
||
|
||
export default definePageLayout({
|
||
universalIdentifier: '203aeb94-6701-46d6-9af1-be2bbcc9e134',
|
||
name: 'Example Record Page',
|
||
type: 'RECORD_PAGE',
|
||
objectUniversalIdentifier: EXAMPLE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||
tabs: [
|
||
{
|
||
universalIdentifier: '6ed26b60-a51d-4ad7-86dd-1c04c7f3cac5',
|
||
title: 'Hello World',
|
||
position: 50,
|
||
icon: 'IconWorld',
|
||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||
widgets: [
|
||
{
|
||
universalIdentifier: 'aa4234e0-2e5f-4c02-a96a-573449e2351d',
|
||
title: 'Hello World',
|
||
type: 'FRONT_COMPONENT',
|
||
configuration: {
|
||
configurationType: 'FRONT_COMPONENT',
|
||
frontComponentUniversalIdentifier:
|
||
HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
```
|
||
|
||
Hauptpunkte:
|
||
* `type` ist typischerweise `'RECORD_PAGE'`, um die Detailansicht eines bestimmten Objekts anzupassen.
|
||
* `objectUniversalIdentifier` gibt an, auf welches Objekt dieses Layout angewendet wird.
|
||
* Jeder `tab` definiert einen Abschnitt der Seite mit `title`, `position` und `layoutMode` (`CANVAS` für ein freies Layout).
|
||
* Jedes `widget` innerhalb eines Tabs kann eine Frontend-Komponente, eine Relationenliste oder andere eingebaute Widget-Typen rendern.
|
||
* `position` auf Tabs steuert deren Reihenfolge. Verwenden Sie höhere Werte (z. B. 50), um benutzerdefinierte Tabs hinter den integrierten zu platzieren.
|
||
|
||
</Accordion>
|
||
</AccordionGroup>
|
||
|
||
## Öffentliche Assets (Ordner `public/`)
|
||
|
||
Der Ordner `public/` im Stammverzeichnis Ihrer App enthält statische Dateien — Bilder, Icons, Schriftarten oder sonstige Assets, die Ihre App zur Laufzeit benötigt. Diese Dateien werden automatisch in Builds aufgenommen, während des Dev-Modus synchronisiert und auf den Server hochgeladen.
|
||
|
||
Für Dateien im Verzeichnis `public/` gilt:
|
||
|
||
* **Öffentlich zugänglich** — nach der Synchronisierung mit dem Server werden Assets unter einer öffentlichen URL bereitgestellt. Zum Zugriff ist keine Authentifizierung erforderlich.
|
||
* **In Frontend-Komponenten verfügbar** — verwenden Sie Asset-URLs, um Bilder, Icons oder andere Medien in Ihren React-Komponenten anzuzeigen.
|
||
* **In Logikfunktionen verfügbar** — referenzieren Sie Asset-URLs in E-Mails, API-Antworten oder in beliebiger serverseitiger Logik.
|
||
* **Für Marketplace-Metadaten verwendet** — die Felder `logoUrl` und `screenshots` in `defineApplication()` referenzieren Dateien aus diesem Ordner (z. B. `public/logo.png`). Diese werden im Marketplace angezeigt, wenn Ihre App veröffentlicht wird.
|
||
* **Im Dev-Modus automatisch synchronisiert** — wenn Sie in `public/` eine Datei hinzufügen, aktualisieren oder löschen, wird sie automatisch mit dem Server synchronisiert. Kein Neustart erforderlich.
|
||
* **In Builds enthalten** — `yarn twenty build` bündelt alle öffentlichen Assets in der Distributionsausgabe.
|
||
|
||
### Zugriff auf öffentliche Assets mit `getPublicAssetUrl`
|
||
|
||
Verwenden Sie den Helper `getPublicAssetUrl` aus `twenty-sdk`, um die vollständige URL einer Datei in Ihrem `public/`-Verzeichnis zu erhalten. Dies funktioniert sowohl in Logikfunktionen als auch in Frontend-Komponenten.
|
||
|
||
**In einer Logikfunktion:**
|
||
|
||
```ts src/logic-functions/send-invoice.ts
|
||
import { defineLogicFunction, getPublicAssetUrl } from 'twenty-sdk';
|
||
|
||
const handler = async (): Promise<any> => {
|
||
const logoUrl = getPublicAssetUrl('logo.png');
|
||
const invoiceUrl = getPublicAssetUrl('templates/invoice.png');
|
||
|
||
// Fetch the file content (no auth required — public endpoint)
|
||
const response = await fetch(invoiceUrl);
|
||
const buffer = await response.arrayBuffer();
|
||
|
||
return { logoUrl, size: buffer.byteLength };
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: 'a1b2c3d4-...',
|
||
name: 'send-invoice',
|
||
description: 'Sends an invoice with the app logo',
|
||
timeoutSeconds: 10,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
**In einer Frontend-Komponente:**
|
||
|
||
```tsx src/front-components/company-card.tsx
|
||
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk';
|
||
|
||
export default defineFrontComponent(() => {
|
||
const logoUrl = getPublicAssetUrl('logo.png');
|
||
|
||
return <img src={logoUrl} alt="App logo" />;
|
||
});
|
||
```
|
||
|
||
Das Argument `path` ist relativ zum `public/`-Ordner Ihrer App. Sowohl `getPublicAssetUrl('logo.png')` als auch `getPublicAssetUrl('public/logo.png')` ergeben dieselbe URL — das Präfix `public/` wird, falls vorhanden, automatisch entfernt.
|
||
|
||
## Verwendung von npm-Paketen
|
||
|
||
Sie können in Ihrer App beliebige npm-Pakete installieren und verwenden. Sowohl Logikfunktionen als auch Frontend-Komponenten werden mit [esbuild](https://esbuild.github.io/) gebündelt, das alle Abhängigkeiten in die Ausgabe einbettet — zur Laufzeit sind keine `node_modules` erforderlich.
|
||
|
||
### Ein Paket installieren
|
||
|
||
```bash filename="Terminal"
|
||
yarn add axios
|
||
```
|
||
|
||
Importieren Sie es anschließend in Ihrem Code:
|
||
|
||
```ts src/logic-functions/fetch-data.ts
|
||
import { defineLogicFunction } from 'twenty-sdk';
|
||
import axios from 'axios';
|
||
|
||
const handler = async (): Promise<any> => {
|
||
const { data } = await axios.get('https://api.example.com/data');
|
||
|
||
return { data };
|
||
};
|
||
|
||
export default defineLogicFunction({
|
||
universalIdentifier: '...',
|
||
name: 'fetch-data',
|
||
description: 'Fetches data from an external API',
|
||
timeoutSeconds: 10,
|
||
handler,
|
||
});
|
||
```
|
||
|
||
Dasselbe funktioniert für Frontend-Komponenten:
|
||
|
||
```tsx src/front-components/chart.tsx
|
||
import { defineFrontComponent } from 'twenty-sdk';
|
||
import { format } from 'date-fns';
|
||
|
||
const DateWidget = () => {
|
||
return <p>Today is {format(new Date(), 'MMMM do, yyyy')}</p>;
|
||
};
|
||
|
||
export default defineFrontComponent({
|
||
universalIdentifier: '...',
|
||
name: 'date-widget',
|
||
component: DateWidget,
|
||
});
|
||
```
|
||
|
||
### Wie das Bundling funktioniert
|
||
|
||
Der Build-Schritt (`yarn twenty dev` oder `yarn twenty build`) verwendet esbuild, um pro Logikfunktion und pro Frontend-Komponente eine einzelne, in sich geschlossene Datei zu erzeugen. Alle importierten Pakete werden in das Bundle eingebettet.
|
||
|
||
**Logikfunktionen** laufen in einer Node.js-Umgebung. Eingebaute Node.js-Module (`fs`, `path`, `crypto`, `http` usw.) stehen zur Verfügung und müssen nicht installiert werden.
|
||
|
||
**Frontend-Komponenten** laufen in einem Web Worker. Eingebaute Node.js-Module sind **nicht** verfügbar — nur Browser-APIs und npm-Pakete, die in einer Browserumgebung funktionieren.
|
||
|
||
In beiden Umgebungen stehen `twenty-client-sdk/core` und `twenty-client-sdk/metadata` als vorab bereitgestellte Module zur Verfügung — sie werden nicht gebündelt, sondern zur Laufzeit vom Server aufgelöst.
|
||
|
||
## Entitäten mit `yarn twenty add` erstellen
|
||
|
||
Anstatt Entitätsdateien manuell zu erstellen, können Sie den interaktiven Scaffolder verwenden:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty add
|
||
```
|
||
|
||
Dies fordert Sie auf, einen Entitätstyp auszuwählen, und führt Sie durch die erforderlichen Felder. Er erzeugt eine einsatzbereite Datei mit einem stabilen `universalIdentifier` und dem korrekten `defineEntity()`-Aufruf.
|
||
|
||
Sie können den Entitätstyp auch direkt übergeben, um die erste Eingabeaufforderung zu überspringen:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty add object
|
||
yarn twenty add logicFunction
|
||
yarn twenty add frontComponent
|
||
```
|
||
|
||
### Verfügbare Entitätstypen
|
||
|
||
| Entitätstyp | Befehl | Generierte Datei |
|
||
| ---------------------- | ------------------------------------ | ------------------------------------- |
|
||
| Objekt | `yarn twenty add object` | `src/objects/<name>.ts` |
|
||
| Feld | `yarn twenty add field` | `src/fields/<name>.ts` |
|
||
| Logikfunktion | `yarn twenty add logicFunction` | `src/logic-functions/<name>.ts` |
|
||
| Frontend-Komponente | `yarn twenty add frontComponent` | `src/front-components/<name>.tsx` |
|
||
| Rolle | `yarn twenty add role` | `src/roles/<name>.ts` |
|
||
| Skill | `yarn twenty add skill` | `src/skills/<name>.ts` |
|
||
| Agent | `yarn twenty add agent` | `src/agents/<name>.ts` |
|
||
| Ansicht | `yarn twenty add view` | `src/views/<name>.ts` |
|
||
| Navigationsmenüeintrag | `yarn twenty add navigationMenuItem` | `src/navigation-menu-items/<name>.ts` |
|
||
| Seitenlayout | `yarn twenty add pageLayout` | `src/page-layouts/<name>.ts` |
|
||
|
||
### Was der Scaffolder generiert
|
||
|
||
Jeder Entitätstyp hat seine eigene Vorlage. Zum Beispiel fragt `yarn twenty add object` nach:
|
||
|
||
1. **Name (Singular)** — z. B. `invoice`
|
||
2. **Name (Plural)** — z. B. `invoices`
|
||
3. **Label (Singular)** — automatisch aus dem Namen befüllt (z. B. `Invoice`)
|
||
4. **Label (Plural)** — automatisch befüllt (z. B. `Invoices`)
|
||
5. **Ansicht und Navigationseintrag erstellen?** — wenn Sie mit Ja antworten, erzeugt der Scaffolder außerdem eine passende Ansicht und einen Sidebar-Link für das neue Objekt.
|
||
|
||
Andere Entitätstypen haben einfachere Eingabeaufforderungen — die meisten fragen nur nach einem Namen.
|
||
|
||
Der Entitätstyp `field` ist detaillierter: Er fragt nach Feldname, Label, Typ (aus einer Liste aller verfügbaren Feldtypen wie `TEXT`, `NUMBER`, `SELECT`, `RELATION` usw.) sowie dem `universalIdentifier` des Zielobjekts.
|
||
|
||
### Benutzerdefinierter Ausgabepfad
|
||
|
||
Verwenden Sie den Schalter `--path`, um die generierte Datei an einem benutzerdefinierten Ort abzulegen:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty add logicFunction --path src/custom-folder
|
||
```
|
||
|
||
## Typisierte API-Clients (twenty-client-sdk)
|
||
|
||
Das Paket `twenty-client-sdk` stellt zwei typisierte GraphQL-Clients bereit, um aus Ihren Logikfunktionen und Frontend-Komponenten mit der Twenty-API zu interagieren.
|
||
|
||
| Client | Importieren | Endpunkt | Generiert? |
|
||
| ------------------- | ---------------------------- | --------------------------------------------------------- | ------------------------------------ |
|
||
| `CoreApiClient` | `twenty-client-sdk/core` | `/graphql` — Arbeitsbereichsdaten (Datensätze, Objekte) | Ja, zur Entwicklungs-/Build-Zeit |
|
||
| `MetadataApiClient` | `twenty-client-sdk/metadata` | `/metadata` — Arbeitsbereichskonfiguration, Datei-Uploads | Nein, wird vorgefertigt ausgeliefert |
|
||
|
||
<AccordionGroup>
|
||
<Accordion title="CoreApiClient" description="Arbeitsbereichsdaten (Datensätze, Objekte) abfragen und ändern">
|
||
|
||
Der `CoreApiClient` ist der Haupt-Client zum Abfragen und Ändern von Arbeitsbereichsdaten. Er wird während `yarn twenty dev` oder `yarn twenty build` **aus Ihrem Arbeitsbereichsschema generiert** und ist daher vollständig typisiert, passend zu Ihren Objekten und Feldern.
|
||
|
||
```ts
|
||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||
|
||
const client = new CoreApiClient();
|
||
|
||
// Query records
|
||
const { companies } = await client.query({
|
||
companies: {
|
||
edges: {
|
||
node: {
|
||
id: true,
|
||
name: true,
|
||
domainName: {
|
||
primaryLinkLabel: true,
|
||
primaryLinkUrl: true,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
// Create a record
|
||
const { createCompany } = await client.mutation({
|
||
createCompany: {
|
||
__args: {
|
||
data: {
|
||
name: 'Acme Corp',
|
||
},
|
||
},
|
||
id: true,
|
||
name: true,
|
||
},
|
||
});
|
||
```
|
||
|
||
Der Client verwendet eine Selection-Set-Syntax: Übergeben Sie `true`, um ein Feld einzuschließen, verwenden Sie `__args` für Argumente, und verschachteln Sie Objekte für Relationen. Sie erhalten vollständige Autovervollständigung und Typprüfung basierend auf Ihrem Arbeitsbereichsschema.
|
||
|
||
<Note>
|
||
**Der CoreApiClient wird zur Entwicklungs-/Build-Zeit generiert.** Wenn Sie ihn verwenden, ohne zuvor `yarn twenty dev` oder `yarn twenty build` ausgeführt zu haben, wird ein Fehler ausgelöst. Die Generierung erfolgt automatisch — die CLI inspiziert das GraphQL-Schema Ihres Arbeitsbereichs und erzeugt mit `@genql/cli` einen typisierten Client.
|
||
</Note>
|
||
|
||
#### Verwendung von CoreSchema für Typannotationen
|
||
|
||
`CoreSchema` stellt TypeScript-Typen bereit, die Ihren Arbeitsbereichsobjekten entsprechen — nützlich zum Typisieren von Komponentenzustand oder Funktionsparametern:
|
||
|
||
```ts
|
||
import { CoreApiClient, CoreSchema } from 'twenty-client-sdk/core';
|
||
import { useState } from 'react';
|
||
|
||
const [company, setCompany] = useState<
|
||
Pick<CoreSchema.Company, 'id' | 'name'> | undefined
|
||
>(undefined);
|
||
|
||
const client = new CoreApiClient();
|
||
const result = await client.query({
|
||
company: {
|
||
__args: { filter: { position: { eq: 1 } } },
|
||
id: true,
|
||
name: true,
|
||
},
|
||
});
|
||
setCompany(result.company);
|
||
```
|
||
|
||
</Accordion>
|
||
<Accordion title="MetadataApiClient" description="Konfiguration des Arbeitsbereichs, Anwendungen und Dateiuploads">
|
||
|
||
`MetadataApiClient` ist im SDK bereits vorgefertigt enthalten (keine Generierung erforderlich). Er fragt den Endpunkt `/metadata` nach Arbeitsbereichskonfiguration, Anwendungen und Datei-Uploads ab.
|
||
|
||
```ts
|
||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||
|
||
const metadataClient = new MetadataApiClient();
|
||
|
||
// List first 10 objects in the workspace
|
||
const { objects } = await metadataClient.query({
|
||
objects: {
|
||
edges: {
|
||
node: {
|
||
id: true,
|
||
nameSingular: true,
|
||
namePlural: true,
|
||
labelSingular: true,
|
||
isCustom: true,
|
||
},
|
||
},
|
||
__args: {
|
||
filter: {},
|
||
paging: { first: 10 },
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
#### Dateien hochladen
|
||
|
||
Der `MetadataApiClient` enthält eine Methode `uploadFile`, um Dateien an Felder des Typs Datei anzuhängen:
|
||
|
||
```ts
|
||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||
import * as fs from 'fs';
|
||
|
||
const metadataClient = new MetadataApiClient();
|
||
|
||
const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||
|
||
const uploadedFile = await metadataClient.uploadFile(
|
||
fileBuffer, // file contents as a Buffer
|
||
'invoice.pdf', // filename
|
||
'application/pdf', // MIME type
|
||
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universalIdentifier
|
||
);
|
||
|
||
console.log(uploadedFile);
|
||
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
|
||
```
|
||
|
||
| Parameter | Typ | Beschreibung |
|
||
| ---------------------------------- | -------- | --------------------------------------------------------------------- |
|
||
| `fileBuffer` | `Buffer` | Der Rohinhalt der Datei |
|
||
| `filename` | `string` | Der Name der Datei (wird für Speicherung und Anzeige verwendet) |
|
||
| `contentType` | `string` | MIME-Typ (standardmäßig `application/octet-stream`, wenn weggelassen) |
|
||
| `fieldMetadataUniversalIdentifier` | `string` | Der `universalIdentifier` des Dateityp-Felds in Ihrem Objekt |
|
||
|
||
Hauptpunkte:
|
||
* Sie verwendet den `universalIdentifier` des Feldes (nicht dessen arbeitsbereichsspezifische ID), sodass Ihr Upload-Code in jedem Arbeitsbereich funktioniert, in dem Ihre App installiert ist.
|
||
* Die zurückgegebene `url` ist eine signierte URL, mit der Sie auf die hochgeladene Datei zugreifen können.
|
||
|
||
</Accordion>
|
||
</AccordionGroup>
|
||
|
||
<Note>
|
||
Wenn Ihr Code auf Twenty ausgeführt wird (Logikfunktionen oder Frontend-Komponenten), injiziert die Plattform Anmeldedaten als Umgebungsvariablen:
|
||
|
||
* `TWENTY_API_URL` — Basis-URL der Twenty-API
|
||
* `TWENTY_APP_ACCESS_TOKEN` — Kurzlebiger Schlüssel, der auf die Standard-Funktionsrolle Ihrer Anwendung begrenzt ist
|
||
|
||
Sie müssen diese **nicht** an die Clients übergeben — sie lesen automatisch aus `process.env`. Die Berechtigungen des API-Schlüssels werden durch die Rolle bestimmt, auf die in `defaultRoleUniversalIdentifier` in Ihrer `application-config.ts` verwiesen wird.
|
||
</Note>
|
||
|
||
## Ihre App testen
|
||
|
||
Das SDK stellt programmgesteuerte APIs bereit, mit denen Sie Ihre App aus Testcode heraus bauen, bereitstellen, installieren und deinstallieren können. In Kombination mit [Vitest](https://vitest.dev/) und den typisierten API-Clients können Sie Integrationstests schreiben, die prüfen, dass Ihre App End-to-End gegen einen echten Twenty-Server funktioniert.
|
||
|
||
### Einrichtung
|
||
|
||
Die erzeugte App enthält bereits Vitest. Wenn Sie es manuell einrichten, installieren Sie die Abhängigkeiten:
|
||
|
||
```bash filename="Terminal"
|
||
yarn add -D vitest vite-tsconfig-paths
|
||
```
|
||
|
||
Erstellen Sie eine `vitest.config.ts` im Stammverzeichnis Ihrer App:
|
||
|
||
```ts vitest.config.ts
|
||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||
import { defineConfig } from 'vitest/config';
|
||
|
||
export default defineConfig({
|
||
plugins: [
|
||
tsconfigPaths({
|
||
projects: ['tsconfig.spec.json'],
|
||
ignoreConfigErrors: true,
|
||
}),
|
||
],
|
||
test: {
|
||
testTimeout: 120_000,
|
||
hookTimeout: 120_000,
|
||
include: ['src/**/*.integration-test.ts'],
|
||
setupFiles: ['src/__tests__/setup-test.ts'],
|
||
env: {
|
||
TWENTY_API_URL: 'http://localhost:2020',
|
||
TWENTY_API_KEY: 'your-api-key',
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
Erstellen Sie eine Setup-Datei, die vor dem Testlauf überprüft, dass der Server erreichbar ist:
|
||
|
||
```ts src/__tests__/setup-test.ts
|
||
import * as fs from 'fs';
|
||
import * as os from 'os';
|
||
import * as path from 'path';
|
||
import { beforeAll } from 'vitest';
|
||
|
||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
|
||
|
||
beforeAll(async () => {
|
||
// Verify the server is running
|
||
const response = await fetch(`${TWENTY_API_URL}/healthz`);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
|
||
'Start the server before running integration tests.',
|
||
);
|
||
}
|
||
|
||
// Write a temporary config for the SDK
|
||
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
|
||
|
||
fs.writeFileSync(
|
||
path.join(TEST_CONFIG_DIR, 'config.json'),
|
||
JSON.stringify({
|
||
remotes: {
|
||
local: {
|
||
apiUrl: process.env.TWENTY_API_URL,
|
||
apiKey: process.env.TWENTY_API_KEY,
|
||
},
|
||
},
|
||
defaultRemote: 'local',
|
||
}, null, 2),
|
||
);
|
||
});
|
||
```
|
||
|
||
### Programmgesteuerte SDK-APIs
|
||
|
||
Der Subpfad `twenty-sdk/cli` exportiert Funktionen, die Sie direkt aus Testcode aufrufen können:
|
||
|
||
| Funktion | Beschreibung |
|
||
| -------------- | ----------------------------------------------------- |
|
||
| `appBuild` | Die App bauen und optional ein Tarball packen |
|
||
| `appDeploy` | Ein Tarball auf den Server hochladen |
|
||
| `appInstall` | Die App im aktiven Arbeitsbereich installieren |
|
||
| `appUninstall` | Die App aus dem aktiven Arbeitsbereich deinstallieren |
|
||
|
||
Jede Funktion gibt ein Ergebnisobjekt mit `success: boolean` und entweder `data` oder `error` zurück.
|
||
|
||
### Einen Integrationstest schreiben
|
||
|
||
Hier ist ein vollständiges Beispiel, das die App baut, bereitstellt und installiert und anschließend prüft, dass sie im Arbeitsbereich erscheint:
|
||
|
||
```ts src/__tests__/app-install.integration-test.ts
|
||
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/application-config';
|
||
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
|
||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
|
||
const APP_PATH = process.cwd();
|
||
|
||
describe('App installation', () => {
|
||
beforeAll(async () => {
|
||
const buildResult = await appBuild({
|
||
appPath: APP_PATH,
|
||
tarball: true,
|
||
onProgress: (message: string) => console.log(`[build] ${message}`),
|
||
});
|
||
|
||
if (!buildResult.success) {
|
||
throw new Error(`Build failed: ${buildResult.error?.message}`);
|
||
}
|
||
|
||
const deployResult = await appDeploy({
|
||
tarballPath: buildResult.data.tarballPath!,
|
||
onProgress: (message: string) => console.log(`[deploy] ${message}`),
|
||
});
|
||
|
||
if (!deployResult.success) {
|
||
throw new Error(`Deploy failed: ${deployResult.error?.message}`);
|
||
}
|
||
|
||
const installResult = await appInstall({ appPath: APP_PATH });
|
||
|
||
if (!installResult.success) {
|
||
throw new Error(`Install failed: ${installResult.error?.message}`);
|
||
}
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await appUninstall({ appPath: APP_PATH });
|
||
});
|
||
|
||
it('should find the installed app in the workspace', async () => {
|
||
const metadataClient = new MetadataApiClient();
|
||
|
||
const result = await metadataClient.query({
|
||
findManyApplications: {
|
||
id: true,
|
||
name: true,
|
||
universalIdentifier: true,
|
||
},
|
||
});
|
||
|
||
const installedApp = result.findManyApplications.find(
|
||
(app: { universalIdentifier: string }) =>
|
||
app.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
|
||
);
|
||
|
||
expect(installedApp).toBeDefined();
|
||
});
|
||
});
|
||
```
|
||
|
||
### Tests ausführen
|
||
|
||
Stellen Sie sicher, dass Ihr lokaler Twenty-Server läuft, und führen Sie dann Folgendes aus:
|
||
|
||
```bash filename="Terminal"
|
||
yarn test
|
||
```
|
||
|
||
Oder im Watch-Modus während der Entwicklung:
|
||
|
||
```bash filename="Terminal"
|
||
yarn test:watch
|
||
```
|
||
|
||
### Typprüfung
|
||
|
||
Sie können die Typprüfung Ihrer App auch ohne Tests ausführen:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty typecheck
|
||
```
|
||
|
||
Dies führt `tsc --noEmit` aus und meldet etwaige Typfehler.
|
||
|
||
## CLI-Referenz
|
||
|
||
Zusätzlich zu `dev`, `build`, `add` und `typecheck` bietet die CLI Befehle zum Ausführen von Funktionen, Anzeigen von Logs und Verwalten von App-Installationen.
|
||
|
||
### Funktionen ausführen (`yarn twenty exec`)
|
||
|
||
Eine Logikfunktion manuell ausführen, ohne sie über HTTP, Cron oder ein Datenbankereignis auszulösen:
|
||
|
||
```bash filename="Terminal"
|
||
# Execute by function name
|
||
yarn twenty exec -n create-new-post-card
|
||
|
||
# Execute by universalIdentifier
|
||
yarn twenty exec -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||
|
||
# Pass a JSON payload
|
||
yarn twenty exec -n create-new-post-card -p '{"name": "Hello"}'
|
||
|
||
# Execute pre-install or post-install functions
|
||
yarn twenty exec --preInstall
|
||
yarn twenty exec --postInstall
|
||
```
|
||
|
||
### Funktionsprotokolle ansehen (`yarn twenty logs`)
|
||
|
||
Ausführungsprotokolle für die Logikfunktionen Ihrer App streamen:
|
||
|
||
```bash filename="Terminal"
|
||
# Stream all function logs
|
||
yarn twenty logs
|
||
|
||
# Filter by function name
|
||
yarn twenty logs -n create-new-post-card
|
||
|
||
# Filter by universalIdentifier
|
||
yarn twenty logs -u e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf
|
||
```
|
||
|
||
<Note>
|
||
Dies unterscheidet sich von `yarn twenty server logs`, das die Docker-Container-Logs anzeigt. `yarn twenty logs` zeigt die Funktionsausführungsprotokolle Ihrer App vom Twenty-Server.
|
||
</Note>
|
||
|
||
### Eine App deinstallieren (`yarn twenty uninstall`)
|
||
|
||
Entfernen Sie Ihre App aus dem aktiven Arbeitsbereich:
|
||
|
||
```bash filename="Terminal"
|
||
yarn twenty uninstall
|
||
|
||
# Skip the confirmation prompt
|
||
yarn twenty uninstall --yes
|
||
```
|
||
|
||
## Remotes verwalten
|
||
|
||
Ein **Remote** ist ein Twenty-Server, mit dem sich Ihre App verbindet. Während der Einrichtung erstellt das Scaffolding-Tool automatisch eines für Sie. Sie können jederzeit weitere Remotes hinzufügen oder zwischen ihnen wechseln.
|
||
|
||
```bash filename="Terminal"
|
||
# Add a new remote (opens a browser for OAuth login)
|
||
yarn twenty remote add
|
||
|
||
# Connect to a local Twenty server (auto-detects port 2020 or 3000)
|
||
yarn twenty remote add --local
|
||
|
||
# Add a remote non-interactively (useful for CI)
|
||
yarn twenty remote add --api-url https://your-twenty-server.com --api-key $TWENTY_API_KEY --as my-remote
|
||
|
||
# List all configured remotes
|
||
yarn twenty remote list
|
||
|
||
# Switch the active remote
|
||
yarn twenty remote switch <name>
|
||
```
|
||
|
||
Ihre Anmeldedaten werden in `~/.twenty/config.json` gespeichert.
|
||
|
||
## CI mit GitHub Actions
|
||
|
||
Das Scaffolding-Tool erzeugt einen einsatzbereiten GitHub-Actions-Workflow in `.github/workflows/ci.yml`. Er führt Ihre Integrationstests automatisch bei jedem Push auf `main` und bei Pull Requests aus.
|
||
|
||
Der Workflow:
|
||
|
||
1. Checkt Ihren Code aus
|
||
2. Startet einen temporären Twenty-Server mit der Aktion `twentyhq/twenty/.github/actions/spawn-twenty-docker-image`
|
||
3. Installiert Abhängigkeiten mit `yarn install --immutable`
|
||
4. Führt `yarn test` aus, wobei `TWENTY_API_URL` und `TWENTY_API_KEY` aus den Aktionsausgaben injiziert werden.
|
||
|
||
```yaml .github/workflows/ci.yml
|
||
name: CI
|
||
|
||
on:
|
||
push:
|
||
branches:
|
||
- main
|
||
pull_request: {}
|
||
|
||
env:
|
||
TWENTY_VERSION: latest
|
||
|
||
jobs:
|
||
test:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@v4
|
||
|
||
- name: Spawn Twenty instance
|
||
id: twenty
|
||
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
|
||
with:
|
||
twenty-version: ${{ env.TWENTY_VERSION }}
|
||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- 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.access-token }}
|
||
```
|
||
|
||
Sie müssen keine Secrets konfigurieren — die Aktion `spawn-twenty-docker-image` startet einen flüchtigen Twenty-Server direkt im Runner und gibt die Verbindungsdetails aus. Das Secret `GITHUB_TOKEN` wird automatisch von GitHub bereitgestellt.
|
||
|
||
Um eine bestimmte Twenty-Version statt `latest` festzulegen, ändern Sie die Umgebungsvariable `TWENTY_VERSION` oben im Workflow.
|